These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

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

ActionTriggered byWhat changes
Widget deploy (npm run deploy:02)Developer, after a code change to the widget bundlesnippet.js + ext/2/snippet.js in R2
Finder publish (dashboard “Publish” button)End user or APIfinders/{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 build

This runs Vite and produces:

  • ext/2/snippet.js — the compiled widget runtime (one directory up from source/)
  • snippet.js at 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.mjs

Or, 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.mjs

deploy-r2.mjs:42const DRY_RUN = process.env.DEPLOY_DRY_RUN === '1';

In dry-run mode:

  • Upload steps log Dry run: {key} ({bytes} bytes) and skip the PutObjectCommand (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

VariableUsed for
R2_ACCOUNT_IDConstructs the R2 endpoint URL
R2_ACCESS_KEY_IDS3-compatible credentials
R2_SECRET_ACCESS_KEYS3-compatible credentials
R2_BUCKETTarget bucket name

Missing required variables cause the script to throw and exit with code 1 (deploy-r2.mjs:59–67).

Optional

VariableDefaultUsed for
R2_ENDPOINThttps://{R2_ACCOUNT_ID}.r2.cloudflarestorage.comOverride R2 endpoint (e.g., for local dev with Miniflare)
R2_REGIONautoS3 region parameter
CLOUDFLARE_ZONE_ID or CF_ZONE_IDCloudflare zone for cache purge
CLOUDFLARE_API_TOKEN or CF_API_TOKENCloudflare API token for cache purge
CDN_BASE_URLhttps://cdn.locationfinders.comConstructs the full URLs passed to the purge API
DEPLOY_DRY_RUNSet 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:

  1. Check out the previous tagged or branched build of the widget source.
  2. Run npm run build to rebuild the old bundle.
  3. Run node scripts/deploy-r2.mjs to 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'), }, });