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
/wakeor/postendpoint. An hourly alarm that encounters a manual run remains due. POST /runruns the complete pipeline and waits for its result. Withonly, 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.
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.
# 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 devfor dev, or the same command with--env productionfor 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.
- Run the tests, build, and deployment dry run below. Compare old/new public
data using
scripts/compare-data.mjson downloaded bucket directories. - Serve the built frontend with
/data/and/assets/backed by the dev bucket, then setSITE_URLto that preview site's origin. Keep Bluesky credentials unset. Run the full pipeline and visually inspect its images. - 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.
- 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.
- Set
ASSETSto the production bucket, confirmPRIVATE, and setSITE_URLto 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. - 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:
- 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.
- Configure the updater with
ADMIN_HOSTNAME,ACCESS_TEAM_DOMAIN(the bare<team>.cloudflareaccess.comhostname), andACCESS_AUD(the application's audience tag). All admin routes fail closed without these settings. - 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.
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.
BASE=https://splatoon2-ink-updater.<subdomain>.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.