# Splatoon 2 updater Worker Runs the existing updaters and Bluesky posts on Cloudflare. `BucketStorage` uses R2 instead of the local runner's `dist/` and `storage/` directories. Private state stays in R2; no database migration is needed. ## Scheduling and manual runs One `Scheduler` Durable Object owns the update → social pipeline. Its alarm targets ten seconds past each hour, matching the old Node cron. Alarms normally have low drift, but maintenance/failover can delay them; this is not a hard real-time deadline. The `wnam` location hint is best effort and correctness does not depend on where the object runs. There is no per-run colo probe. - The pipeline runs all updaters and posts only if all succeed. - A failed hourly pipeline retries after one minute, up to three retries, then returns to the next hour. Successful posts keep their existing per-post Bluesky timestamps so a retry skips them. - The minute-30 cron checks/re-arms the alarm. The object may sleep between alarms; the watchdog repairs scheduling, rather than keeping a process alive. - Manual runs use the same object. A request during an active run returns HTTP 409; retry it later. The admin panel can persist one background manual request. There is no `/wake` or `/post` endpoint. An hourly alarm that encounters a manual run remains due. - `POST /run` runs the complete pipeline and waits for its result. With `only`, it repairs the named updaters and skips social posting. Unknown names fail. - The existing object name and hourly/retry state survive deployment. Pending requests from the old scheduler become one full run before normal scheduling resumes. `GET /status` reports the due time, retries, last hourly run, last manual run, and whether the object is busy. Run summaries include actual start/completion information, updater results and per-post/client social outcomes. HTTP runs return 200 for success, 409 when busy, or 502 for a failed pipeline. Storage/RPC errors return 500. Inspect the response body and Worker logs for details. Only one backend may write production at a time. Coordination in this Worker does not serialize it with the old server or a locally started runner. A social API accepting a post followed by a failed timestamp write still leaves an ambiguous outcome; storage cannot make those two external operations atomic. ## Social posts and screenshots ### Public routing and caching Cloudflare Pages serves the frontend. Cloud Connector serves individual `/data/`, `/assets/splatnet/`, and `/twitter-images/` files directly from R2 at their original site URLs; do not redirect file requests to the asset host. Production `/data`, slash-terminated `/data/` directories, and slash-terminated `/assets/splatnet/` directories redirect to the browser at `assets.splatoon2.ink`. The asset-browser Worker handles directory listings only. Cloudflare's `Long-lived immutable media` cache rule uses a one-year edge TTL for production `/assets/` files (excluding directories and HTML), and image files under `assets.splatoon2.ink/assets/splatnet/`. The zone's Browser Cache TTL is **Respect Existing Headers**; the old `/assets/*` Page Rule that forced a one-month browser TTL is disabled. These settings are managed in Cloudflare, not Wrangler. Do not add a browser TTL override to obtain longer edge caching. Rotating public images under `/twitter-images/` reuse their filenames. The updater writes them with `Cache-Control: no-cache` so caches revalidate before reuse. Keep them outside the long-lived asset rule. On September 15, 2026, production routing and cache settings were verified, the existing schedule and gear PNGs received this metadata, and their old edge-cache entries were purged. ### Posting pipeline `src/app/social` posts to Bluesky. Twitter/X support and its dependency have been removed. Local commands are `npm run social` and `npm run social:test`. The existing `bluesky-lastPostTimes.json`, `salmonrun-previousSchedule.json`, and `stages.json` keys are preserved. Public images remain at `twitter-images/` to preserve existing URLs; that legacy directory name does not enable X posting. With no Bluesky credentials the runner generates public images only. Shadow runs still update some private state, including the remembered Salmon Run shift. Each post captures one PNG. The public copy stays in R2; when Bluesky needs JPEG, the Worker's `IMAGES` binding converts those same PNG bytes at quality 90. The local Node runner uses `sharp` through a conditional package import, keeping native dependencies out of the Worker bundle. Conversion failures fail that client's post without advancing its timestamp; no second browser capture is used. Public-image-only shadow runs do not request conversions. The Images Free plan includes 5,000 unique transformations per month; on that plan, new transformations beyond the quota fail instead of incurring charges. This uses transformations only, not paid Images storage. See [Images pricing](https://developers.cloudflare.com/images/pricing/). Browser Rendering opens `SITE_URL/screenshots.html`. Before rendering or posting, the Worker compares that site's five data JSON files and English localization against its own public bucket. A mismatch fails the run so the scheduler can retry. Point `SITE_URL` at a preview site serving the dev bucket to validate shadow output; production is suitable only when its data matches. The screenshot request asks the browser to revalidate cached resources. The preflight validates JSON, not pixel output or an atomic snapshot across a CDN. Visually check generated images before cutover. Nintendo, Bluesky, and rendering-site checks have 30-second network deadlines, including body consumption. Browser Rendering uses 10-second navigation, page-ready, and capture limits, through the Browser binding. It waits for `data-screenshot-ready="true"` after data, Vue rendering, fonts, images and layout settle, instead of waiting for network idle. Deploy the updated screenshot page before the Worker that requires this marker. Transient screenshot errors (timeouts, network failures, and HTTP 5xx) get up to three retries after the first attempt, with 0.5/1/2-second backoff. Authentication, rate-limit and non-timeout validation errors fail immediately. These retries only repeat rendering, never the social send; exhausted failures still reach the hourly pipeline's bounded retry mechanism. Errors are reported rather than logged as successful social runs. ## Local screenshots and Browser Run testing Node commands use Puppeteer; the Worker uses `BrowserRunRenderer` with its `BROWSER` binding. `ScreenshotGenerator` owns the shared routes, viewport, and page-ready selector; either renderer returns PNG bytes. No API token or account ID is needed for screenshots, and no package import conditions select renderers. With Puppeteer, leave `SITE_URL` empty to temporarily serve the built `dist/` on loopback. Run `npm run build` first and provide the usual data/assets in `dist/`. Puppeteer installs its own Chrome; Browserless is not required. Set `SITE_URL=http://127.0.0.1:8080` to use `npm run serve` instead. The Vue dev server already serves data/assets from `dist/`; keep those files aligned with the data the social test reads. Local rendering waits for the page-ready signal and uses 10-second navigation, readiness, and browser-protocol timeouts. ```sh # Generate the social test images against the local build. SITE_URL= npm run social:test # Capture one route; replace the timestamp with a rotation in your data. SITE_URL= npm run screenshot -- \ --hash '/schedules/1788652800' --output dist/test-screenshots/schedule.png # Capture a running dev server directly. npm run screenshot -- \ --url 'http://127.0.0.1:8080/screenshots.html#/schedules/1788652800' # Start the capture-only Worker locally, with a remote Browser binding. npm run screenshot:cloudflare # In another terminal, save a capture from a publicly reachable screenshot page. curl --fail-with-body --get 'http://127.0.0.1:8789/' \ --data-urlencode 'url=https://dev.splatoon2.ink/screenshots.html#/schedules/1788652800' \ --output /tmp/cloudflare.png ``` `npm run screenshot` loads `.env`, accepts either `--url` or `--hash`, and saves a PNG using local Chrome. `--hash` uses `SITE_URL` or the temporary dist server. `npm run screenshot:cloudflare` runs the small `workers/screenshots` entry point through Wrangler. Sign in with `npx wrangler login` if needed. Only the browser runs remotely; the capture endpoint listens on loopback port 8789. This entry point is for local development, not deployment. Cloudflare cannot reach localhost: use a deployed preview or tunnel for an unpublished frontend. Remote captures use the account's Browser Run allowance. Both paths are capture-only: no updates, social sends, checkpoints, dataset comparisons, or JPEG conversion. All screenshot pages must provide the readiness marker. The updater's social pipeline retains its published-data checks. ## Shadow testing and cutover The `dev` environment deploys `splatoon2-ink-dev-updater`, using `splatoon2-ink-dev-assets`, `splatoon2-ink-dev-private`, and `https://dev.splatoon2.ink`. The `production` environment deploys `splatoon2-ink-updater`, using the production buckets and site. Each Worker owns its own scheduler and secrets. Leave Bluesky credentials unset in dev. The npm updater development, deployment, dry-run and tail commands select dev. Use `npm run updater:deploy:production` for a manual production deployment. Neither environment starts automatic scheduling on a fresh scheduler. Cloudflare Workers Builds deploys pushes to `develop` to the dev Worker and pushes to `main` to the production Worker. Both use these dashboard settings: - Repository root: `/` - Build command: `npm run lint -- --max-warnings 0 && npm test` - Deploy command: `npx wrangler deploy --config workers/updater/wrangler.jsonc --env dev` for dev, or the same command with `--env production` for production - Build variable: `NODE_VERSION=22` - Preview builds for other branches: disabled The frontend deploys separately through Cloudflare Pages. Deployments preserve the stored scheduling toggle; they do not enable automatic updates. Production cut over on September 13, 2026. The old backend is stopped, public data and private checkpoints are in the production R2 buckets, and hourly scheduling is enabled. The production admin panel is at `https://admin.splatoon2.ink/`, protected by its own Access application using the same owner-only policy as dev. 1. Run the tests, build, and deployment dry run below. Compare old/new public data using `scripts/compare-data.mjs` on downloaded bucket directories. 2. Serve the built frontend with `/data/` and `/assets/` backed by the dev bucket, then set `SITE_URL` to that preview site's origin. Keep Bluesky credentials unset. Run the full pipeline and visually inspect its images. 3. At cutover, stop the old scheduler and let its current run finish. Pause the shadow Worker before copying state: clearing the cron alone does **not** stop the object's existing alarms. Do not leave two writers active. 4. Copy the latest production private state (especially the Bluesky last-post times and previous Salmon Run shift) to the intended Worker private bucket. Preserve an old-state backup and the old deployment for rollback. 5. Set `ASSETS` to the production bucket, confirm `PRIVATE`, and set `SITE_URL` to the production site that serves that bucket. Add the Bluesky credentials. Deploy and arm the Worker; check its full run, data freshness, images, and next alarm. Deploy the frontend refresh changes as part of this rollout. 6. For rollback, stop the Worker/alarms before restarting the old backend. Transfer the latest Bluesky checkpoints back so already-sent posts remain recorded. Restore the prior site/data configuration as needed. These are rollout steps, not actions performed by tests or a dry run. To stop this Worker safely for cutover/rollback, use authenticated `POST /pause`; this persists the pause and deletes the alarm. `/arm` resumes scheduling; a saved overdue run executes immediately. Do not pause in the middle of a run: a busy pause request returns 409 so the caller can retry. ## Admin panel The admin panels at `https://admin.splatoon2.ink/` and `https://admin.dev.splatoon2.ink/` provide data-only, social-only, and full manual runs. The authenticated browser starts a persisted request and polls its status; closing the tab does not cancel the job. One manual request can be pending at a time, and it shares the hourly scheduler's lock. Social runs keep normal checkpoints and the published-data check. An interrupted manual run is reported as failed rather than automatically replaying an uncertain social send. The hourly schedule is preserved. Paused scheduling also blocks manual runs. The panel polls live application log lines every two seconds during a run and retains the latest 50 manual and scheduled runs in one history, including failed attempts. Older deployments contribute their two existing results. Each run stores up to the latest 200 lines (500 characters each, 32 KB total) with its final summary. Known secret values are redacted from captured lines. This includes updater, social, and screenshot-retry messages, not platform or third-party library logs. Live lines are held in memory until completion; an isolate interruption can lose those lines. Full operational logs remain available through Workers logging. Preview the actual panel with simulated results using `npm run admin:preview`, then open `http://127.0.0.1:8788/admin/`. This standalone preview server listens only on loopback and has no production credentials or bindings. The production Worker has no local-authentication bypass. The dev admin hostname is attached as a Worker Custom Domain and protected by the "Splatoon2 dev admin" Access application using the existing owner-only policy. `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are stored as Worker secrets to keep account-specific configuration out of the public repository. `/admin/` remains an alias; the panel API stays under `/admin/api/`. For another deployment: 1. Create a Cloudflare Access self-hosted application protecting the entire chosen admin hostname. Allow only the owner's identity, with email one-time codes or their preferred provider. 2. Configure the updater with `ADMIN_HOSTNAME`, `ACCESS_TEAM_DOMAIN` (the bare `.cloudflareaccess.com` hostname), and `ACCESS_AUD` (the application's audience tag). All admin routes fail closed without these settings. 3. Attach the admin hostname to this Worker and deploy. Open `/` and verify login, status, and a deliberate test run. No Access application or production admin domain is created by the local preview. The Worker verifies JWT signature, issuer, audience, expiration, and hostname. Mutating browser requests also require a matching Origin and JSON content type. The existing bearer-token API remains available for scripts, independently of Access. The panel exposes an **Automatic scheduling** toggle. Its value is saved in the scheduler Durable Object and survives deployments. Turning it off clears the hourly/retry schedule, but manual runs still work. The watchdog respects the setting, so leave its cron configured. Turning it on schedules the next hour at :00:10; missed hours are not replayed. Wait for an active or queued run to finish before toggling. The separate API-only `/pause` remains a maintenance stop: it also blocks manual runs. `/arm` clears that maintenance pause without changing the automatic setting. The panel does not expose force-repost or maintenance pause/resume controls. ## Configuration and local commands Secrets: `NINTENDO_SESSION_ID_NA`, `NINTENDO_SESSION_ID_EU`, `NINTENDO_SESSION_ID_JP`, optional `SPLATNET_USER_AGENT`, `RUN_TOKEN`, optional `SENTRY_DSN`, and at cutover `BLUESKY_SERVICE`, `BLUESKY_IDENTIFIER`, `BLUESKY_PASSWORD`. Use `wrangler secret put NAME --config workers/updater/wrangler.jsonc --env dev` for a secret. `SITE_URL` is a non-secret var in the config. Screenshots use the `BROWSER` binding; no Browser Run API credentials are required. For local development, use gitignored `workers/updater/.dev.vars.dev`. The existing shared code reads these values through Workers' populated `process.env`. Sentry wrappers route shared updater errors to Sentry when `SENTRY_DSN` is set. ```sh npm test npm run lint npm run build npm run updater:deploy:dry-run npm run updater:dev npm run updater:tail ``` Operator requests require `Authorization: Bearer $UPDATER_RUN_TOKEN`, matching the deployed `RUN_TOKEN` secret. Without `RUN_TOKEN` the endpoints are disabled. ```sh BASE=https://splatoon2-ink-updater..workers.dev curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/run" curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/run?only=Schedules,Timeline" curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/arm" curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/pause" curl -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/status" curl -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/list?prefix=data/" ``` `/list` returns at most 1000 public-bucket keys and reports truncation. It is a small diagnostic endpoint, not a full bucket export tool.