Files
splatoon2.ink/workers/updater/README.md
Matt Isenhower 0e93b3d427
Some checks failed
Checks / checks (push) Has been cancelled
Fix rotating image caching and document direct R2 serving
2026-09-15 21:04:06 -07:00

314 lines
17 KiB
Markdown

# 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
`<team>.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.<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.