External widget — deploy
Deploying the widget means uploading a new built version of snippet.js and ext/2/snippet.js to Cloudflare R2 and optionally purging the CDN cache for those two files. Finder payloads are never uploaded by this script — payload files are written to R2 by the backend’s ExternalController::syncFinderDetails on each publish. The deploy script only handles the widget bundle itself.
When to deploy vs. when to publish
| Action | Triggered by | What changes |
|---|---|---|
Widget deploy (npm run deploy:02) | Developer, after a code change to the widget bundle | snippet.js + ext/2/snippet.js in R2 |
| Finder publish (dashboard “Publish” button) | End user or API | finders/{key}/config.json + finders/{key}/v/{hash}.json in R2 |
Publishing a finder does not re-upload the widget bundle. Deploying the widget does not update any finder payloads. The two operations are fully independent.
Build step
From ext/2/source/:
npm run buildThis runs Vite and produces:
ext/2/snippet.js— the compiled widget runtime (one directory up fromsource/)snippet.jsat the repo root (the entry-point loader, four directories up via../../../../snippet.js)
The deploy script resolves source paths relative to scripts/deploy-r2.mjs using import.meta.dirname (deploy-r2.mjs:87), so build artifacts must exist before running the script.
Running the deploy
node scripts/deploy-r2.mjsOr, if a deploy:02 npm script is defined in package.json, npm run deploy:02 from ext/2/source/.
Dry-run mode
Set DEPLOY_DRY_RUN=1 to print what would be uploaded without touching R2 or calling the Cloudflare purge API:
DEPLOY_DRY_RUN=1 node scripts/deploy-r2.mjsdeploy-r2.mjs:42 — const DRY_RUN = process.env.DEPLOY_DRY_RUN === '1';
In dry-run mode:
- Upload steps log
Dry run: {key} ({bytes} bytes)and skip thePutObjectCommand(deploy-r2.mjs:90–92). - The Cloudflare purge step logs
Dry run: skipped Cloudflare purge.and returns early (deploy-r2.mjs:110–112).
Environment variables
The script reads from the environment, with a fallback loader that reads .env.development from ext/2/source/ if it exists (deploy-r2.mjs:4–37). Variables already set in the environment are never overwritten by the file loader.
Required
| Variable | Used for |
|---|---|
R2_ACCOUNT_ID | Constructs the R2 endpoint URL |
R2_ACCESS_KEY_ID | S3-compatible credentials |
R2_SECRET_ACCESS_KEY | S3-compatible credentials |
R2_BUCKET | Target bucket name |
Missing required variables cause the script to throw and exit with code 1 (deploy-r2.mjs:59–67).
Optional
| Variable | Default | Used for |
|---|---|---|
R2_ENDPOINT | https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com | Override R2 endpoint (e.g., for local dev with Miniflare) |
R2_REGION | auto | S3 region parameter |
CLOUDFLARE_ZONE_ID or CF_ZONE_ID | — | Cloudflare zone for cache purge |
CLOUDFLARE_API_TOKEN or CF_API_TOKEN | — | Cloudflare API token for cache purge |
CDN_BASE_URL | https://cdn.locationfinders.com | Constructs the full URLs passed to the purge API |
DEPLOY_DRY_RUN | — | Set to 1 to enable dry-run mode |
deploy-r2.mjs:114–116 — If CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN are both absent, the cache purge step is skipped with a warning rather than failing:
if (!zoneId || !apiToken) {
console.log('Skipped Cloudflare purge: CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN are not set.');
return;
}This means you can perform R2 uploads without Cloudflare credentials — useful for testing or when the Cloudflare zone is managed separately.
Upload targets
deploy-r2.mjs:44–57 — The ASSETS array defines exactly two upload targets:
const ASSETS = [
{
source: '../../../../snippet.js', // repo root loader
key: 'snippet.js',
contentType: 'text/javascript; charset=utf-8',
cacheControl: 'public, max-age=300, must-revalidate',
},
{
source: '../../snippet.js', // widget runtime
key: 'ext/2/snippet.js',
contentType: 'text/javascript; charset=utf-8',
cacheControl: 'public, max-age=300, must-revalidate',
},
];Both files are uploaded with a 5-minute (max-age=300) cache and must-revalidate. This is intentionally short: a broken widget deploy must be recoverable within 5 minutes without a manual purge.
Cache purge
deploy-r2.mjs:108–139 — After uploads succeed, the script calls the Cloudflare cache purge API for both file URLs:
const files = ASSETS.map((asset) => `${CDN_BASE_URL.replace(/\/$/, '')}/${asset.key}`);
// → ['https://cdn.locationfinders.com/snippet.js', 'https://cdn.locationfinders.com/ext/2/snippet.js']
await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ files }),
});A non-OK response or data.success === false throws an error and exits with code 1 (deploy-r2.mjs:134–136).
Rollback
There is no automated rollback in the script. To roll back a bad deploy:
- Check out the previous tagged or branched build of the widget source.
- Run
npm run buildto rebuild the old bundle. - Run
node scripts/deploy-r2.mjsto upload and purge.
Old widget builds are not retained in R2 — each deploy overwrites the two keys. If the previous build artifact is unavailable, it must be rebuilt from source control.
R2 client setup
deploy-r2.mjs:73–84 — The script uses @aws-sdk/client-s3 with an S3Client pointed at the R2 endpoint. The endpoint is constructed as https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com unless overridden by R2_ENDPOINT. The region is always auto unless overridden.
return new S3Client({
region: process.env.R2_REGION ?? 'auto',
endpoint: getR2Endpoint(accountId),
credentials: {
accessKeyId: requiredEnv('R2_ACCESS_KEY_ID'),
secretAccessKey: requiredEnv('R2_SECRET_ACCESS_KEY'),
},
});Related pages
- overview.md — widget architecture overview
- cdn-and-pointer.md — how the widget reads from R2 at runtime
- storage-and-cdn.md — R2/CDN pattern and bucket layout
- data-flow-builder.md — how finder payloads get into R2 on publish