mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-10-01 05:37:06 -05:00
Run the updaters in a Cloudflare Worker
workers/updater runs src/app/updater, the same code npm run splatnet runs locally, against the Worker's R2 bindings through BucketStorage. The Scheduler Durable Object from the proof of concept owns when the run happens: an alarm at :00:10 that re-arms itself, with retries after a failed run; the only cron trigger is a minute-30 watchdog that re-arms a lost alarm. Operator endpoints (/run, /wake, /arm, /status) are behind a bearer token. Handlers and the Durable Object are wrapped with @sentry/cloudflare so the updaters' @sentry/core reports are delivered when SENTRY_DSN is set. updateAll() now returns a per-updater result (name, ok, duration, error) that both the local runner and the Worker can report on, and the SplatNet client reads its user agent lazily since Workers populate process.env from the bindings. Deployed in shadow mode against the dev bucket: a full run of all eight updaters on Cloudflare matched production's data semantically within the same hour, and steady-state runs take about seven seconds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ logs
|
||||
test-screenshot-*
|
||||
/storage
|
||||
.wrangler
|
||||
.dev.vars
|
||||
|
||||
87
package-lock.json
generated
87
package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"name": "splatoon2.ink",
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.20.42",
|
||||
"@sentry/cloudflare": "^10.73.0",
|
||||
"@sentry/core": "^10.73.0",
|
||||
"@sentry/node": "^10.73.0",
|
||||
"bulma": "^0.9.4",
|
||||
@@ -1851,7 +1852,7 @@
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
|
||||
"integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -1861,7 +1862,7 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
|
||||
"integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"unenv": "2.0.0-rc.24",
|
||||
@@ -1998,7 +1999,6 @@
|
||||
"version": "5.20260903.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260903.1.tgz",
|
||||
"integrity": "sha512-Dgm28XJqMYj3VCNK14Xwo9UPtSPWL8Izo0emiyeQLZCRXWuhc3GnlKENF4Pf3ot+3NaWosq+yq8OIa531dCr6g==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
@@ -2007,7 +2007,7 @@
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
@@ -2020,7 +2020,7 @@
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
@@ -3265,7 +3265,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -3286,7 +3286,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -3842,7 +3841,7 @@
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
|
||||
"integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kleur": "^4.1.5"
|
||||
@@ -3852,7 +3851,7 @@
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
|
||||
"integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@poppinss/colors": "^4.1.5",
|
||||
@@ -3864,7 +3863,7 @@
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -3877,7 +3876,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
|
||||
"integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
@@ -4322,6 +4321,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry/cloudflare": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-10.73.0.tgz",
|
||||
"integrity": "sha512-6BmvIbstT8GLdA7xDrjd3oR98tdXh7uwDbnm/Hu3HjO3py0HEQlTypeSZIDVdRpR3veYZ9eT0hJ/74Gp+QZZtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/server-utils": "10.73.0",
|
||||
"magic-string": "~0.30.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/workers-types": "^4.x || ^5.x",
|
||||
"wrangler": "^4.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cloudflare/workers-types": {
|
||||
"optional": true
|
||||
},
|
||||
"wrangler": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
||||
@@ -4461,7 +4487,7 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
||||
"integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -4500,7 +4526,7 @@
|
||||
"version": "1.2.24",
|
||||
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
|
||||
"integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
@@ -6200,7 +6226,7 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
|
||||
"integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
@@ -6947,7 +6973,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -7970,7 +7996,7 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
|
||||
"integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -8016,7 +8042,7 @@
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -10216,7 +10242,7 @@
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -10892,7 +10918,6 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -11128,7 +11153,7 @@
|
||||
"version": "5.20260831.0-alpha",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260831.0-alpha.tgz",
|
||||
"integrity": "sha512-Hwgh1VDUiPCPGQKODQfUmy7hRAje1D55icB+9png3ueiM64rlSM87nSrtqpxAD+DlLWI4ehnYBuECaXV43zGmQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "0.8.1",
|
||||
@@ -11663,7 +11688,7 @@
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -11676,7 +11701,7 @@
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
|
||||
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
@@ -11721,7 +11746,7 @@
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -12697,7 +12722,7 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@@ -15553,7 +15578,7 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -15570,7 +15595,7 @@
|
||||
"version": "2.0.0-rc.24",
|
||||
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
|
||||
"integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3"
|
||||
@@ -16807,7 +16832,7 @@
|
||||
"version": "1.20260831.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260831.1.tgz",
|
||||
"integrity": "sha512-A2LwrkBel/FnKABPfeBAMiL6v70+rugnunqQRfWsWZjlhsTZoBScWUVunMy/xLCGLjWCQL2zp39AVR6aO0jurQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -16828,7 +16853,7 @@
|
||||
"version": "4.128.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.128.0.tgz",
|
||||
"integrity": "sha512-jNXy9e8/pbx8iqTzXPiuflnitKJZoAfEUSUUDLW87bwyeMvJ7kb3yQMSbxEcfNdfHqJW38KRcKaLljOYV4N/4w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/kv-asset-handler": "0.5.0",
|
||||
@@ -16864,7 +16889,7 @@
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
@@ -16995,7 +17020,7 @@
|
||||
"version": "4.1.0-beta.10",
|
||||
"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
|
||||
"integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@poppinss/colors": "^4.1.5",
|
||||
@@ -17009,7 +17034,7 @@
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
|
||||
"integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@poppinss/exception": "^1.2.2",
|
||||
|
||||
10
package.json
10
package.json
@@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build --modern --no-clean",
|
||||
"test": "node --test && npm run worker:test",
|
||||
"test": "node --test && npm run worker:test && npm run updater:test",
|
||||
"lint": "eslint .",
|
||||
"cron": "node src/app/cron.js",
|
||||
"start": "npm run splatnet && npm run twitter && npm run cron",
|
||||
@@ -23,10 +23,16 @@
|
||||
"worker:test": "vitest run --config vitest.worker.config.mjs",
|
||||
"worker:deploy:dry-run": "wrangler deploy --dry-run",
|
||||
"worker:deploy": "wrangler deploy",
|
||||
"lint-fix": "npm run lint -- --fix"
|
||||
"lint-fix": "npm run lint -- --fix",
|
||||
"updater:dev": "wrangler dev --config workers/updater/wrangler.jsonc --test-scheduled",
|
||||
"updater:test": "vitest run --config workers/updater/vitest.config.mjs",
|
||||
"updater:deploy:dry-run": "wrangler deploy --config workers/updater/wrangler.jsonc --dry-run",
|
||||
"updater:deploy": "wrangler deploy --config workers/updater/wrangler.jsonc",
|
||||
"updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.20.42",
|
||||
"@sentry/cloudflare": "^10.73.0",
|
||||
"@sentry/core": "^10.73.0",
|
||||
"@sentry/node": "^10.73.0",
|
||||
"bulma": "^0.9.4",
|
||||
|
||||
@@ -20,16 +20,31 @@ export function createUpdaters(storage) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every updater. `storage` is shared by all of them for the run (see createUpdaters);
|
||||
* a Worker passes BucketStorage over its R2 bindings, a local run passes FilesystemStorage.
|
||||
* Run every updater (or the named ones). `storage` is shared by all of them for the run; a
|
||||
* Worker passes BucketStorage over its R2 bindings, a local run passes FilesystemStorage.
|
||||
* A failing updater is logged and does not stop the others.
|
||||
*
|
||||
* @param {object} storage
|
||||
* @param {{ only?: string[] }} [options] restrict the run to updaters with these names
|
||||
* @returns {Promise<Array<{ name: string, ok: boolean, ms: number, error?: string }>>}
|
||||
*/
|
||||
export default async function updateAll(storage) {
|
||||
export default async function updateAll(storage, { only } = {}) {
|
||||
let results = [];
|
||||
|
||||
for (let updater of createUpdaters(storage)) {
|
||||
let name = updater.options.name;
|
||||
if (only && !only.includes(name))
|
||||
continue;
|
||||
|
||||
let started = Date.now();
|
||||
try {
|
||||
await updater.update();
|
||||
results.push({ name, ok: true, ms: Date.now() - started });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
results.push({ name, ok: false, ms: Date.now() - started, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SplatNet2 API
|
||||
const userAgent = process.env.SPLATNET_USER_AGENT;
|
||||
const splatnetBaseUrl = 'https://app.splatoon2.nintendo.net';
|
||||
|
||||
// Read lazily: in a Worker, process.env is populated from the bindings rather than at startup.
|
||||
const userAgent = () => process.env.SPLATNET_USER_AGENT;
|
||||
|
||||
export default class SplatNet {
|
||||
constructor(region = 'NA', language = 'en-US') {
|
||||
this.region = region;
|
||||
@@ -18,7 +20,7 @@ export default class SplatNet {
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
...(userAgent ? { 'User-Agent': userAgent } : {}),
|
||||
...(userAgent() ? { 'User-Agent': userAgent() } : {}),
|
||||
'Cookie': `iksm_session=${this.getSessionId()}`,
|
||||
'Accept-Language': this.language,
|
||||
};
|
||||
@@ -110,7 +112,7 @@ export default class SplatNet {
|
||||
/** Download an image (no session cookie needed). Returns the bytes. */
|
||||
async getImage(imagePath) {
|
||||
let response = await this.request(imagePath, {
|
||||
headers: userAgent ? { 'User-Agent': userAgent } : {},
|
||||
headers: userAgent() ? { 'User-Agent': userAgent() } : {},
|
||||
});
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
78
workers/updater/README.md
Normal file
78
workers/updater/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Splatoon 2 updater Worker
|
||||
|
||||
Runs the site's updaters (`src/app/updater`, the same code `npm run splatnet` runs
|
||||
locally) on Cloudflare, writing to R2 through `BucketStorage` instead of to `dist/`
|
||||
and `storage/` through `FilesystemStorage`.
|
||||
|
||||
## Scheduling: cron wakes, the object works
|
||||
|
||||
Cron Triggers fire anywhere inside their minute (observed about 55 seconds late)
|
||||
and execute in whichever colo Cloudflare chooses; placement hints only apply to
|
||||
fetch handlers. So Cron Triggers never do work here. They call the `Scheduler`
|
||||
Durable Object, which runs jobs from its own alarm, in place, next to the R2
|
||||
buckets (the stub is created with a `wnam` location hint).
|
||||
|
||||
- **Hourly job** (`updaters`): the full updater run from an alarm at :00:10,
|
||||
re-armed for the next hour after each run. A run with a failed updater is
|
||||
retried after a minute, up to three times, before falling back to the next
|
||||
hour. Alarms have measured about 1 ms of drift.
|
||||
- **Woken jobs**: `wake(job)` asks the object to run a job as soon as possible.
|
||||
The scheduled handler maps each cron expression in `CRON_ACTIONS` to a call on
|
||||
the object; this is how a cron-driven job is expressed.
|
||||
- **Watchdog**: the only cron trigger, at minute 30, calls `ensureArmed()` so a
|
||||
lost hourly alarm heals within the hour. The alarm handler also restores the
|
||||
hourly schedule itself if it finds it missing.
|
||||
|
||||
Every run logs a structured summary (`driftMs`, `runMs`, per-updater results,
|
||||
the colo) under `updater: "alarm"`, visible in the Worker's Observability tab.
|
||||
`GET /status` returns the last run of each job.
|
||||
|
||||
## Errors
|
||||
|
||||
The shared updater code reports through `@sentry/core`; this Worker wraps its
|
||||
handlers and the Durable Object with `@sentry/cloudflare`, so those reports go
|
||||
to Sentry when the `SENTRY_DSN` secret is set and nowhere otherwise.
|
||||
|
||||
## Shadow mode
|
||||
|
||||
`wrangler.jsonc` points the `ASSETS` binding at `splatoon2-ink-dev-assets` while
|
||||
the container still owns `splatoon2-ink-assets`. Cutover is changing that
|
||||
`bucket_name`. Compare the two buckets' `data/` with `scripts/compare-data.mjs`
|
||||
after downloading, e.g. with `wrangler r2 object get --remote`.
|
||||
|
||||
## Secrets
|
||||
|
||||
```sh
|
||||
npx wrangler secret put NINTENDO_SESSION_ID_NA --config workers/updater/wrangler.jsonc
|
||||
npx wrangler secret put NINTENDO_SESSION_ID_EU --config workers/updater/wrangler.jsonc
|
||||
npx wrangler secret put NINTENDO_SESSION_ID_JP --config workers/updater/wrangler.jsonc
|
||||
npx wrangler secret put SPLATNET_USER_AGENT --config workers/updater/wrangler.jsonc
|
||||
npx wrangler secret put RUN_TOKEN --config workers/updater/wrangler.jsonc
|
||||
npx wrangler secret put SENTRY_DSN --config workers/updater/wrangler.jsonc # optional
|
||||
```
|
||||
|
||||
The updaters read the SplatNet secrets through `process.env`, which Workers
|
||||
populate from the bindings. For local development put the same names in
|
||||
`workers/updater/.dev.vars` (gitignored).
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
npm run updater:test # vitest in workerd: the updaters against local R2, the Scheduler
|
||||
npm run updater:dev # local dev; GET http://localhost:8787/cdn-cgi/handler/scheduled fires the cron
|
||||
npm run updater:deploy:dry-run
|
||||
npm run updater:deploy
|
||||
npm run updater:tail # live logs
|
||||
```
|
||||
|
||||
Authenticated operator endpoints, all requiring `Authorization: Bearer $UPDATER_RUN_TOKEN`
|
||||
(the token is in `.env` locally); without `RUN_TOKEN` set they are off:
|
||||
|
||||
```sh
|
||||
BASE=https://splatoon2-ink-updater.<subdomain>.workers.dev
|
||||
curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/run" # run every updater now
|
||||
curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/run?only=Schedules,Timeline" # or some of them
|
||||
curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/wake?job=updaters" # ask the Scheduler to run now
|
||||
curl -X POST -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/arm" # schedule the hourly job if it is not scheduled
|
||||
curl -H "Authorization: Bearer $UPDATER_RUN_TOKEN" "$BASE/status" # alarm time, hourly/retry state, last run per job
|
||||
```
|
||||
162
workers/updater/Scheduler.spec.mjs
Normal file
162
workers/updater/Scheduler.spec.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createExecutionContext, createScheduledController, env, runDurableObjectAlarm } from 'cloudflare:test';
|
||||
import worker from './src/index.mjs';
|
||||
import { nextRunAt, GRACE_MS, HOUR_MS } from './src/schedule.mjs';
|
||||
|
||||
function stub() {
|
||||
return env.SCHEDULER.get(env.SCHEDULER.idFromName(`test-${crypto.randomUUID()}`));
|
||||
}
|
||||
|
||||
// In the test runtime a due alarm may fire on its own (the fake Date is visible to the
|
||||
// runtime too), so trigger it explicitly and then wait for the outcome either way.
|
||||
async function runAlarmUntil(scheduler, done) {
|
||||
await runDurableObjectAlarm(scheduler);
|
||||
await vi.waitFor(async () => expect(done(await scheduler.status())).toBe(true), { timeout: 5000 });
|
||||
return scheduler.status();
|
||||
}
|
||||
|
||||
import { fakeSplatNet, setSessionEnvironment } from './fakeSplatNet.mjs';
|
||||
|
||||
setSessionEnvironment();
|
||||
|
||||
// The Durable Object runs in the test isolate, so stubbing global fetch (and Date) reaches it.
|
||||
function splatnetDown() {
|
||||
vi.stubGlobal('fetch', async () => new Response('down', { status: 503 }));
|
||||
}
|
||||
|
||||
function splatnetUp() {
|
||||
vi.stubGlobal('fetch', fakeSplatNet());
|
||||
}
|
||||
|
||||
describe('Scheduler', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('schedules the hourly job for the next :00:10 and is idempotent', async () => {
|
||||
let scheduler = stub();
|
||||
let before = Date.now();
|
||||
let first = await scheduler.ensureArmed();
|
||||
|
||||
expect(first.armed).toBe(true);
|
||||
expect(first.hourlyAt).toBeGreaterThanOrEqual(nextRunAt(before));
|
||||
expect((first.hourlyAt - GRACE_MS) % HOUR_MS).toBe(0);
|
||||
expect(first.alarmAt).toBe(first.hourlyAt);
|
||||
|
||||
let second = await scheduler.ensureArmed();
|
||||
expect(second).toEqual({ armed: false, hourlyAt: first.hourlyAt, alarmAt: first.hourlyAt });
|
||||
|
||||
let status = await scheduler.status();
|
||||
expect(status.alarmAt).toBe(first.hourlyAt);
|
||||
expect(status.pending).toEqual([]);
|
||||
expect(status.lastRuns).toEqual({});
|
||||
});
|
||||
|
||||
it('runs a woken job immediately and then goes back to the hourly schedule', async () => {
|
||||
splatnetUp();
|
||||
let scheduler = stub();
|
||||
let { hourlyAt } = await scheduler.ensureArmed();
|
||||
|
||||
let woke = await scheduler.wake('updaters');
|
||||
expect(woke.accepted).toBe(true);
|
||||
expect(woke.alarmAt).toBeLessThan(hourlyAt);
|
||||
expect(woke.alarmAt - Date.now()).toBeLessThan(1000);
|
||||
await scheduler.wake('updaters'); // duplicate collapses into the same run
|
||||
|
||||
let status = await runAlarmUntil(scheduler, s => s.lastRuns.updaters !== undefined);
|
||||
expect(status.pending).toEqual([]);
|
||||
expect(status.lastRuns.updaters.reason).toBe('wake');
|
||||
expect(status.lastRuns.updaters.ok).toBe(true);
|
||||
expect(status.lastRuns.updaters.driftMs).toBeNull();
|
||||
expect(status.lastRuns.updaters.result.updaters).toHaveLength(8);
|
||||
expect(status.hourlyAt).toBe(hourlyAt); // a wake run does not move the hourly schedule
|
||||
expect(status.alarmAt).toBe(hourlyAt);
|
||||
});
|
||||
|
||||
it('restores the hourly schedule if a wake runs before the object was ever armed', async () => {
|
||||
splatnetUp();
|
||||
let scheduler = stub();
|
||||
await scheduler.wake('updaters');
|
||||
|
||||
let status = await runAlarmUntil(scheduler, s => s.lastRuns.updaters !== undefined);
|
||||
expect(status.lastRuns.updaters.ok).toBe(true);
|
||||
expect(status.hourlyAt).toBe(nextRunAt(status.lastRuns.updaters.firedAt));
|
||||
expect(status.alarmAt).toBe(status.hourlyAt);
|
||||
});
|
||||
|
||||
it('rejects unknown jobs without scheduling anything', async () => {
|
||||
let scheduler = stub();
|
||||
expect(await scheduler.wake('nope')).toEqual({ job: 'nope', accepted: false, error: 'Unknown job: nope' });
|
||||
expect((await scheduler.status()).alarmAt).toBeNull();
|
||||
});
|
||||
|
||||
it('retries a failed hourly run after a minute, then gives up until the next hour', async () => {
|
||||
splatnetDown();
|
||||
let scheduler = stub();
|
||||
let { hourlyAt } = await scheduler.ensureArmed();
|
||||
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(hourlyAt + 5);
|
||||
let status = await runAlarmUntil(scheduler, s => s.retries === 1);
|
||||
expect(status.lastRuns.updaters).toMatchObject({ reason: 'hourly', ok: false, retries: 0 });
|
||||
expect(status.lastRuns.updaters.driftMs).toBeGreaterThanOrEqual(5);
|
||||
expect(status.lastRuns.updaters.driftMs).toBeLessThan(1000); // the faked clock still creeps a little
|
||||
expect(status.lastRuns.updaters.error).toContain('updaters failed');
|
||||
expect(status.retries).toBe(1);
|
||||
expect(status.retryAt - (hourlyAt + 5 + 60 * 1000)).toBeGreaterThanOrEqual(0);
|
||||
expect(status.retryAt - (hourlyAt + 5 + 60 * 1000)).toBeLessThan(2000);
|
||||
expect(status.alarmAt).toBe(status.retryAt);
|
||||
expect(status.hourlyAt).toBe(hourlyAt);
|
||||
|
||||
for (let attempt = 2; attempt <= 3; attempt++) {
|
||||
vi.setSystemTime(status.retryAt);
|
||||
status = await runAlarmUntil(scheduler, s => s.retries === attempt);
|
||||
expect(status.lastRuns.updaters.reason).toBe('retry');
|
||||
}
|
||||
|
||||
// Fourth failure exhausts retries: back to the next hour
|
||||
vi.setSystemTime(status.retryAt);
|
||||
status = await runAlarmUntil(scheduler, s => s.retryAt === null);
|
||||
expect(status.retries).toBe(0);
|
||||
expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS);
|
||||
expect(status.alarmAt).toBe(hourlyAt + HOUR_MS);
|
||||
});
|
||||
|
||||
it('moves to the next hour after a successful hourly run', async () => {
|
||||
splatnetUp();
|
||||
let scheduler = stub();
|
||||
let { hourlyAt } = await scheduler.ensureArmed();
|
||||
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(hourlyAt + 1);
|
||||
let status = await runAlarmUntil(scheduler, s => s.hourlyAt !== hourlyAt);
|
||||
expect(status.lastRuns.updaters).toMatchObject({ reason: 'hourly', ok: true, colo: 'TEST' });
|
||||
expect(status.lastRuns.updaters.driftMs).toBeGreaterThanOrEqual(1);
|
||||
expect(status.lastRuns.updaters.driftMs).toBeLessThan(1000);
|
||||
expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS);
|
||||
expect(status.alarmAt).toBe(hourlyAt + HOUR_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cron watchdog', () => {
|
||||
it('arms the scheduler and does nothing else', async () => {
|
||||
let calls = [];
|
||||
let fakeEnv = {
|
||||
SCHEDULER: {
|
||||
idFromName: name => ({ name }),
|
||||
get: (id, options) => {
|
||||
calls.push({ id, options });
|
||||
return { ensureArmed: async () => ({ armed: true, hourlyAt: 1, alarmAt: 1 }) };
|
||||
},
|
||||
},
|
||||
};
|
||||
await worker.scheduled(createScheduledController({ cron: '30 * * * *', scheduledTime: new Date }), fakeEnv, createExecutionContext());
|
||||
expect(calls).toEqual([{ id: { name: 'schedules' }, options: { locationHint: 'wnam' } }]);
|
||||
});
|
||||
|
||||
it('ignores cron expressions it has no action for', async () => {
|
||||
let fakeEnv = { SCHEDULER: { idFromName: () => ({}), get: () => { throw new Error('should not be called'); } } };
|
||||
await expect(worker.scheduled(createScheduledController({ cron: '0 0 1 1 *', scheduledTime: new Date }), fakeEnv, createExecutionContext())).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
50
workers/updater/fakeSplatNet.mjs
Normal file
50
workers/updater/fakeSplatNet.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// A fake SplatNet for the Worker specs: enough of every endpoint for all eight updaters.
|
||||
export const SESSIONS = { NA: 'na-session', EU: 'eu-session', JP: 'jp-session' };
|
||||
|
||||
const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` });
|
||||
const rotation = start => ({
|
||||
id: start, start_time: start, end_time: start + 7200,
|
||||
stage_a: stage('0', 'The Reef'), stage_b: stage('1', 'Musselforge Fitness'),
|
||||
game_mode: { key: 'regular', name: 'Regular Battle' },
|
||||
rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' },
|
||||
});
|
||||
const gear = { kind: 'head', id: '1', name: 'Fake Hat', image: '/images/gear/1.png', brand: { id: '0', name: 'Brand', image: '/images/brand/0.png', frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } } };
|
||||
const festival = id => ({ festival_id: id, names: { alpha_short: 'A', bravo_short: 'B' }, times: { start: 3600, end: 7200 }, images: { alpha: '/images/festival/a.png', bravo: '/images/festival/b.png', panel: '/images/festival/p.png' }, special_stage: stage('100', 'Shifty Station') });
|
||||
|
||||
export const ROUTES = {
|
||||
'/api/schedules': () => ({ regular: [rotation(3600)], gachi: [rotation(3600)], league: [rotation(3600)] }),
|
||||
'/api/data/stages': () => ({ stages: [stage('0', 'The Reef'), stage('1', 'Musselforge Fitness')] }),
|
||||
'/api/coop_schedules': () => ({ schedules: [{ start_time: 3600, end_time: 7200 }], details: [{ start_time: 3600, end_time: 7200, stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, weapons: [null] }] }),
|
||||
'/api/timeline': () => ({ coop: { importance: 1, reward_gear: { available_time: 0, gear } }, weapon_availability: null }),
|
||||
'/api/festivals/active': () => ({ festivals: [festival(2)] }),
|
||||
'/api/festivals/pasts': () => ({ festivals: [festival(1)], results: [{ festival_id: 1 }] }),
|
||||
'/api/festivals/1/rankings': () => ({ rankings: [] }),
|
||||
'/api/onlineshop/merchandises': () => ({ merchandises: [{ end_time: 7200, gear, skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } }] }),
|
||||
};
|
||||
|
||||
/** Install on global fetch (e.g. vi.stubGlobal('fetch', fakeSplatNet())). Records every request. */
|
||||
export function fakeSplatNet(routes = ROUTES) {
|
||||
const requests = [];
|
||||
const fetch = async (input, init = {}) => {
|
||||
const url = new URL(input);
|
||||
const headers = new Headers(init.headers);
|
||||
requests.push({ path: url.pathname, language: headers.get('Accept-Language'), cookie: headers.get('Cookie') });
|
||||
if (url.hostname === 'www.cloudflare.com')
|
||||
return new Response('colo=TEST\n');
|
||||
if (url.pathname.startsWith('/images/'))
|
||||
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'content-type': 'image/png' } });
|
||||
const route = routes[url.pathname];
|
||||
if (!route)
|
||||
return new Response('not found', { status: 404 });
|
||||
const result = await route();
|
||||
return result instanceof Response ? result : Response.json(result);
|
||||
};
|
||||
fetch.requests = requests;
|
||||
return fetch;
|
||||
}
|
||||
|
||||
export function setSessionEnvironment() {
|
||||
process.env.NINTENDO_SESSION_ID_NA = SESSIONS.NA;
|
||||
process.env.NINTENDO_SESSION_ID_EU = SESSIONS.EU;
|
||||
process.env.NINTENDO_SESSION_ID_JP = SESSIONS.JP;
|
||||
}
|
||||
18
workers/updater/schedule.spec.mjs
Normal file
18
workers/updater/schedule.spec.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { nextRunAt, HOUR_MS, GRACE_MS } from './src/schedule.mjs';
|
||||
|
||||
const HOUR = Date.UTC(2026, 8, 4, 14, 0, 0); // 14:00:00 UTC
|
||||
const t = (seconds) => HOUR + seconds * 1000;
|
||||
|
||||
describe('nextRunAt', () => {
|
||||
it('targets :00:10 of the current hour when that is still ahead', () => {
|
||||
expect(nextRunAt(t(0))).toBe(HOUR + GRACE_MS);
|
||||
expect(nextRunAt(t(9))).toBe(HOUR + GRACE_MS);
|
||||
});
|
||||
|
||||
it('moves to the next hour once :00:10 has passed', () => {
|
||||
expect(nextRunAt(t(10))).toBe(HOUR + HOUR_MS + GRACE_MS);
|
||||
expect(nextRunAt(t(12))).toBe(HOUR + HOUR_MS + GRACE_MS);
|
||||
expect(nextRunAt(t(59 * 60 + 55))).toBe(HOUR + HOUR_MS + GRACE_MS);
|
||||
});
|
||||
});
|
||||
153
workers/updater/src/Scheduler.mjs
Normal file
153
workers/updater/src/Scheduler.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
// Durable Object that owns *when* and *where* updater jobs run.
|
||||
//
|
||||
// Why a Durable Object: Cron Triggers fire anywhere inside their minute and execute in
|
||||
// whichever colo Cloudflare picks (placement hints only apply to fetch handlers). An
|
||||
// object's alarm fires within milliseconds, and the object stays in the colo it was
|
||||
// created in, next to the R2 buckets. So the cron trigger only wakes the object; the
|
||||
// object does the work.
|
||||
//
|
||||
// Two ways work gets scheduled:
|
||||
// - the hourly job re-arms itself for the next :00:10 after every run;
|
||||
// - wake(job) asks for a job to run as soon as possible (used by cron-driven jobs).
|
||||
// Both share one alarm: it is always set to the earliest thing that is due.
|
||||
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { runUpdaters } from './updaters.mjs';
|
||||
import { nextRunAt } from './schedule.mjs';
|
||||
import { createLogger, describeError } from './log.mjs';
|
||||
import { currentColo } from './colo.mjs';
|
||||
|
||||
const RETRY_DELAY_MS = 60 * 1000;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export const HOURLY_JOB = 'updaters';
|
||||
|
||||
// Jobs the object can run. The hourly job is the full updater run; more can be added here.
|
||||
const JOBS = {
|
||||
updaters: env => runUpdaters(env),
|
||||
};
|
||||
|
||||
const EMPTY_STATE = {
|
||||
hourlyAt: null, // next scheduled run of the hourly job
|
||||
retryAt: null, // when set, a failed hourly run is retried at this time instead
|
||||
retries: 0,
|
||||
pending: [], // jobs requested through wake(), run at the next alarm
|
||||
lastRuns: {}, // per job: timing and outcome of the most recent run
|
||||
};
|
||||
|
||||
export class Scheduler extends DurableObject {
|
||||
/** Schedule the hourly job if it is not scheduled yet. Safe to call repeatedly (the cron watchdog does). */
|
||||
async ensureArmed() {
|
||||
let state = await this.#state();
|
||||
let armed = state.hourlyAt === null;
|
||||
if (armed) {
|
||||
state.hourlyAt = nextRunAt(Date.now());
|
||||
await this.#save(state);
|
||||
}
|
||||
return { armed, hourlyAt: state.hourlyAt, alarmAt: await this.#rearm(state) };
|
||||
}
|
||||
|
||||
/** Run a job as soon as possible. Duplicate requests before the run collapse into one. */
|
||||
async wake(job) {
|
||||
if (!Object.hasOwn(JOBS, job))
|
||||
return { job, accepted: false, error: `Unknown job: ${job}` };
|
||||
let state = await this.#state();
|
||||
if (!state.pending.includes(job))
|
||||
state.pending.push(job);
|
||||
await this.#save(state);
|
||||
return { job, accepted: true, alarmAt: await this.#rearm(state) };
|
||||
}
|
||||
|
||||
async status() {
|
||||
return { alarmAt: await this.ctx.storage.getAlarm(), ...await this.#state() };
|
||||
}
|
||||
|
||||
async #state() {
|
||||
return { ...EMPTY_STATE, ...await this.ctx.storage.get('state') ?? {} };
|
||||
}
|
||||
|
||||
async #save(state) {
|
||||
await this.ctx.storage.put('state', state);
|
||||
}
|
||||
|
||||
/** Point the single alarm at the earliest due time. */
|
||||
async #rearm(state) {
|
||||
let candidates = [state.retryAt ?? state.hourlyAt, state.pending.length ? Date.now() : null]
|
||||
.filter(time => time !== null);
|
||||
if (!candidates.length)
|
||||
return null;
|
||||
let alarmAt = Math.min(...candidates);
|
||||
await this.ctx.storage.setAlarm(alarmAt);
|
||||
return alarmAt;
|
||||
}
|
||||
|
||||
async alarm(alarmInfo) {
|
||||
let firedAt = Date.now();
|
||||
let log = createLogger('alarm');
|
||||
let colo = await currentColo();
|
||||
let state = await this.#state();
|
||||
|
||||
// Work out what is due. The hourly job is due when its time (or its retry time) has come;
|
||||
// pending jobs are due now. A pending request for the hourly job merges into the hourly run.
|
||||
let due = [];
|
||||
let hourlyScheduledFor = state.retryAt ?? state.hourlyAt;
|
||||
if (hourlyScheduledFor !== null && firedAt >= hourlyScheduledFor)
|
||||
due.push({ job: HOURLY_JOB, reason: state.retryAt ? 'retry' : 'hourly', scheduledFor: hourlyScheduledFor });
|
||||
for (let job of state.pending)
|
||||
if (Object.hasOwn(JOBS, job) && !due.some(entry => entry.job === job))
|
||||
due.push({ job, reason: 'wake', scheduledFor: null });
|
||||
state.pending = [];
|
||||
|
||||
for (let { job, reason, scheduledFor } of due) {
|
||||
let startedAt = Date.now();
|
||||
let run = {
|
||||
job,
|
||||
reason,
|
||||
scheduledFor,
|
||||
firedAt,
|
||||
driftMs: scheduledFor === null ? null : firedAt - scheduledFor,
|
||||
retryCount: alarmInfo?.retryCount ?? 0,
|
||||
retries: state.retries,
|
||||
colo,
|
||||
};
|
||||
|
||||
// Errors are caught so the alarm is always re-armed; the platform's own alarm retries are
|
||||
// capped and only cover the latest setAlarm(), so hourly retries are managed here.
|
||||
try {
|
||||
let result = await JOBS[job](this.env);
|
||||
// A job reports partial failure by returning { ok: false } rather than throwing
|
||||
run = { ...run, ok: result?.ok !== false, runMs: Date.now() - startedAt, result };
|
||||
if (!run.ok)
|
||||
run.error = `${job} failed: ${result.updaters?.filter(u => !u.ok).map(u => u.name).join(', ')}`;
|
||||
} catch (error) {
|
||||
run = { ...run, ok: false, runMs: Date.now() - startedAt, ...describeError(error) };
|
||||
}
|
||||
|
||||
if (job === HOURLY_JOB && reason !== 'wake') {
|
||||
if (run.ok || state.retries >= MAX_RETRIES) {
|
||||
state.hourlyAt = nextRunAt(Date.now());
|
||||
state.retryAt = null;
|
||||
state.retries = 0;
|
||||
} else {
|
||||
state.retryAt = Date.now() + RETRY_DELAY_MS;
|
||||
state.retries += 1;
|
||||
}
|
||||
}
|
||||
|
||||
state.lastRuns[job] = run;
|
||||
log[run.ok ? 'info' : 'error']('Alarm run finished', run);
|
||||
}
|
||||
|
||||
// This instance always owns the hourly job. If it has somehow been lost (for example the
|
||||
// alarm was consumed by a wake before ensureArmed() ever ran), restore it here rather than
|
||||
// waiting for the cron watchdog.
|
||||
if (state.hourlyAt === null) {
|
||||
state.hourlyAt = nextRunAt(Date.now());
|
||||
log.warn('Hourly schedule was missing; restored', { hourlyAt: state.hourlyAt });
|
||||
}
|
||||
|
||||
await this.#save(state);
|
||||
let alarmAt = await this.#rearm(state);
|
||||
log.info('Alarm re-armed', { alarmAt, hourlyAt: state.hourlyAt, retryAt: state.retryAt, ran: due.map(entry => entry.job) });
|
||||
}
|
||||
}
|
||||
12
workers/updater/src/colo.mjs
Normal file
12
workers/updater/src/colo.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Which Cloudflare colo this invocation is running in, for comparing scheduling paths.
|
||||
// A subrequest to a Cloudflare-fronted host is answered from the local colo, so the
|
||||
// trace endpoint reports where this Worker's code is executing.
|
||||
export async function currentColo() {
|
||||
try {
|
||||
let response = await fetch('https://www.cloudflare.com/cdn-cgi/trace');
|
||||
let text = await response.text();
|
||||
return text.match(/^colo=(\w+)$/m)?.[1] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
92
workers/updater/src/index.mjs
Normal file
92
workers/updater/src/index.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import { withSentry, instrumentDurableObjectWithSentry } from '@sentry/cloudflare';
|
||||
import { runUpdaters } from './updaters.mjs';
|
||||
import { Scheduler as SchedulerClass } from './Scheduler.mjs';
|
||||
import { createLogger, describeError } from './log.mjs';
|
||||
|
||||
// Sentry: the shared updater code reports through @sentry/core, which lands on the client
|
||||
// these wrappers set up per invocation. With no SENTRY_DSN secret nothing is sent.
|
||||
const sentryOptions = env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0 });
|
||||
|
||||
export const Scheduler = instrumentDurableObjectWithSentry(sentryOptions, SchedulerClass);
|
||||
|
||||
// The object is created next to the R2 buckets (western North America). Only the first
|
||||
// get() for an object honors the hint; after that it stays where it is.
|
||||
function scheduler(env) {
|
||||
return env.SCHEDULER.get(env.SCHEDULER.idFromName('schedules'), { locationHint: 'wnam' });
|
||||
}
|
||||
|
||||
// Cron Triggers never do work themselves: they wake the Scheduler, which runs jobs in
|
||||
// place. Each entry maps a cron expression from wrangler.jsonc to a call on the object.
|
||||
const CRON_ACTIONS = {
|
||||
'30 * * * *': scheduler => scheduler.ensureArmed(), // watchdog: the hourly alarm must always be armed
|
||||
// Example of a cron-driven job: '15 3 * * *': scheduler => scheduler.wake('some-daily-job'),
|
||||
};
|
||||
|
||||
function timingSafeEqual(a, b) {
|
||||
let encoder = new TextEncoder;
|
||||
let left = encoder.encode(a);
|
||||
let right = encoder.encode(b);
|
||||
if (left.byteLength !== right.byteLength)
|
||||
return false;
|
||||
return crypto.subtle.timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function isAuthorized(request, env) {
|
||||
if (!env.RUN_TOKEN)
|
||||
return false;
|
||||
let header = request.headers.get('Authorization') ?? '';
|
||||
let [scheme, token] = header.split(' ');
|
||||
return scheme === 'Bearer' && !!token && timingSafeEqual(token, env.RUN_TOKEN);
|
||||
}
|
||||
|
||||
export default withSentry(sentryOptions, {
|
||||
async scheduled(controller, env, ctx) {
|
||||
let log = createLogger('cron');
|
||||
let action = CRON_ACTIONS[controller.cron];
|
||||
if (!action) {
|
||||
log.warn('No action for cron expression', { cron: controller.cron });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Cron action finished', { cron: controller.cron, result: await action(scheduler(env)) });
|
||||
} catch (error) {
|
||||
log.error('Cron action failed', { cron: controller.cron, ...describeError(error) });
|
||||
throw error; // Mark the invocation as failed in Workers metrics
|
||||
}
|
||||
},
|
||||
|
||||
// Authenticated operator endpoints, "Authorization: Bearer <RUN_TOKEN>":
|
||||
// POST /run[?only=Name,Name] run the updaters in this invocation and return the summary
|
||||
// POST /wake?job=NAME ask the Scheduler to run a job as soon as possible
|
||||
// POST /arm schedule the hourly job if it is not scheduled
|
||||
// GET /status alarm state and the last run of each job
|
||||
async fetch(request, env, ctx) {
|
||||
let url = new URL(request.url);
|
||||
let route = `${request.method} ${url.pathname}`;
|
||||
if (!['POST /run', 'POST /wake', 'POST /arm', 'GET /status'].includes(route))
|
||||
return new Response('Not found', { status: 404 });
|
||||
if (!isAuthorized(request, env))
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
try {
|
||||
switch (route) {
|
||||
case 'POST /run': {
|
||||
let only = url.searchParams.get('only')?.split(',').map(name => name.trim()).filter(Boolean);
|
||||
return Response.json(await runUpdaters(env, { only }));
|
||||
}
|
||||
case 'POST /wake': {
|
||||
let woke = await scheduler(env).wake(url.searchParams.get('job') ?? '');
|
||||
return Response.json({ ok: woke.accepted, ...woke }, { status: woke.accepted ? 200 : 400 });
|
||||
}
|
||||
case 'POST /arm':
|
||||
return Response.json({ ok: true, ...await scheduler(env).ensureArmed() });
|
||||
case 'GET /status':
|
||||
return Response.json({ ok: true, ...await scheduler(env).status() });
|
||||
}
|
||||
} catch (error) {
|
||||
createLogger('http').error(`${route} failed`, describeError(error));
|
||||
return Response.json({ ok: false, ...describeError(error) }, { status: 500 });
|
||||
}
|
||||
},
|
||||
});
|
||||
19
workers/updater/src/log.mjs
Normal file
19
workers/updater/src/log.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// Structured logging for Workers Logs. Objects (not JSON strings) are logged so the
|
||||
// dashboard extracts and indexes each field, e.g. filter on driftMs or updater.
|
||||
export function createLogger(updater) {
|
||||
function emit(level, message, fields) {
|
||||
console[level]({ updater, message, ...fields });
|
||||
}
|
||||
|
||||
return {
|
||||
info: (message, fields = {}) => emit('info', message, fields),
|
||||
warn: (message, fields = {}) => emit('warn', message, fields),
|
||||
error: (message, fields = {}) => emit('error', message, fields),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeError(error) {
|
||||
return error instanceof Error
|
||||
? { error: error.message, stack: error.stack }
|
||||
: { error: String(error) };
|
||||
}
|
||||
11
workers/updater/src/schedule.mjs
Normal file
11
workers/updater/src/schedule.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
// Time math for the alarm scheduler. The hourly job runs ten seconds past the top of the
|
||||
// hour, the moment the container's node-cron ran at.
|
||||
|
||||
export const HOUR_MS = 60 * 60 * 1000;
|
||||
export const GRACE_MS = 10 * 1000;
|
||||
|
||||
/** The :00:10 strictly after `now`. */
|
||||
export function nextRunAt(now = Date.now()) {
|
||||
let candidate = now - (now % HOUR_MS) + GRACE_MS;
|
||||
return candidate > now ? candidate : candidate + HOUR_MS;
|
||||
}
|
||||
27
workers/updater/src/updaters.mjs
Normal file
27
workers/updater/src/updaters.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
// Runs the shared updaters (src/app/updater) against this Worker's R2 bindings.
|
||||
// This is the Worker's counterpart of src/app/local.js.
|
||||
|
||||
import { BucketStorage } from '../../../src/common/storage/index.js';
|
||||
import { updateAll } from '../../../src/app/updater/index.js';
|
||||
import { createLogger } from './log.mjs';
|
||||
|
||||
export function bucketStorage(env) {
|
||||
return {
|
||||
publicStorage: new BucketStorage(env.ASSETS),
|
||||
privateStorage: new BucketStorage(env.PRIVATE),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ ASSETS: R2Bucket, PRIVATE: R2Bucket }} env
|
||||
* @param {{ only?: string[] }} [options] restrict to updaters with these names
|
||||
* @returns {Promise<{ ok: boolean, ms: number, updaters: object[] }>}
|
||||
*/
|
||||
export async function runUpdaters(env, { only } = {}) {
|
||||
let log = createLogger('updaters');
|
||||
let started = Date.now();
|
||||
let updaters = await updateAll(bucketStorage(env), { only });
|
||||
let summary = { ok: updaters.every(u => u.ok), ms: Date.now() - started, updaters };
|
||||
log[summary.ok ? 'info' : 'error']('Updaters finished', summary);
|
||||
return summary;
|
||||
}
|
||||
55
workers/updater/updaters.spec.mjs
Normal file
55
workers/updater/updaters.spec.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { env } from 'cloudflare:test';
|
||||
import { runUpdaters } from './src/updaters.mjs';
|
||||
import { fakeSplatNet, setSessionEnvironment } from './fakeSplatNet.mjs';
|
||||
|
||||
// The shared updaters (src/app/updater) running inside workerd against the R2 bindings.
|
||||
setSessionEnvironment();
|
||||
|
||||
async function keys(bucket, prefix = '') {
|
||||
return (await bucket.list({ prefix })).objects.map(o => o.key).sort();
|
||||
}
|
||||
|
||||
describe('runUpdaters', () => {
|
||||
let fetch;
|
||||
beforeEach(() => { fetch = fakeSplatNet(); vi.stubGlobal('fetch', fetch); });
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('runs every updater against the R2 bindings and publishes the site data', async () => {
|
||||
let summary = await runUpdaters(env);
|
||||
|
||||
expect(summary.ok).toBe(true);
|
||||
expect(summary.updaters.map(u => u.name)).toEqual([
|
||||
'Original Gear', 'Schedules', 'Co-op Schedules', 'Timeline', 'Festivals NA', 'Festivals EU', 'Festivals JP', 'Merchandises',
|
||||
]);
|
||||
expect(await keys(env.ASSETS, 'data/')).toEqual(expect.arrayContaining([
|
||||
'data/schedules.json', 'data/coop-schedules.json', 'data/coop-schedules.ics', 'data/timeline.json',
|
||||
'data/festivals.json', 'data/festivals-na.ics', 'data/festivals-eu.ics', 'data/festivals-jp.ics',
|
||||
'data/festivals/na-1-rankings.json', 'data/merchandises.json', 'data/locale/en.json', 'data/locale/ja.json',
|
||||
]));
|
||||
expect(await keys(env.PRIVATE)).toEqual(['stages.json']);
|
||||
|
||||
let schedules = await (await env.ASSETS.get('data/schedules.json')).json();
|
||||
expect(schedules.regular[0].stage_a.name).toBe('The Reef');
|
||||
expect((await env.ASSETS.head('data/schedules.json')).httpMetadata.cacheControl).toContain('stale-while-revalidate');
|
||||
expect(await keys(env.ASSETS, 'assets/splatnet/images/stage/')).toEqual(['assets/splatnet/images/stage/0.png', 'assets/splatnet/images/stage/1.png', 'assets/splatnet/images/stage/100.png']);
|
||||
|
||||
let festivals = await (await env.ASSETS.get('data/festivals.json')).json();
|
||||
expect(Object.keys(festivals).sort()).toEqual(['eu', 'jp', 'na']);
|
||||
expect(fetch.requests.find(r => r.path === '/api/schedules').cookie).toBe('iksm_session=na-session');
|
||||
});
|
||||
|
||||
it('can run a subset of updaters, and reports a failing one without stopping the rest', async () => {
|
||||
await env.ASSETS.delete(['data/timeline.json', 'data/merchandises.json']);
|
||||
vi.stubGlobal('fetch', fakeSplatNet({ '/api/timeline': () => new Response('down', { status: 503 }), '/api/onlineshop/merchandises': () => ({ merchandises: [] }) }));
|
||||
let summary = await runUpdaters(env, { only: ['Timeline', 'Merchandises'] });
|
||||
|
||||
expect(summary.ok).toBe(false);
|
||||
expect(summary.updaters).toMatchObject([
|
||||
{ name: 'Timeline', ok: false, error: expect.stringContaining('status 503') },
|
||||
{ name: 'Merchandises', ok: true },
|
||||
]);
|
||||
expect(await env.ASSETS.get('data/timeline.json')).toBeNull();
|
||||
expect(await env.ASSETS.get('data/merchandises.json')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
23
workers/updater/vitest.config.mjs
Normal file
23
workers/updater/vitest.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { cloudflareTest } from '@cloudflare/vitest-plugin';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: {
|
||||
configPath: fileURLToPath(new URL('./wrangler.jsonc', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
include: ['workers/updater/**/*.spec.mjs'],
|
||||
deps: {
|
||||
optimizer: {
|
||||
// ics -> yup -> property-expr is CommonJS; pre-bundle it so its named exports resolve
|
||||
// in vitest's module runner the way they do in wrangler's bundle.
|
||||
ssr: { enabled: true, include: ['ics'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
43
workers/updater/wrangler.jsonc
Normal file
43
workers/updater/wrangler.jsonc
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "../../node_modules/wrangler/config-schema.json",
|
||||
"name": "splatoon2-ink-updater",
|
||||
"main": "src/index.mjs",
|
||||
"compatibility_date": "2026-09-03",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
// The workers.dev URL only exposes the authenticated manual trigger (see README).
|
||||
"workers_dev": true,
|
||||
"preview_urls": false,
|
||||
"triggers": {
|
||||
// Cron Triggers only wake the Scheduler Durable Object (see src/index.mjs CRON_ACTIONS).
|
||||
// The hourly update runs from the object's own alarm at :00:10; this cron is the watchdog
|
||||
// that re-arms that alarm if it is ever lost. Minute 30 keeps it clear of the hourly run.
|
||||
"crons": ["30 * * * *"]
|
||||
},
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{ "name": "SCHEDULER", "class_name": "Scheduler" }
|
||||
]
|
||||
},
|
||||
"migrations": [
|
||||
{ "tag": "v1", "new_sqlite_classes": ["Scheduler"] }
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
// Shadow mode: writes go to the dev bucket while the container still owns production.
|
||||
// Cutover = point this at "splatoon2-ink-assets".
|
||||
"binding": "ASSETS",
|
||||
"bucket_name": "splatoon2-ink-dev-assets"
|
||||
},
|
||||
{
|
||||
// Mutable updater state that is not public (the old storage/ directory).
|
||||
"binding": "PRIVATE",
|
||||
"bucket_name": "splatoon2-ink-private"
|
||||
}
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
"logs": {
|
||||
"head_sampling_rate": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user