Skip to content

The service worker

Since v0.109.0 (PWA: foundations). The source is apps/web/src/sw/; the build is vite-plugin-pwa in injectManifest mode, configured in apps/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, /images and /socket.io on the app's own origin (the dev server's proxy) are refused too. apiNeverCached.guard.test.ts drives the handler with every shape of API request in every environment, and fails the build if anything in src/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.

  1. Set the repository variable for the environment to 1 (Settings → Secrets and variables → Actions → Variables):

    Environment Variable Workflow
    production PWA_KILL_SWITCH ci.yml
    alpha ALPHA_PWA_KILL_SWITCH deploy-alpha.yml
    next DEV_PWA_KILL_SWITCH deploy-dev.yml
  2. Re-run that environment's deploy (or push). Nothing in the code changes.

  3. Confirm: curl -s https://<host>/sw.js is 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.

  4. 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_SUBJECT on the API, one pair per environment. Without them push is off and nothing fails; GET /api/push/vapid-public-key says which. Setting them up is below.
  • Subscriptions. push_subscriptions, one row per device, written by POST /api/push/subscriptions from the Settings switch (where permission is asked, and nowhere else) and removed by DELETE on 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.