@joystyk/pwa 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +36 -0
  4. package/README.md +217 -2
  5. package/dist/asset-manifest.d.ts +76 -0
  6. package/dist/asset-manifest.js +80 -0
  7. package/dist/asset-manifest.js.map +1 -0
  8. package/dist/build/documents.d.ts +79 -0
  9. package/dist/build/documents.js +135 -0
  10. package/dist/build/documents.js.map +1 -0
  11. package/dist/build/graph.d.ts +39 -0
  12. package/dist/build/graph.js +70 -0
  13. package/dist/build/graph.js.map +1 -0
  14. package/dist/build/icons.d.ts +30 -0
  15. package/dist/build/icons.js +192 -0
  16. package/dist/build/icons.js.map +1 -0
  17. package/dist/build/index.d.ts +4 -0
  18. package/dist/build/index.js +5 -0
  19. package/dist/build/plugin.d.ts +32 -0
  20. package/dist/build/plugin.js +172 -0
  21. package/dist/build/plugin.js.map +1 -0
  22. package/dist/client/availability.d.ts +12 -0
  23. package/dist/client/availability.js +94 -0
  24. package/dist/client/availability.js.map +1 -0
  25. package/dist/client/control.d.ts +3 -0
  26. package/dist/client/control.js +19 -0
  27. package/dist/client/control.js.map +1 -0
  28. package/dist/client/elements.d.ts +45 -0
  29. package/dist/client/elements.js +319 -0
  30. package/dist/client/elements.js.map +1 -0
  31. package/dist/client/forced.d.ts +33 -0
  32. package/dist/client/forced.js +57 -0
  33. package/dist/client/forced.js.map +1 -0
  34. package/dist/client/index.d.ts +7 -0
  35. package/dist/client/index.js +8 -0
  36. package/dist/client/pinned.d.ts +13 -0
  37. package/dist/client/pinned.js +29 -0
  38. package/dist/client/pinned.js.map +1 -0
  39. package/dist/client/pins.d.ts +43 -0
  40. package/dist/client/pins.js +140 -0
  41. package/dist/client/pins.js.map +1 -0
  42. package/dist/client/register.d.ts +64 -0
  43. package/dist/client/register.js +255 -0
  44. package/dist/client/register.js.map +1 -0
  45. package/dist/control.d.ts +46 -0
  46. package/dist/control.js +88 -0
  47. package/dist/control.js.map +1 -0
  48. package/dist/index.d.ts +7 -0
  49. package/dist/index.js +8 -0
  50. package/dist/names.d.ts +47 -0
  51. package/dist/names.js +64 -0
  52. package/dist/names.js.map +1 -0
  53. package/dist/offline/offline.css +94 -0
  54. package/dist/offline/offline.d.ts +1 -0
  55. package/dist/offline/offline.js +26 -0
  56. package/dist/offline/offline.js.map +1 -0
  57. package/dist/offline-mark.d.ts +15 -0
  58. package/dist/offline-mark.js +51 -0
  59. package/dist/offline-mark.js.map +1 -0
  60. package/dist/pin-record.d.ts +14 -0
  61. package/dist/pin-record.js +36 -0
  62. package/dist/pin-record.js.map +1 -0
  63. package/dist/preloads.d.ts +2 -0
  64. package/dist/preloads.js +26 -0
  65. package/dist/preloads.js.map +1 -0
  66. package/dist/routing.d.ts +57 -0
  67. package/dist/routing.js +81 -0
  68. package/dist/routing.js.map +1 -0
  69. package/dist/sw/remove-sw.d.ts +1 -0
  70. package/dist/sw/remove-sw.js +16 -0
  71. package/dist/sw/remove-sw.js.map +1 -0
  72. package/dist/sw/sw.d.ts +1 -0
  73. package/dist/sw/sw.js +165 -0
  74. package/dist/sw/sw.js.map +1 -0
  75. package/dist/validate.d.ts +8 -0
  76. package/dist/validate.js +24 -0
  77. package/dist/validate.js.map +1 -0
  78. package/package.json +71 -3
@@ -0,0 +1,140 @@
1
+ import { PIN_RECORD_PATH, parsePinnedCacheName, pinnedCacheName } from "../names.js";
2
+ import { parseAssetManifest, planPin, referencedAssets } from "../asset-manifest.js";
3
+ import { pinnedGames } from "./pinned.js";
4
+ import { err, ok } from "@joystyk/contracts";
5
+ //#region src/client/pins.ts
6
+ var browserPinPorts = () => ({
7
+ caches: globalThis.caches,
8
+ fetch: (url, init) => fetch(url, init),
9
+ origin: location.origin,
10
+ persist: async () => (await import("@joystyk/storage/browser")).requestPersistentStorage()
11
+ });
12
+ var PIN_MESSAGES = {
13
+ Offline: "You're offline. Connect to the internet, then try again.",
14
+ Quota: "This device is out of storage space, so the game couldn't be saved for offline play.",
15
+ Invalid: "This game can't be saved for offline play right now.",
16
+ Incomplete: "This game couldn't be saved for offline play right now. Try again in a minute.",
17
+ Unsupported: "This browser can't keep games for offline play."
18
+ };
19
+ var pinError = (code, detail) => ({
20
+ code,
21
+ message: PIN_MESSAGES[code],
22
+ detail
23
+ });
24
+ var PinFailure = class extends Error {
25
+ code;
26
+ constructor(code, detail) {
27
+ super(detail);
28
+ this.code = code;
29
+ }
30
+ };
31
+ var toPinError = (cause) => {
32
+ if (cause instanceof PinFailure) return pinError(cause.code, cause.message);
33
+ if (cause instanceof DOMException && cause.name === "QuotaExceededError") return pinError("Quota", cause.message);
34
+ if (cause instanceof TypeError) return pinError("Offline", cause.message);
35
+ return pinError("Incomplete", String(cause));
36
+ };
37
+ var pathsIn = async (cache) => (await cache.keys()).map((request) => new URL(request.url).pathname);
38
+ var fetchOk = async (ports, url, cache) => {
39
+ const response = await ports.fetch(url, {
40
+ cache,
41
+ credentials: "same-origin",
42
+ ...cache === "no-cache" ? { headers: { "X-Joystyk-Pin-Refresh": "1" } } : {}
43
+ });
44
+ if (!response.ok || response.redirected) throw new PinFailure("Incomplete", `${url}: HTTP ${String(response.status)}${response.redirected ? " (redirected)" : ""}`);
45
+ return response;
46
+ };
47
+ var loadManifest = async (ports, gameId, assets) => {
48
+ const response = await fetchOk(ports, assets, "no-cache").catch((cause) => {
49
+ throw cause instanceof PinFailure ? new PinFailure("Invalid", cause.message) : cause;
50
+ });
51
+ const raw = await response.clone().json().catch(() => null);
52
+ const parsed = parseAssetManifest(raw);
53
+ if (!parsed.ok) throw new PinFailure("Invalid", `${assets}: ${parsed.error.join("; ")}`);
54
+ if (parsed.value.id !== gameId) throw new PinFailure("Invalid", `${assets} describes ${parsed.value.id}, not ${gameId}`);
55
+ return {
56
+ manifest: parsed.value,
57
+ response
58
+ };
59
+ };
60
+ var pinOnce = async (ports, gameId, assets) => {
61
+ const { manifest, response: manifestResponse } = await loadManifest(ports, gameId, assets);
62
+ const names = await ports.caches.keys();
63
+ const firstPin = !names.some((name) => parsePinnedCacheName(name) !== null);
64
+ const existing = [];
65
+ for (const name of names) if (parsePinnedCacheName(name)?.id === gameId) existing.push({
66
+ cacheName: name,
67
+ paths: await pathsIn(await ports.caches.open(name))
68
+ });
69
+ const target = pinnedCacheName(manifest.id, manifest.version);
70
+ const created = !names.includes(target);
71
+ const plan = planPin(manifest, assets, target, existing, PIN_RECORD_PATH);
72
+ const key = (path) => new URL(path, ports.origin).href;
73
+ const cache = await ports.caches.open(target);
74
+ try {
75
+ for (const url of plan.fetch) await cache.put(key(url), await fetchOk(ports, url, "default"));
76
+ const files = new Set(manifest.files.map((file) => file.url));
77
+ const documents = [];
78
+ for (const url of plan.documents) documents.push([url, url === assets ? manifestResponse : await fetchOk(ports, url, "no-cache")]);
79
+ const page = documents.find(([url]) => url === manifest.page)?.[1];
80
+ if (page !== void 0) {
81
+ const missing = referencedAssets(await page.clone().text()).filter((url) => !files.has(url));
82
+ if (missing.length > 0) throw new PinFailure("Incomplete", `${manifest.page} needs files the asset manifest doesn't list: ${missing.join(", ")}`);
83
+ }
84
+ for (const [url, response] of documents) await cache.put(key(url), response);
85
+ const record = {
86
+ id: manifest.id,
87
+ name: manifest.name,
88
+ version: manifest.version,
89
+ release: manifest.release,
90
+ page: manifest.page,
91
+ assets,
92
+ totalBytes: manifest.totalBytes
93
+ };
94
+ await cache.put(key(PIN_RECORD_PATH), new Response(JSON.stringify(record), { headers: { "content-type": "application/json" } }));
95
+ for (const url of plan.remove) await cache.delete(key(url));
96
+ for (const name of plan.dropCaches) await ports.caches.delete(name);
97
+ return {
98
+ record,
99
+ persisted: firstPin ? await ports.persist() : null
100
+ };
101
+ } catch (cause) {
102
+ if (created) await ports.caches.delete(target);
103
+ throw cause;
104
+ }
105
+ };
106
+ /**
107
+ * Makes a game available offline, or brings its pin up to date: reads the asset manifest at
108
+ * `assets` (the game manifest's `assets`), caches every file and the game's page, then
109
+ * removes older builds. The first pin asks for persistent storage.
110
+ */
111
+ var pinGame = async (input, ports = browserPinPorts()) => {
112
+ if (ports.caches === void 0) return err(pinError("Unsupported", "no Cache Storage"));
113
+ for (let attempt = 1;; attempt += 1) try {
114
+ return ok(await pinOnce(ports, input.gameId, input.assets));
115
+ } catch (cause) {
116
+ const error = toPinError(cause);
117
+ if (error.code !== "Incomplete" || attempt === 2) return err(error);
118
+ }
119
+ };
120
+ /**
121
+ * Brings every pin up to the build the server has now: same version, missing files added and
122
+ * stale ones dropped; a new version, a new cache, then the old one removed. Call it at a safe
123
+ * point while online (Rule 2); a pin that fails to refresh keeps its old build.
124
+ */
125
+ var refreshPinnedGames = async (ports = browserPinPorts()) => {
126
+ const refreshed = [];
127
+ const failed = [];
128
+ for (const record of await pinnedGames(ports)) ((await pinGame({
129
+ gameId: record.id,
130
+ assets: record.assets
131
+ }, ports)).ok ? refreshed : failed).push(record.id);
132
+ return {
133
+ refreshed,
134
+ failed
135
+ };
136
+ };
137
+ //#endregion
138
+ export { PIN_MESSAGES, browserPinPorts, pinGame, refreshPinnedGames };
139
+
140
+ //# sourceMappingURL=pins.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pins.js","names":[],"sources":["../../src/client/pins.ts"],"sourcesContent":["// Tier 3, from the page: \"Make available offline\" writes a game's whole build into its own\n// cache, `game:<id>:<version>`, which the worker checks before anything else it doesn't\n// precache. The page and the worker share the origin's Cache Storage, so no message passes\n// between them (the blog's design). A pin holds every file in the game's asset manifest and\n// the game's page, so it is complete or it isn't kept.\nimport { err, ok, type Result } from '@joystyk/contracts';\nimport type { PersistResult } from '@joystyk/storage/browser';\nimport { parseAssetManifest, planPin, referencedAssets, type GameAssetManifest } from '../asset-manifest.ts';\nimport { parsePinnedCacheName, PIN_RECORD_PATH, pinnedCacheName } from '../names.ts';\nimport type { PinRecord } from '../pin-record.ts';\nimport { pinnedGames, type CachePorts } from './pinned.ts';\n\n/** What pinning touches; tests pass fakes. */\nexport type PinPorts = CachePorts & {\n readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;\n /** Asks the browser to keep the site's data; called on the first pin only. */\n readonly persist: () => Promise<PersistResult>;\n};\n\nexport const browserPinPorts = (): PinPorts => ({\n caches: globalThis.caches,\n fetch: (url, init) => fetch(url, init),\n origin: location.origin,\n // Loaded on first use: the storage package's browser code is a chunk of its own.\n persist: async () => (await import('@joystyk/storage/browser')).requestPersistentStorage(),\n});\n\nexport type PinErrorCode = 'Offline' | 'Quota' | 'Invalid' | 'Incomplete' | 'Unsupported';\n\nexport type PinError = {\n readonly code: PinErrorCode;\n /** For players. */\n readonly message: string;\n /** For developers. */\n readonly detail: string;\n};\n\nexport const PIN_MESSAGES: Readonly<Record<PinErrorCode, string>> = {\n Offline: \"You're offline. Connect to the internet, then try again.\",\n Quota: \"This device is out of storage space, so the game couldn't be saved for offline play.\",\n Invalid: \"This game can't be saved for offline play right now.\",\n Incomplete: \"This game couldn't be saved for offline play right now. Try again in a minute.\",\n Unsupported: \"This browser can't keep games for offline play.\",\n};\n\nconst pinError = (code: PinErrorCode, detail: string): PinError => ({ code, message: PIN_MESSAGES[code], detail });\n\nclass PinFailure extends Error {\n readonly code: PinErrorCode;\n constructor(code: PinErrorCode, detail: string) {\n super(detail);\n this.code = code;\n }\n}\n\nconst toPinError = (cause: unknown): PinError => {\n if (cause instanceof PinFailure) return pinError(cause.code, cause.message);\n if (cause instanceof DOMException && cause.name === 'QuotaExceededError') return pinError('Quota', cause.message);\n if (cause instanceof TypeError) return pinError('Offline', cause.message);\n return pinError('Incomplete', String(cause));\n};\n\nexport type Pinned = {\n readonly record: PinRecord;\n /** Set on the first pin, when persistent storage was requested; null otherwise. */\n readonly persisted: PersistResult | null;\n};\n\nconst pathsIn = async (cache: Cache): Promise<string[]> =>\n (await cache.keys()).map((request) => new URL(request.url).pathname);\n\nconst fetchOk = async (ports: PinPorts, url: string, cache: RequestCache): Promise<Response> => {\n const response = await ports.fetch(url, {\n cache,\n credentials: 'same-origin',\n ...(cache === 'no-cache' ? { headers: { 'X-Joystyk-Pin-Refresh': '1' } } : {}),\n });\n if (!response.ok || response.redirected)\n throw new PinFailure(\n 'Incomplete',\n `${url}: HTTP ${String(response.status)}${response.redirected ? ' (redirected)' : ''}`,\n );\n return response;\n};\n\nconst loadManifest = async (\n ports: PinPorts,\n gameId: string,\n assets: string,\n): Promise<{ manifest: GameAssetManifest; response: Response }> => {\n const response = await fetchOk(ports, assets, 'no-cache').catch((cause: unknown) => {\n throw cause instanceof PinFailure ? new PinFailure('Invalid', cause.message) : cause;\n });\n const raw: unknown = await response\n .clone()\n .json()\n .catch(() => null);\n const parsed = parseAssetManifest(raw);\n if (!parsed.ok) throw new PinFailure('Invalid', `${assets}: ${parsed.error.join('; ')}`);\n if (parsed.value.id !== gameId)\n throw new PinFailure('Invalid', `${assets} describes ${parsed.value.id}, not ${gameId}`);\n return { manifest: parsed.value, response };\n};\n\nconst pinOnce = async (ports: PinPorts, gameId: string, assets: string): Promise<Pinned> => {\n const { manifest, response: manifestResponse } = await loadManifest(ports, gameId, assets);\n const names = await ports.caches.keys();\n const firstPin = !names.some((name) => parsePinnedCacheName(name) !== null);\n const existing: { cacheName: string; paths: string[] }[] = [];\n for (const name of names) {\n if (parsePinnedCacheName(name)?.id === gameId)\n existing.push({ cacheName: name, paths: await pathsIn(await ports.caches.open(name)) });\n }\n const target = pinnedCacheName(manifest.id, manifest.version);\n const created = !names.includes(target);\n const plan = planPin(manifest, assets, target, existing, PIN_RECORD_PATH);\n const key = (path: string): string => new URL(path, ports.origin).href;\n const cache = await ports.caches.open(target);\n try {\n // Hashed files first: they never change, so the browser's HTTP cache may answer.\n for (const url of plan.fetch) await cache.put(key(url), await fetchOk(ports, url, 'default'));\n // Then the pages, fresh. A deploy between reading the manifest and the page would leave a\n // page naming files the pin lacks; refuse it, and the caller tries once more.\n const files = new Set(manifest.files.map((file) => file.url));\n const documents: [string, Response][] = [];\n for (const url of plan.documents) {\n documents.push([url, url === assets ? manifestResponse : await fetchOk(ports, url, 'no-cache')]);\n }\n const page = documents.find(([url]) => url === manifest.page)?.[1];\n if (page !== undefined) {\n const missing = referencedAssets(await page.clone().text()).filter((url) => !files.has(url));\n if (missing.length > 0)\n throw new PinFailure(\n 'Incomplete',\n `${manifest.page} needs files the asset manifest doesn't list: ${missing.join(', ')}`,\n );\n }\n for (const [url, response] of documents) await cache.put(key(url), response);\n const record: PinRecord = {\n id: manifest.id,\n name: manifest.name,\n version: manifest.version,\n release: manifest.release,\n page: manifest.page,\n assets,\n totalBytes: manifest.totalBytes,\n };\n await cache.put(\n key(PIN_RECORD_PATH),\n new Response(JSON.stringify(record), { headers: { 'content-type': 'application/json' } }),\n );\n // Only now, with the new build complete, drop what it replaced.\n for (const url of plan.remove) await cache.delete(key(url));\n for (const name of plan.dropCaches) await ports.caches.delete(name);\n return { record, persisted: firstPin ? await ports.persist() : null };\n } catch (cause) {\n if (created) await ports.caches.delete(target);\n throw cause;\n }\n};\n\n/**\n * Makes a game available offline, or brings its pin up to date: reads the asset manifest at\n * `assets` (the game manifest's `assets`), caches every file and the game's page, then\n * removes older builds. The first pin asks for persistent storage.\n */\nexport const pinGame = async (\n input: { readonly gameId: string; readonly assets: string },\n ports: PinPorts = browserPinPorts(),\n): Promise<Result<Pinned, PinError>> => {\n if ((ports.caches as CacheStorage | undefined) === undefined) return err(pinError('Unsupported', 'no Cache Storage'));\n for (let attempt = 1; ; attempt += 1) {\n try {\n return ok(await pinOnce(ports, input.gameId, input.assets));\n } catch (cause) {\n const error = toPinError(cause);\n if (error.code !== 'Incomplete' || attempt === 2) return err(error);\n }\n }\n};\n\n/**\n * Brings every pin up to the build the server has now: same version, missing files added and\n * stale ones dropped; a new version, a new cache, then the old one removed. Call it at a safe\n * point while online (Rule 2); a pin that fails to refresh keeps its old build.\n */\nexport const refreshPinnedGames = async (\n ports: PinPorts = browserPinPorts(),\n): Promise<{ refreshed: string[]; failed: string[] }> => {\n const refreshed: string[] = [];\n const failed: string[] = [];\n for (const record of await pinnedGames(ports)) {\n const result = await pinGame({ gameId: record.id, assets: record.assets }, ports);\n (result.ok ? refreshed : failed).push(record.id);\n }\n return { refreshed, failed };\n};\n"],"mappings":"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"}
@@ -0,0 +1,64 @@
1
+ import { type SecurityNotice } from './forced.js';
2
+ /** A daily game's content client, as `@joystyk/daily/browser`'s `createDailyClient` makes it. */
3
+ export type DailyPrefetcher = {
4
+ readonly gameId: string;
5
+ prefetch(today: string): Promise<unknown>;
6
+ };
7
+ /** How the page shows "Update ready". The default is `<jy-update-toast>`. */
8
+ export interface UpdatePrompt {
9
+ show(choose: (choice: 'refresh' | 'later') => void): void;
10
+ hide(): void;
11
+ }
12
+ export type PwaOptions = {
13
+ /** This build's release ID, for diagnostics. */
14
+ readonly release: string;
15
+ /**
16
+ * Subscribes to safe-point reports: pass the launcher's `watchSafePoints`
17
+ * (`@joystyk/launcher/browser`). Without it, every moment counts as safe.
18
+ */
19
+ readonly watchSafePoints?: (listener: (detail: {
20
+ readonly safe: boolean;
21
+ }) => void) => () => void;
22
+ /** How often to look for a new worker. Default 45 minutes, as the blog does. */
23
+ readonly updateCheckMs?: number;
24
+ /**
25
+ * The daily games' content clients. At a safe point, an installed app prefetches every
26
+ * daily game, and a browser tab only the pinned ones (D-46: today and tomorrow, no further).
27
+ */
28
+ readonly daily?: readonly DailyPrefetcher[];
29
+ readonly prompt?: UpdatePrompt;
30
+ /**
31
+ * When this release's code was committed (`__JOYSTYK_RELEASE_TIME__`). With it, the page
32
+ * honors the flag file's minimum release and forces a security update when it is older.
33
+ */
34
+ readonly releaseTime?: string;
35
+ /** Saves the game in play before a forced update: pass the launcher's `requestSave`. */
36
+ readonly saveGame?: () => Promise<void>;
37
+ /** How often to read the flag file for a minimum release. Default 15 minutes. */
38
+ readonly controlCheckMs?: number;
39
+ readonly securityNotice?: SecurityNotice;
40
+ };
41
+ export interface PwaController {
42
+ /** The release this page runs. */
43
+ readonly release: string;
44
+ /** The registration, or null without a worker (unsupported, failed, or switched off). */
45
+ readonly ready: Promise<ServiceWorkerRegistration | null>;
46
+ /** Looks for a new worker now. */
47
+ checkForUpdate(): Promise<void>;
48
+ dispose(): void;
49
+ }
50
+ /** The daily games to prefetch: all of them in an installed app, otherwise the pinned ones. */
51
+ export declare const dailyToPrefetch: (prefetchers: readonly DailyPrefetcher[], context: {
52
+ readonly installed: boolean;
53
+ readonly pinned: readonly string[];
54
+ }) => DailyPrefetcher[];
55
+ /** Unregisters every worker of this origin and deletes the PWA's caches. Saves are untouched. */
56
+ export declare const disablePwa: () => Promise<void>;
57
+ /** The default prompt: `<jy-update-toast>` at the end of the body. */
58
+ export declare const toastPrompt: () => UpdatePrompt;
59
+ /**
60
+ * Registers /sw.js and runs the update flow. Call once per page, in production builds only.
61
+ * A new worker installs and waits; the page shows "Update ready" only while the launcher
62
+ * reports a safe point, then posts SKIP_WAITING and reloads once the new worker controls it.
63
+ */
64
+ export declare const registerPwa: (options: PwaOptions) => PwaController;
@@ -0,0 +1,255 @@
1
+ import { MESSAGES, isPwaCache } from "../names.js";
2
+ import { shouldOfferUpdate } from "../routing.js";
3
+ import { isDisabled } from "../control.js";
4
+ import { watchOfflineAvailability } from "./availability.js";
5
+ import { readControl } from "./control.js";
6
+ import { watchForcedUpdates } from "./forced.js";
7
+ //#region src/client/register.ts
8
+ var loadElements = async () => (await import("./elements.js")).definePwaElements();
9
+ var DEFAULT_UPDATE_CHECK_MS = 27e5;
10
+ /** Refresh pins and prefetch at most this often per page. */
11
+ var IDLE_WORK_MS = 18e5;
12
+ /** The daily games to prefetch: all of them in an installed app, otherwise the pinned ones. */
13
+ var dailyToPrefetch = (prefetchers, context) => prefetchers.filter((p) => context.installed || context.pinned.includes(p.gameId));
14
+ /** Unregisters every worker of this origin and deletes the PWA's caches. Saves are untouched. */
15
+ var disablePwa = async () => {
16
+ for (const registration of await navigator.serviceWorker.getRegistrations()) await registration.unregister();
17
+ if (!("caches" in window)) return;
18
+ const names = (await caches.keys()).filter(isPwaCache);
19
+ await Promise.all(names.map((name) => caches.delete(name)));
20
+ };
21
+ /** The default prompt: `<jy-update-toast>` at the end of the body. */
22
+ var toastPrompt = () => {
23
+ let toast = null;
24
+ let wanted = false;
25
+ return {
26
+ show: (choose) => {
27
+ wanted = true;
28
+ loadElements().then(() => {
29
+ if (!wanted) return;
30
+ toast ??= document.createElement("jy-update-toast");
31
+ toast.onChoice = choose;
32
+ if (!toast.isConnected) document.body.append(toast);
33
+ });
34
+ },
35
+ hide: () => {
36
+ wanted = false;
37
+ toast?.remove();
38
+ }
39
+ };
40
+ };
41
+ var isInstalled = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
42
+ /** Resolves with `promise`, or with undefined after `ms`. */
43
+ var within = (promise, ms) => Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(void 0), ms))]);
44
+ /** Resolves once `worker` reaches `state`, or is thrown away. */
45
+ var reaches = (worker, state) => new Promise((resolve) => {
46
+ const done = () => worker.state === state || worker.state === "redundant";
47
+ if (done()) return resolve();
48
+ const listener = () => {
49
+ if (!done()) return;
50
+ worker.removeEventListener("statechange", listener);
51
+ resolve();
52
+ };
53
+ worker.addEventListener("statechange", listener);
54
+ });
55
+ /** Runs `work` once the page has loaded, so nothing competes with the page itself. */
56
+ var afterLoad = (work) => {
57
+ if (document.readyState === "complete") work();
58
+ else addEventListener("load", work, { once: true });
59
+ };
60
+ var forcedUpdates = (options, prepare) => options.releaseTime === void 0 ? null : watchForcedUpdates({
61
+ releaseTime: options.releaseTime,
62
+ ...options.saveGame === void 0 ? {} : { saveGame: options.saveGame },
63
+ ...options.controlCheckMs === void 0 ? {} : { checkMs: options.controlCheckMs },
64
+ ...options.securityNotice === void 0 ? {} : { notice: options.securityNotice },
65
+ ...prepare === void 0 ? {} : { prepare }
66
+ });
67
+ /**
68
+ * Registers /sw.js and runs the update flow. Call once per page, in production builds only.
69
+ * A new worker installs and waits; the page shows "Update ready" only while the launcher
70
+ * reports a safe point, then posts SKIP_WAITING and reloads once the new worker controls it.
71
+ */
72
+ var registerPwa = (options) => {
73
+ if (!("serviceWorker" in navigator)) {
74
+ const forced = forcedUpdates(options);
75
+ if (forced !== null) afterLoad(() => void forced.check());
76
+ return {
77
+ release: options.release,
78
+ ready: Promise.resolve(null),
79
+ checkForUpdate: async () => void 0,
80
+ dispose: () => forced?.dispose()
81
+ };
82
+ }
83
+ const container = navigator.serviceWorker;
84
+ const prompt = options.prompt ?? toastPrompt();
85
+ const cleanups = [watchOfflineAvailability()];
86
+ let registration = null;
87
+ let waiting = null;
88
+ /** Another tab accepted an update: this page runs the old release under the new worker. */
89
+ let stale = false;
90
+ let safe = true;
91
+ let dismissed = false;
92
+ let accepted = false;
93
+ let lastIdleWork = -Infinity;
94
+ let idleWork = null;
95
+ /**
96
+ * Before a forced update's reload: install the new release's worker and hand it the page,
97
+ * within a few seconds. Best effort, since the reload fetches the new release's page from
98
+ * the network either way; a worker left waiting is offered at the next safe point.
99
+ */
100
+ const activateNewWorker = async () => {
101
+ const reg = registration;
102
+ if (reg === null || container.controller === null) return;
103
+ await within(reg.update().catch(() => void 0), 5e3);
104
+ const installing = reg.installing;
105
+ if (installing !== null) await within(reaches(installing, "installed"), 1e4);
106
+ const next = reg.waiting;
107
+ if (next === null) return;
108
+ const handedOver = new Promise((resolve) => container.addEventListener("controllerchange", () => resolve(), { once: true }));
109
+ next.postMessage({ type: MESSAGES.skipWaiting });
110
+ await within(handedOver, 3e3);
111
+ };
112
+ const forced = forcedUpdates(options, activateNewWorker);
113
+ if (forced !== null) cleanups.push(() => forced.dispose());
114
+ const choose = (choice) => {
115
+ if (choice === "later") {
116
+ dismissed = true;
117
+ evaluate();
118
+ return;
119
+ }
120
+ accepted = true;
121
+ prompt.hide();
122
+ if (waiting !== null && !stale) waiting.postMessage({ type: MESSAGES.skipWaiting });
123
+ else location.reload();
124
+ };
125
+ const runIdleWork = () => {
126
+ if (idleWork !== null || !navigator.onLine || container.controller === null) return;
127
+ if (performance.now() - lastIdleWork < IDLE_WORK_MS) return;
128
+ lastIdleWork = performance.now();
129
+ idleWork = (async () => {
130
+ await (await import("./pins.js")).refreshPinnedGames();
131
+ const prefetchers = options.daily ?? [];
132
+ if (prefetchers.length === 0) return;
133
+ const pinned = (await (await import("./pinned.js")).pinnedGames()).map((record) => record.id);
134
+ const { today } = await import("@joystyk/daily/browser");
135
+ const date = today();
136
+ for (const client of dailyToPrefetch(prefetchers, {
137
+ installed: isInstalled(),
138
+ pinned
139
+ })) await client.prefetch(date);
140
+ })().catch(() => void 0).finally(() => {
141
+ idleWork = null;
142
+ });
143
+ };
144
+ function evaluate() {
145
+ if (forced?.forcing === true) {
146
+ prompt.hide();
147
+ return;
148
+ }
149
+ if (shouldOfferUpdate({
150
+ ready: waiting !== null || stale,
151
+ safe,
152
+ dismissed
153
+ })) prompt.show(choose);
154
+ else prompt.hide();
155
+ if (safe) runIdleWork();
156
+ }
157
+ const track = (reg) => {
158
+ if (reg.waiting !== null && container.controller !== null) waiting = reg.waiting;
159
+ reg.addEventListener("updatefound", () => {
160
+ const installing = reg.installing;
161
+ installing?.addEventListener("statechange", () => {
162
+ if (installing.state === "installed" && container.controller !== null) {
163
+ waiting = installing;
164
+ dismissed = false;
165
+ evaluate();
166
+ }
167
+ });
168
+ });
169
+ evaluate();
170
+ };
171
+ const checkForUpdate = async () => {
172
+ try {
173
+ await registration?.update();
174
+ } catch {}
175
+ };
176
+ const onControllerChange = () => {
177
+ if (forced?.forcing === true) return;
178
+ if (accepted) {
179
+ location.reload();
180
+ return;
181
+ }
182
+ if (container.controller === null) return;
183
+ stale = true;
184
+ dismissed = false;
185
+ evaluate();
186
+ };
187
+ container.addEventListener("controllerchange", onControllerChange);
188
+ cleanups.push(() => container.removeEventListener("controllerchange", onControllerChange));
189
+ const onMessage = (event) => {
190
+ if (event.data?.type === MESSAGES.disabled) {
191
+ waiting = null;
192
+ stale = false;
193
+ prompt.hide();
194
+ }
195
+ };
196
+ container.addEventListener("message", onMessage);
197
+ cleanups.push(() => container.removeEventListener("message", onMessage));
198
+ if (options.watchSafePoints !== void 0) cleanups.push(options.watchSafePoints((detail) => {
199
+ safe = detail.safe;
200
+ evaluate();
201
+ }));
202
+ const ready = new Promise((resolve) => {
203
+ const start = async () => {
204
+ if (isDisabled(forced === null ? await readControl() : await forced.check())) {
205
+ await disablePwa().catch(() => void 0);
206
+ resolve(null);
207
+ return;
208
+ }
209
+ try {
210
+ registration = await container.register("/sw.js", {
211
+ scope: "/",
212
+ updateViaCache: "none"
213
+ }) ?? null;
214
+ } catch {
215
+ registration = null;
216
+ }
217
+ if (registration !== null) track(registration);
218
+ resolve(registration);
219
+ };
220
+ afterLoad(() => {
221
+ loadElements();
222
+ start();
223
+ });
224
+ });
225
+ const interval = setInterval(() => void checkForUpdate(), options.updateCheckMs ?? DEFAULT_UPDATE_CHECK_MS);
226
+ cleanups.push(() => clearInterval(interval));
227
+ const onVisible = () => {
228
+ if (document.visibilityState === "visible") checkForUpdate();
229
+ };
230
+ document.addEventListener("visibilitychange", onVisible);
231
+ cleanups.push(() => document.removeEventListener("visibilitychange", onVisible));
232
+ const navigation = globalThis.navigation;
233
+ if (navigation !== void 0) {
234
+ const onNavigate = () => {
235
+ if (location.pathname !== "/") return;
236
+ checkForUpdate();
237
+ forced?.check();
238
+ };
239
+ navigation.addEventListener("navigatesuccess", onNavigate);
240
+ cleanups.push(() => navigation.removeEventListener("navigatesuccess", onNavigate));
241
+ }
242
+ return {
243
+ release: options.release,
244
+ ready,
245
+ checkForUpdate,
246
+ dispose: () => {
247
+ for (const cleanup of cleanups.splice(0)) cleanup();
248
+ prompt.hide();
249
+ }
250
+ };
251
+ };
252
+ //#endregion
253
+ export { dailyToPrefetch, disablePwa, registerPwa, toastPrompt };
254
+
255
+ //# sourceMappingURL=register.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register.js","names":[],"sources":["../../src/client/register.ts"],"sourcesContent":["// The page's side of the worker: register it, check for updates, offer an update only at a\n// safe point (Rule 2), force a security update when the flag file's minimum release says so\n// (Rule 2, ./forced.ts), honor the kill switch (Rule 5), and, while the page is idle at a safe\n// point and online, refresh pinned games and prefetch daily puzzles (offline-and-updates.md).\n// Registration waits for the page's `load` event, so it never competes with the page itself,\n// and the elements and the pinning code load after it, in a chunk of their own.\nimport { watchOfflineAvailability } from './availability.ts';\nimport { isDisabled } from '../control.ts';\nimport { isPwaCache, MESSAGES, SW_URL } from '../names.ts';\nimport { shouldOfferUpdate } from '../routing.ts';\nimport { readControl } from './control.ts';\nimport type { UpdateToast } from './elements.ts';\nimport { watchForcedUpdates, type ForcedUpdates, type SecurityNotice } from './forced.ts';\n\nconst loadElements = async (): Promise<void> => (await import('./elements.ts')).definePwaElements();\n\n/** A daily game's content client, as `@joystyk/daily/browser`'s `createDailyClient` makes it. */\nexport type DailyPrefetcher = {\n readonly gameId: string;\n prefetch(today: string): Promise<unknown>;\n};\n\n/** How the page shows \"Update ready\". The default is `<jy-update-toast>`. */\nexport interface UpdatePrompt {\n show(choose: (choice: 'refresh' | 'later') => void): void;\n hide(): void;\n}\n\nexport type PwaOptions = {\n /** This build's release ID, for diagnostics. */\n readonly release: string;\n /**\n * Subscribes to safe-point reports: pass the launcher's `watchSafePoints`\n * (`@joystyk/launcher/browser`). Without it, every moment counts as safe.\n */\n readonly watchSafePoints?: (listener: (detail: { readonly safe: boolean }) => void) => () => void;\n /** How often to look for a new worker. Default 45 minutes, as the blog does. */\n readonly updateCheckMs?: number;\n /**\n * The daily games' content clients. At a safe point, an installed app prefetches every\n * daily game, and a browser tab only the pinned ones (D-46: today and tomorrow, no further).\n */\n readonly daily?: readonly DailyPrefetcher[];\n readonly prompt?: UpdatePrompt;\n /**\n * When this release's code was committed (`__JOYSTYK_RELEASE_TIME__`). With it, the page\n * honors the flag file's minimum release and forces a security update when it is older.\n */\n readonly releaseTime?: string;\n /** Saves the game in play before a forced update: pass the launcher's `requestSave`. */\n readonly saveGame?: () => Promise<void>;\n /** How often to read the flag file for a minimum release. Default 15 minutes. */\n readonly controlCheckMs?: number;\n readonly securityNotice?: SecurityNotice;\n};\n\nexport interface PwaController {\n /** The release this page runs. */\n readonly release: string;\n /** The registration, or null without a worker (unsupported, failed, or switched off). */\n readonly ready: Promise<ServiceWorkerRegistration | null>;\n /** Looks for a new worker now. */\n checkForUpdate(): Promise<void>;\n dispose(): void;\n}\n\nconst DEFAULT_UPDATE_CHECK_MS = 45 * 60 * 1000;\n/** Refresh pins and prefetch at most this often per page. */\nconst IDLE_WORK_MS = 30 * 60 * 1000;\n\n/** The daily games to prefetch: all of them in an installed app, otherwise the pinned ones. */\nexport const dailyToPrefetch = (\n prefetchers: readonly DailyPrefetcher[],\n context: { readonly installed: boolean; readonly pinned: readonly string[] },\n): DailyPrefetcher[] => prefetchers.filter((p) => context.installed || context.pinned.includes(p.gameId));\n\n/** Unregisters every worker of this origin and deletes the PWA's caches. Saves are untouched. */\nexport const disablePwa = async (): Promise<void> => {\n for (const registration of await navigator.serviceWorker.getRegistrations()) await registration.unregister();\n if (!('caches' in window)) return;\n const names = (await caches.keys()).filter(isPwaCache);\n await Promise.all(names.map((name) => caches.delete(name)));\n};\n\n/** The default prompt: `<jy-update-toast>` at the end of the body. */\nexport const toastPrompt = (): UpdatePrompt => {\n let toast: UpdateToast | null = null;\n let wanted = false;\n return {\n show: (choose) => {\n wanted = true;\n void loadElements().then(() => {\n if (!wanted) return;\n toast ??= document.createElement('jy-update-toast');\n toast.onChoice = choose;\n if (!toast.isConnected) document.body.append(toast);\n });\n },\n hide: () => {\n wanted = false;\n toast?.remove();\n },\n };\n};\n\nconst isInstalled = (): boolean =>\n matchMedia('(display-mode: standalone)').matches ||\n (navigator as Navigator & { standalone?: boolean }).standalone === true;\n\ntype NavigationLike = EventTarget;\n\n/** Resolves with `promise`, or with undefined after `ms`. */\nconst within = <T>(promise: Promise<T>, ms: number): Promise<T | undefined> =>\n Promise.race([promise, new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))]);\n\n/** Resolves once `worker` reaches `state`, or is thrown away. */\nconst reaches = (worker: ServiceWorker, state: ServiceWorkerState): Promise<void> =>\n new Promise((resolve) => {\n const done = (): boolean => worker.state === state || worker.state === 'redundant';\n if (done()) return resolve();\n const listener = (): void => {\n if (!done()) return;\n worker.removeEventListener('statechange', listener);\n resolve();\n };\n worker.addEventListener('statechange', listener);\n });\n\n/** Runs `work` once the page has loaded, so nothing competes with the page itself. */\nconst afterLoad = (work: () => void): void => {\n if (document.readyState === 'complete') work();\n else addEventListener('load', work, { once: true });\n};\n\nconst forcedUpdates = (options: PwaOptions, prepare?: () => Promise<void>): ForcedUpdates | null =>\n options.releaseTime === undefined\n ? null\n : watchForcedUpdates({\n releaseTime: options.releaseTime,\n ...(options.saveGame === undefined ? {} : { saveGame: options.saveGame }),\n ...(options.controlCheckMs === undefined ? {} : { checkMs: options.controlCheckMs }),\n ...(options.securityNotice === undefined ? {} : { notice: options.securityNotice }),\n ...(prepare === undefined ? {} : { prepare }),\n });\n\n/**\n * Registers /sw.js and runs the update flow. Call once per page, in production builds only.\n * A new worker installs and waits; the page shows \"Update ready\" only while the launcher\n * reports a safe point, then posts SKIP_WAITING and reloads once the new worker controls it.\n */\nexport const registerPwa = (options: PwaOptions): PwaController => {\n if (!('serviceWorker' in navigator)) {\n // No worker, but a forced update still reaches the page.\n const forced = forcedUpdates(options);\n if (forced !== null) afterLoad(() => void forced.check());\n return {\n release: options.release,\n ready: Promise.resolve(null),\n checkForUpdate: async () => undefined,\n dispose: () => forced?.dispose(),\n };\n }\n const container = navigator.serviceWorker;\n const prompt = options.prompt ?? toastPrompt();\n const cleanups: (() => void)[] = [watchOfflineAvailability()];\n let registration: ServiceWorkerRegistration | null = null;\n let waiting: ServiceWorker | null = null;\n /** Another tab accepted an update: this page runs the old release under the new worker. */\n let stale = false;\n let safe = true;\n let dismissed = false;\n let accepted = false;\n let lastIdleWork = -Infinity;\n let idleWork: Promise<void> | null = null;\n\n /**\n * Before a forced update's reload: install the new release's worker and hand it the page,\n * within a few seconds. Best effort, since the reload fetches the new release's page from\n * the network either way; a worker left waiting is offered at the next safe point.\n */\n const activateNewWorker = async (): Promise<void> => {\n const reg = registration;\n if (reg === null || container.controller === null) return;\n await within(\n reg.update().catch(() => undefined),\n 5_000,\n );\n const installing = reg.installing;\n if (installing !== null) await within(reaches(installing, 'installed'), 10_000);\n const next = reg.waiting;\n if (next === null) return;\n const handedOver = new Promise<void>((resolve) =>\n container.addEventListener('controllerchange', () => resolve(), { once: true }),\n );\n next.postMessage({ type: MESSAGES.skipWaiting });\n await within(handedOver, 3_000);\n };\n const forced = forcedUpdates(options, activateNewWorker);\n if (forced !== null) cleanups.push(() => forced.dispose());\n\n const choose = (choice: 'refresh' | 'later'): void => {\n if (choice === 'later') {\n dismissed = true;\n evaluate();\n return;\n }\n accepted = true;\n prompt.hide();\n // The reload happens on controllerchange, once the new worker is in charge; reloading\n // now would race the handoff. A stale page has nothing to wait for.\n if (waiting !== null && !stale) waiting.postMessage({ type: MESSAGES.skipWaiting });\n else location.reload();\n };\n\n const runIdleWork = (): void => {\n if (idleWork !== null || !navigator.onLine || container.controller === null) return;\n if (performance.now() - lastIdleWork < IDLE_WORK_MS) return;\n lastIdleWork = performance.now();\n idleWork = (async () => {\n await (await import('./pins.ts')).refreshPinnedGames();\n const prefetchers = options.daily ?? [];\n if (prefetchers.length === 0) return;\n const pinned = (await (await import('./pinned.ts')).pinnedGames()).map((record) => record.id);\n const { today } = await import('@joystyk/daily/browser');\n const date = today();\n for (const client of dailyToPrefetch(prefetchers, { installed: isInstalled(), pinned }))\n await client.prefetch(date);\n })()\n .catch(() => undefined)\n .finally(() => {\n idleWork = null;\n });\n };\n\n function evaluate(): void {\n // A forced update's notice is up: no toast beside it, and no work behind it.\n if (forced?.forcing === true) {\n prompt.hide();\n return;\n }\n if (shouldOfferUpdate({ ready: waiting !== null || stale, safe, dismissed })) prompt.show(choose);\n else prompt.hide();\n if (safe) runIdleWork();\n }\n\n const track = (reg: ServiceWorkerRegistration): void => {\n // A worker may already be waiting from an earlier visit; `updatefound` won't fire for it.\n if (reg.waiting !== null && container.controller !== null) waiting = reg.waiting;\n reg.addEventListener('updatefound', () => {\n const installing = reg.installing;\n installing?.addEventListener('statechange', () => {\n // The very first install has nothing to update from: no controller yet.\n if (installing.state === 'installed' && container.controller !== null) {\n waiting = installing;\n dismissed = false;\n evaluate();\n }\n });\n });\n evaluate();\n };\n\n const checkForUpdate = async (): Promise<void> => {\n try {\n await registration?.update();\n } catch {\n // Offline, or the server is down: try again at the next check.\n }\n };\n\n const onControllerChange = (): void => {\n // A forced update reloads by itself once the new worker is in charge.\n if (forced?.forcing === true) return;\n if (accepted) {\n location.reload();\n return;\n }\n if (container.controller === null) return;\n stale = true;\n dismissed = false;\n evaluate();\n };\n container.addEventListener('controllerchange', onControllerChange);\n cleanups.push(() => container.removeEventListener('controllerchange', onControllerChange));\n\n const onMessage = (event: MessageEvent): void => {\n if ((event.data as { type?: unknown } | null)?.type === MESSAGES.disabled) {\n waiting = null;\n stale = false;\n prompt.hide();\n }\n };\n container.addEventListener('message', onMessage);\n cleanups.push(() => container.removeEventListener('message', onMessage));\n\n if (options.watchSafePoints !== undefined) {\n cleanups.push(\n options.watchSafePoints((detail) => {\n safe = detail.safe;\n evaluate();\n }),\n );\n }\n\n const ready = new Promise<ServiceWorkerRegistration | null>((resolve) => {\n const start = async (): Promise<void> => {\n if (isDisabled(forced === null ? await readControl() : await forced.check())) {\n await disablePwa().catch(() => undefined);\n resolve(null);\n return;\n }\n try {\n // Some environments (automation that blocks workers) resolve without a registration.\n registration =\n ((await container.register(SW_URL, { scope: '/', updateViaCache: 'none' })) as\n ServiceWorkerRegistration | undefined) ?? null;\n } catch {\n registration = null;\n }\n // Without one the site still works fully, only without offline play or update prompts.\n if (registration !== null) track(registration);\n resolve(registration);\n };\n afterLoad(() => {\n void loadElements();\n void start();\n });\n });\n\n // Periodic checks, a check when the tab comes back, and one on each navigation to the index.\n const interval = setInterval(() => void checkForUpdate(), options.updateCheckMs ?? DEFAULT_UPDATE_CHECK_MS);\n cleanups.push(() => clearInterval(interval));\n const onVisible = (): void => {\n if (document.visibilityState === 'visible') void checkForUpdate();\n };\n document.addEventListener('visibilitychange', onVisible);\n cleanups.push(() => document.removeEventListener('visibilitychange', onVisible));\n const navigation = (globalThis as { navigation?: NavigationLike }).navigation;\n if (navigation !== undefined) {\n const onNavigate = (): void => {\n if (location.pathname !== '/') return;\n void checkForUpdate();\n void forced?.check();\n };\n navigation.addEventListener('navigatesuccess', onNavigate);\n cleanups.push(() => navigation.removeEventListener('navigatesuccess', onNavigate));\n }\n\n return {\n release: options.release,\n ready,\n checkForUpdate,\n dispose: () => {\n for (const cleanup of cleanups.splice(0)) cleanup();\n prompt.hide();\n },\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ export type Control = {
2
+ readonly schemaVersion: 1;
3
+ readonly serviceWorker: 'enabled' | 'disabled';
4
+ /**
5
+ * The release time of the oldest release that may keep running, as an RFC 3339 date-time
6
+ * such as "2026-10-09T14:03:12Z". An open page from an older release must update.
7
+ */
8
+ readonly minimumRelease?: string;
9
+ };
10
+ /** A release time in milliseconds since the epoch, or null when it isn't a valid date-time. */
11
+ export declare const releaseTimeMs: (value: unknown) => number | null;
12
+ /** A release time in its canonical form: UTC, whole seconds, e.g. "2026-10-09T14:03:12Z". */
13
+ export declare const canonicalReleaseTime: (value: string) => string | null;
14
+ export declare const ControlSchema: import("@joystyk/contracts").Validator<Control>;
15
+ export declare const DEFAULT_CONTROL: Control;
16
+ /**
17
+ * What the flag file says. Anything unreadable, a network failure included, reads as "no
18
+ * instruction": a broken flag file must not wipe every player's offline games, and a page
19
+ * that can't reach the server keeps playing.
20
+ */
21
+ export declare const parseControl: (value: unknown) => Control | null;
22
+ export declare const isDisabled: (control: Control | null) => boolean;
23
+ /**
24
+ * True when a page running the release built at `releaseTime` must update now: the flag
25
+ * file names a minimum release, and this one is older. An unreadable time on either side
26
+ * never forces anything.
27
+ */
28
+ export declare const mustUpdate: (control: Control | null, releaseTime: string) => boolean;
29
+ /**
30
+ * The flag file as the server running `releaseTime` serves it: a minimum later than that
31
+ * release comes down to it. Raising the minimum past what the site serves would otherwise
32
+ * send every page into a reload that can never reach a new enough release.
33
+ */
34
+ export declare const capMinimumRelease: (control: Control, releaseTime: string) => Control;
35
+ /**
36
+ * The build's record of its release, written beside `sw.js` as `release.json` and never
37
+ * served: the site reads it to cap the minimum release it serves.
38
+ */
39
+ export type ReleaseRecord = {
40
+ readonly release: string;
41
+ /** Canonical: UTC, whole seconds. */
42
+ readonly releaseTime: string;
43
+ };
44
+ export declare const RELEASE_RECORD_FILE = "release.json";
45
+ export declare const ReleaseRecordSchema: import("@joystyk/contracts").Validator<ReleaseRecord>;
46
+ export declare const parseReleaseRecord: (value: unknown) => ReleaseRecord | null;