The service worker
Since v0.109.0 (PWA: foundations). The source is
apps/web/src/sw/; the build isvite-plugin-pwaininjectManifestmode, configured inapps/web/vite.config.ts.
The web app is installable, and a service worker sits between it and the network. This page says what the worker does, what it will never do, and how to take it out of production if it is ever the problem.
What it does
| Request | Answer |
|---|---|
| A navigation on the app's own origin | Network first. The precached index.html only when the network fails outright. A deploy is picked up on the next page load whatever the worker holds. |
A file in the precache list — the hashed build's js, css and index.html |
From the precache. |
/ort/*, /mediapipe/*, /models/mediapipe/* — the segmentation runtime |
Cache first, from its first use, in sw-segmentation-runtime-ort<version>-mediapipe<version>. Never precached. Older runtime caches are deleted when a new worker activates. |
POST /share-target — the phone's share sheet (v0.110.0) |
Parked. The shared files go into the share-inbox cache under a one-time id, and the answer is a redirect to /share?id=…, which asks where they go. The one non-GET the worker answers; no server route exists behind it. |
| Everything else | Nothing. The worker does not call respondWith; the browser makes the request as if there were no worker. |
"Everything else" includes, by design:
- The API. It is another origin (
VITE_API_BASE_URL), authenticated by a bearer token, and a cached response would be one user's data served to the next person on a shared device./api,/imagesand/socket.ioon the app's own origin (the dev server's proxy) are refused too.apiNeverCached.guard.test.tsdrives the handler with every shape of API request in every environment, and fails the build if anything insrc/sw/routes around the one fetch handler. - Any request that is not a
GET. - SAM2's weights and the library classifier's model, which keep their own
Cache API stores (
purple-cat-segmentation,purple-cat-library-classifier).
Updates
A new deploy ships a new sw.js (uploaded no-cache, as are
manifest.webmanifest and index.html; the hashed assets are immutable). The
browser finds it on its next update check, installs it, and the new worker
waits:
- found within the first ten seconds of a launch, it is applied at once — the cold launch, when a reload costs nothing;
- later, it is announced: a toast with Reload anywhere but a map, a quiet badge in the play bar on one. Nothing reloads on its own.
The kill switch
The way back if a worker in production is ever the problem — serving something it should not, failing to update, or anything you cannot diagnose quickly enough.
What it is. A build flag, VITE_PWA_KILL_SWITCH=1. A build with it set
ships a different sw.js (src/sw/killSwitch.ts) that takes over at once,
deletes every Cache API cache on the origin, unregisters itself, and reloads
each open page once. The page of the same build unregisters any worker rather
than registering one, so nothing comes back. Users lose nothing but the
segmentation runtime's cache, which refills on next use.
When. When the cause is the worker, or might be, and a fix will not be out quickly. Not for an ordinary bad deploy: navigations are network first, so a normal fix-forward deploy already reaches every client on its next page load.
How.
-
Set the repository variable for the environment to
1(Settings → Secrets and variables → Actions → Variables):Environment Variable Workflow production PWA_KILL_SWITCHci.ymlalpha ALPHA_PWA_KILL_SWITCHdeploy-alpha.ymlnext DEV_PWA_KILL_SWITCHdeploy-dev.yml -
Re-run that environment's deploy (or push). Nothing in the code changes.
-
Confirm:
curl -s https://<host>/sw.jsis a few hundred bytes with no precache list in it. An installed client is clean after its next update check — at the latest, its next launch: DevTools → Application → Service workers shows none, and Cache storage is empty. -
Leave it deployed until the fix is in, then delete the variable (or set it to anything but
1) and deploy again. The next build ships the worker, and clients install it from scratch.
Before the first worker reaches production it is to be deployed to alpha and
checked there (PWA.11). killSwitch.test.ts holds what it does and in what order.
Push notifications (v0.111.0)
A Message filed by createMessage is pushed to the recipient's devices when
their user:<id> socket room is empty — the app is open nowhere
(packages/server-core/src/services/pushDelivery.ts). The worker's push
handler shows it (the title, a fixed line per kind — never the body — and the
environment's icon) and sets the icon's badge; tapping it focuses an open
window of the app on the page it names, or opens one there.
- Keys.
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECTon the API, one pair per environment. Without them push is off and nothing fails;GET /api/push/vapid-public-keysays which. Setting them up is below. - Subscriptions.
push_subscriptions, one row per device, written byPOST /api/push/subscriptionsfrom the Settings switch (where permission is asked, and nowhere else) and removed byDELETEon the switch and on Log Out. A push service answering 404 or 410 deletes the row. - iOS. Only an installed app can receive Web Push; in a Safari tab the switch says to install first.
Turning push on in an environment
Push needs the API to have three secrets, and on ECS every secret the task definition names must exist in the environment's Secrets Manager JSON, or the task never starts — so the keys go into the secret first, and the task definition names them second. One pair per environment, generated once and kept: a new pair orphans every device subscribed with the old one.
| Environment | Secret | ECS cluster / task family | Task definition managed by |
|---|---|---|---|
| production | purple-cat-prod-server-config |
purple-cat-prod / purple-cat-server |
the family itself (step 2) |
| alpha | purple-cat-alpha-server-config |
purple-cat-alpha / purple-cat-server-alpha |
the family itself (step 2) |
| next | purple-cat-dev-server-config |
purple-cat-dev / purple-cat-server-dev |
the family itself (step 2) |
1. Put the keys in the secret — merged into the JSON already there;
put-secret-value replaces the whole secret, so never send the three alone.
Needs jq (sudo apt install jq). The subshell stops at the first failure,
before anything is written, and refuses a merge that lost the existing keys:
(
set -euo pipefail
SECRET=purple-cat-dev-server-config # per the table
npx web-push generate-vapid-keys --json > /tmp/vapid.json
aws secretsmanager get-secret-value --secret-id "$SECRET" --query SecretString --output text \
| jq --slurpfile v /tmp/vapid.json \
'. + {VAPID_PUBLIC_KEY: $v[0].publicKey, VAPID_PRIVATE_KEY: $v[0].privateKey, VAPID_SUBJECT: "mailto:ops@purplecat.games"}' \
> /tmp/merged.json
echo "Keys to be written:"; jq -r 'keys[]' /tmp/merged.json
jq -e '.CLERK_SECRET_KEY and .POSTGRES_URL and .VAPID_PRIVATE_KEY' /tmp/merged.json > /dev/null
aws secretsmanager put-secret-value --secret-id "$SECRET" --secret-string file:///tmp/merged.json
)
rm -f /tmp/vapid.json /tmp/merged.json
Check before step 2 — the task will not start if the flag is on and the keys
are not there: aws secretsmanager get-secret-value --secret-id "$SECRET" --query SecretString --output text | jq 'has("VAPID_PRIVATE_KEY")' prints true.
2. Name them in the task definition, once per environment. Every deploy
copies the family's latest revision and swaps only the image, so a revision
registered with the keys keeps them from then on. Also needs jq:
(
set -euo pipefail
SECRET=purple-cat-dev-server-config # per the table
FAMILY=purple-cat-server-dev # per the table
ARN=$(aws secretsmanager describe-secret --secret-id "$SECRET" --query ARN --output text)
aws ecs describe-task-definition --task-definition "$FAMILY" --query taskDefinition \
| jq --arg a "$ARN" '
del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)
| .containerDefinitions[0].secrets |= ((. // []) | map(select(.name | startswith("VAPID_") | not)) + [
{name: "VAPID_PUBLIC_KEY", valueFrom: "\($a):VAPID_PUBLIC_KEY::"},
{name: "VAPID_PRIVATE_KEY", valueFrom: "\($a):VAPID_PRIVATE_KEY::"},
{name: "VAPID_SUBJECT", valueFrom: "\($a):VAPID_SUBJECT::"}])' \
> /tmp/td.json
echo "Secrets in the new revision:"; jq -r '.containerDefinitions[0].secrets[].name' /tmp/td.json
aws ecs register-task-definition --cli-input-json file:///tmp/td.json --query taskDefinition.taskDefinitionArn --output text
)
rm -f /tmp/td.json
The next deploy uses it (for next, any push to a version/ branch; for
production, the next release to main). To use it at once:
aws ecs update-service --cluster <cluster> --service <service> --task-definition <the ARN it printed> --force-new-deployment
— the service is purple-cat-server in every cluster.
Terraform. include_vapid_keys in the app-env module (and the legacy prod
root) says the same thing, and is set for next in infra/envs/nonprod. It is
only usable where that root's state is: the state is a local, gitignored file,
and a checkout without it plans to create all 74 nonprod resources from
scratch. Never apply a plan that creates the environment's buckets,
CloudFront distributions or cluster — they already exist.
3. Check. curl https://api-next.purplecat.games/api/push/vapid-public-key
(or api-alpha., api.) answers {"enabled":true,"publicKey":"B…"}. Then
Settings → Push notifications on an installed phone.
Not in scope
No offline play and no background sync. The reasons are on the Progressive Web App page.