@joystyk/pwa 0.0.0 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +215 -2
- package/dist/asset-manifest.d.ts +76 -0
- package/dist/asset-manifest.js +80 -0
- package/dist/asset-manifest.js.map +1 -0
- package/dist/build/documents.d.ts +79 -0
- package/dist/build/documents.js +135 -0
- package/dist/build/documents.js.map +1 -0
- package/dist/build/graph.d.ts +39 -0
- package/dist/build/graph.js +70 -0
- package/dist/build/graph.js.map +1 -0
- package/dist/build/icons.d.ts +30 -0
- package/dist/build/icons.js +192 -0
- package/dist/build/icons.js.map +1 -0
- package/dist/build/index.d.ts +4 -0
- package/dist/build/index.js +5 -0
- package/dist/build/plugin.d.ts +32 -0
- package/dist/build/plugin.js +172 -0
- package/dist/build/plugin.js.map +1 -0
- package/dist/client/availability.d.ts +12 -0
- package/dist/client/availability.js +94 -0
- package/dist/client/availability.js.map +1 -0
- package/dist/client/control.d.ts +3 -0
- package/dist/client/control.js +19 -0
- package/dist/client/control.js.map +1 -0
- package/dist/client/elements.d.ts +45 -0
- package/dist/client/elements.js +319 -0
- package/dist/client/elements.js.map +1 -0
- package/dist/client/forced.d.ts +33 -0
- package/dist/client/forced.js +57 -0
- package/dist/client/forced.js.map +1 -0
- package/dist/client/index.d.ts +7 -0
- package/dist/client/index.js +8 -0
- package/dist/client/pinned.d.ts +13 -0
- package/dist/client/pinned.js +29 -0
- package/dist/client/pinned.js.map +1 -0
- package/dist/client/pins.d.ts +43 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/pins.js.map +1 -0
- package/dist/client/register.d.ts +64 -0
- package/dist/client/register.js +255 -0
- package/dist/client/register.js.map +1 -0
- package/dist/control.d.ts +46 -0
- package/dist/control.js +88 -0
- package/dist/control.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/names.d.ts +47 -0
- package/dist/names.js +64 -0
- package/dist/names.js.map +1 -0
- package/dist/offline/offline.css +94 -0
- package/dist/offline/offline.d.ts +1 -0
- package/dist/offline/offline.js +26 -0
- package/dist/offline/offline.js.map +1 -0
- package/dist/offline-mark.d.ts +15 -0
- package/dist/offline-mark.js +51 -0
- package/dist/offline-mark.js.map +1 -0
- package/dist/pin-record.d.ts +14 -0
- package/dist/pin-record.js +36 -0
- package/dist/pin-record.js.map +1 -0
- package/dist/preloads.d.ts +2 -0
- package/dist/preloads.js +26 -0
- package/dist/preloads.js.map +1 -0
- package/dist/routing.d.ts +57 -0
- package/dist/routing.js +81 -0
- package/dist/routing.js.map +1 -0
- package/dist/sw/remove-sw.d.ts +1 -0
- package/dist/sw/remove-sw.js +16 -0
- package/dist/sw/remove-sw.js.map +1 -0
- package/dist/sw/sw.d.ts +1 -0
- package/dist/sw/sw.js +165 -0
- package/dist/sw/sw.js.map +1 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -0
- package/package.json +65 -11
|
@@ -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;
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { decodeSync, isRecord, validator } from "./validate.js";
|
|
2
|
+
//#region src/control.ts
|
|
3
|
+
/** An RFC 3339 date-time with seconds and a zone: what `git show -s --format=%cI` prints, or UTC. */
|
|
4
|
+
var RELEASE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
5
|
+
/** A release time in milliseconds since the epoch, or null when it isn't a valid date-time. */
|
|
6
|
+
var releaseTimeMs = (value) => {
|
|
7
|
+
if (typeof value !== "string" || !RELEASE_TIME.test(value)) return null;
|
|
8
|
+
const ms = Date.parse(value);
|
|
9
|
+
return Number.isNaN(ms) ? null : ms;
|
|
10
|
+
};
|
|
11
|
+
/** A release time in its canonical form: UTC, whole seconds, e.g. "2026-10-09T14:03:12Z". */
|
|
12
|
+
var canonicalReleaseTime = (value) => {
|
|
13
|
+
const ms = releaseTimeMs(value);
|
|
14
|
+
return ms === null ? null : `${(/* @__PURE__ */ new Date(Math.floor(ms / 1e3) * 1e3)).toISOString().slice(0, 19)}Z`;
|
|
15
|
+
};
|
|
16
|
+
var ControlSchema = validator((value, issues) => {
|
|
17
|
+
if (!isRecord(value)) {
|
|
18
|
+
issues.push("Expected an object");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const { schemaVersion, serviceWorker, minimumRelease } = value;
|
|
22
|
+
if (schemaVersion !== 1) issues.push("schemaVersion: expected 1");
|
|
23
|
+
if (serviceWorker !== "enabled" && serviceWorker !== "disabled") issues.push("serviceWorker: expected \"enabled\" or \"disabled\"");
|
|
24
|
+
if (minimumRelease !== void 0 && releaseTimeMs(minimumRelease) === null) issues.push("minimumRelease: expected a date-time such as \"2026-10-09T14:03:12Z\"");
|
|
25
|
+
if (issues.length > 0) return void 0;
|
|
26
|
+
const control = {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
serviceWorker
|
|
29
|
+
};
|
|
30
|
+
return minimumRelease === void 0 ? control : {
|
|
31
|
+
...control,
|
|
32
|
+
minimumRelease
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
var DEFAULT_CONTROL = {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
serviceWorker: "enabled"
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* What the flag file says. Anything unreadable, a network failure included, reads as "no
|
|
41
|
+
* instruction": a broken flag file must not wipe every player's offline games, and a page
|
|
42
|
+
* that can't reach the server keeps playing.
|
|
43
|
+
*/
|
|
44
|
+
var parseControl = (value) => decodeSync(ControlSchema, value);
|
|
45
|
+
var isDisabled = (control) => control?.serviceWorker === "disabled";
|
|
46
|
+
/**
|
|
47
|
+
* True when a page running the release built at `releaseTime` must update now: the flag
|
|
48
|
+
* file names a minimum release, and this one is older. An unreadable time on either side
|
|
49
|
+
* never forces anything.
|
|
50
|
+
*/
|
|
51
|
+
var mustUpdate = (control, releaseTime) => {
|
|
52
|
+
const minimum = releaseTimeMs(control?.minimumRelease);
|
|
53
|
+
const own = releaseTimeMs(releaseTime);
|
|
54
|
+
return minimum !== null && own !== null && own < minimum;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The flag file as the server running `releaseTime` serves it: a minimum later than that
|
|
58
|
+
* release comes down to it. Raising the minimum past what the site serves would otherwise
|
|
59
|
+
* send every page into a reload that can never reach a new enough release.
|
|
60
|
+
*/
|
|
61
|
+
var capMinimumRelease = (control, releaseTime) => {
|
|
62
|
+
const minimum = releaseTimeMs(control.minimumRelease);
|
|
63
|
+
const own = releaseTimeMs(releaseTime);
|
|
64
|
+
if (minimum === null || own === null || minimum <= own) return control;
|
|
65
|
+
return {
|
|
66
|
+
...control,
|
|
67
|
+
minimumRelease: releaseTime
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
var RELEASE_RECORD_FILE = "release.json";
|
|
71
|
+
var ReleaseRecordSchema = validator((value, issues) => {
|
|
72
|
+
if (!isRecord(value)) {
|
|
73
|
+
issues.push("Expected an object");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { release, releaseTime } = value;
|
|
77
|
+
if (typeof release !== "string" || release === "") issues.push("release: expected text");
|
|
78
|
+
if (releaseTimeMs(releaseTime) === null) issues.push("releaseTime: expected a date-time");
|
|
79
|
+
return issues.length === 0 ? {
|
|
80
|
+
release,
|
|
81
|
+
releaseTime
|
|
82
|
+
} : void 0;
|
|
83
|
+
});
|
|
84
|
+
var parseReleaseRecord = (value) => decodeSync(ReleaseRecordSchema, value);
|
|
85
|
+
//#endregion
|
|
86
|
+
export { ControlSchema, DEFAULT_CONTROL, RELEASE_RECORD_FILE, ReleaseRecordSchema, canonicalReleaseTime, capMinimumRelease, isDisabled, mustUpdate, parseControl, parseReleaseRecord, releaseTimeMs };
|
|
87
|
+
|
|
88
|
+
//# sourceMappingURL=control.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"control.js","names":[],"sources":["../src/control.ts"],"sourcesContent":["// The flag file (offline-and-updates.md, Rules 2 and 5): a small `no-cache` JSON file the site\n// serves at /pwa-control.json, which operators change without a deploy. It carries two flags.\n//\n// - The kill switch. When it says the worker is disabled, pages stop registering it, and\n// every installed worker unregisters itself and deletes the PWA's caches. Saves and other\n// IndexedDB data are never touched.\n// - The minimum release, for forced security updates. A page whose release is older shows a\n// blocking notice, saves the game and reloads into the current release.\n//\n// Releases are ordered by their release time: the UTC time of the commit a release is built\n// from (apps/site/vite.config.ts). Release IDs are commit hashes, which have no order.\nimport { decodeSync, isRecord, validator } from './validate.ts';\n\nexport type Control = {\n readonly schemaVersion: 1;\n readonly serviceWorker: 'enabled' | 'disabled';\n /**\n * The release time of the oldest release that may keep running, as an RFC 3339 date-time\n * such as \"2026-10-09T14:03:12Z\". An open page from an older release must update.\n */\n readonly minimumRelease?: string;\n};\n\n/** An RFC 3339 date-time with seconds and a zone: what `git show -s --format=%cI` prints, or UTC. */\nconst RELEASE_TIME = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$/;\n\n/** A release time in milliseconds since the epoch, or null when it isn't a valid date-time. */\nexport const releaseTimeMs = (value: unknown): number | null => {\n if (typeof value !== 'string' || !RELEASE_TIME.test(value)) return null;\n const ms = Date.parse(value);\n return Number.isNaN(ms) ? null : ms;\n};\n\n/** A release time in its canonical form: UTC, whole seconds, e.g. \"2026-10-09T14:03:12Z\". */\nexport const canonicalReleaseTime = (value: string): string | null => {\n const ms = releaseTimeMs(value);\n return ms === null ? null : `${new Date(Math.floor(ms / 1000) * 1000).toISOString().slice(0, 19)}Z`;\n};\n\nexport const ControlSchema = validator<Control>((value, issues) => {\n if (!isRecord(value)) {\n issues.push('Expected an object');\n return undefined;\n }\n const { schemaVersion, serviceWorker, minimumRelease } = value;\n if (schemaVersion !== 1) issues.push('schemaVersion: expected 1');\n if (serviceWorker !== 'enabled' && serviceWorker !== 'disabled')\n issues.push('serviceWorker: expected \"enabled\" or \"disabled\"');\n if (minimumRelease !== undefined && releaseTimeMs(minimumRelease) === null) {\n issues.push('minimumRelease: expected a date-time such as \"2026-10-09T14:03:12Z\"');\n }\n if (issues.length > 0) return undefined;\n const control: Control = { schemaVersion: 1, serviceWorker: serviceWorker as Control['serviceWorker'] };\n return minimumRelease === undefined ? control : { ...control, minimumRelease: minimumRelease as string };\n});\n\nexport const DEFAULT_CONTROL: Control = { schemaVersion: 1, serviceWorker: 'enabled' };\n\n/**\n * What the flag file says. Anything unreadable, a network failure included, reads as \"no\n * instruction\": a broken flag file must not wipe every player's offline games, and a page\n * that can't reach the server keeps playing.\n */\nexport const parseControl = (value: unknown): Control | null => decodeSync(ControlSchema, value);\n\nexport const isDisabled = (control: Control | null): boolean => control?.serviceWorker === 'disabled';\n\n/**\n * True when a page running the release built at `releaseTime` must update now: the flag\n * file names a minimum release, and this one is older. An unreadable time on either side\n * never forces anything.\n */\nexport const mustUpdate = (control: Control | null, releaseTime: string): boolean => {\n const minimum = releaseTimeMs(control?.minimumRelease);\n const own = releaseTimeMs(releaseTime);\n return minimum !== null && own !== null && own < minimum;\n};\n\n/**\n * The flag file as the server running `releaseTime` serves it: a minimum later than that\n * release comes down to it. Raising the minimum past what the site serves would otherwise\n * send every page into a reload that can never reach a new enough release.\n */\nexport const capMinimumRelease = (control: Control, releaseTime: string): Control => {\n const minimum = releaseTimeMs(control.minimumRelease);\n const own = releaseTimeMs(releaseTime);\n if (minimum === null || own === null || minimum <= own) return control;\n return { ...control, minimumRelease: releaseTime };\n};\n\n/**\n * The build's record of its release, written beside `sw.js` as `release.json` and never\n * served: the site reads it to cap the minimum release it serves.\n */\nexport type ReleaseRecord = {\n readonly release: string;\n /** Canonical: UTC, whole seconds. */\n readonly releaseTime: string;\n};\n\nexport const RELEASE_RECORD_FILE = 'release.json';\n\nexport const ReleaseRecordSchema = validator<ReleaseRecord>((value, issues) => {\n if (!isRecord(value)) {\n issues.push('Expected an object');\n return undefined;\n }\n const { release, releaseTime } = value;\n if (typeof release !== 'string' || release === '') issues.push('release: expected text');\n if (releaseTimeMs(releaseTime) === null) issues.push('releaseTime: expected a date-time');\n return issues.length === 0 ? { release: release as string, releaseTime: releaseTime as string } : undefined;\n});\n\nexport const parseReleaseRecord = (value: unknown): ReleaseRecord | null => decodeSync(ReleaseRecordSchema, value);\n"],"mappings":";;;AAwBA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB,UAAkC;CAC9D,IAAI,OAAO,UAAU,YAAY,CAAC,aAAa,KAAK,KAAK,GAAG,OAAO;CACnE,MAAM,KAAK,KAAK,MAAM,KAAK;CAC3B,OAAO,OAAO,MAAM,EAAE,IAAI,OAAO;AACnC;;AAGA,IAAa,wBAAwB,UAAiC;CACpE,MAAM,KAAK,cAAc,KAAK;CAC9B,OAAO,OAAO,OAAO,OAAO,oBAAG,IAAI,KAAK,KAAK,MAAM,KAAK,GAAI,IAAI,GAAI,EAAA,CAAE,YAAY,CAAC,CAAC,MAAM,GAAG,EAAE,EAAE;AACnG;AAEA,IAAa,gBAAgB,WAAoB,OAAO,WAAW;CACjE,IAAI,CAAC,SAAS,KAAK,GAAG;EACpB,OAAO,KAAK,oBAAoB;EAChC;CACF;CACA,MAAM,EAAE,eAAe,eAAe,mBAAmB;CACzD,IAAI,kBAAkB,GAAG,OAAO,KAAK,2BAA2B;CAChE,IAAI,kBAAkB,aAAa,kBAAkB,YACnD,OAAO,KAAK,qDAAiD;CAC/D,IAAI,mBAAmB,KAAA,KAAa,cAAc,cAAc,MAAM,MACpE,OAAO,KAAK,uEAAqE;CAEnF,IAAI,OAAO,SAAS,GAAG,OAAO,KAAA;CAC9B,MAAM,UAAmB;EAAE,eAAe;EAAkB;CAA0C;CACtG,OAAO,mBAAmB,KAAA,IAAY,UAAU;EAAE,GAAG;EAAyB;CAAyB;AACzG,CAAC;AAED,IAAa,kBAA2B;CAAE,eAAe;CAAG,eAAe;AAAU;;;;;;AAOrF,IAAa,gBAAgB,UAAmC,WAAW,eAAe,KAAK;AAE/F,IAAa,cAAc,YAAqC,SAAS,kBAAkB;;;;;;AAO3F,IAAa,cAAc,SAAyB,gBAAiC;CACnF,MAAM,UAAU,cAAc,SAAS,cAAc;CACrD,MAAM,MAAM,cAAc,WAAW;CACrC,OAAO,YAAY,QAAQ,QAAQ,QAAQ,MAAM;AACnD;;;;;;AAOA,IAAa,qBAAqB,SAAkB,gBAAiC;CACnF,MAAM,UAAU,cAAc,QAAQ,cAAc;CACpD,MAAM,MAAM,cAAc,WAAW;CACrC,IAAI,YAAY,QAAQ,QAAQ,QAAQ,WAAW,KAAK,OAAO;CAC/D,OAAO;EAAE,GAAG;EAAS,gBAAgB;CAAY;AACnD;AAYA,IAAa,sBAAsB;AAEnC,IAAa,sBAAsB,WAA0B,OAAO,WAAW;CAC7E,IAAI,CAAC,SAAS,KAAK,GAAG;EACpB,OAAO,KAAK,oBAAoB;EAChC;CACF;CACA,MAAM,EAAE,SAAS,gBAAgB;CACjC,IAAI,OAAO,YAAY,YAAY,YAAY,IAAI,OAAO,KAAK,wBAAwB;CACvF,IAAI,cAAc,WAAW,MAAM,MAAM,OAAO,KAAK,mCAAmC;CACxF,OAAO,OAAO,WAAW,IAAI;EAAW;EAAgC;CAAsB,IAAI,KAAA;AACpG,CAAC;AAED,IAAa,sBAAsB,UAAyC,WAAW,qBAAqB,KAAK"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { ASSET_MANIFEST_VERSION, AssetFileSchema, FILE_KINDS, formatBytes, GameAssetManifestSchema, parseAssetManifest, planPin, referencedAssets, type AssetFile, type FileKind, type GameAssetManifest, type PinPlan, } from './asset-manifest.js';
|
|
2
|
+
export { markOfflineHtml, markOfflineResponse } from './offline-mark.js';
|
|
3
|
+
export { parsePinRecord, PinRecordSchema, type PinRecord } from './pin-record.js';
|
|
4
|
+
export { canonicalReleaseTime, capMinimumRelease, ControlSchema, DEFAULT_CONTROL, isDisabled, mustUpdate, parseControl, parseReleaseRecord, RELEASE_RECORD_FILE, ReleaseRecordSchema, releaseTimeMs, type Control, type ReleaseRecord, } from './control.js';
|
|
5
|
+
export { CONTROL_URL, isPwaCache, MESSAGES, OFFLINE_ATTRIBUTE, OFFLINE_URL, parsePinnedCacheName, PIN_RECORD_PATH, pinnedCacheName, PRECACHE_NAME, PRECACHE_PREFIX, RECENT_KINDS, recentCacheName, SW_URL, WEB_MANIFEST_URL, type RecentKind, } from './names.js';
|
|
6
|
+
export { ASSETS_PREFIX, fileKind, RECENT_LIMITS, routeFor, shouldOfferUpdate, type PromptInput, type Route, type RouteInput, } from './routing.js';
|
|
7
|
+
export { decodeSync, validator } from './validate.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CONTROL_URL, MESSAGES, OFFLINE_ATTRIBUTE, OFFLINE_URL, PIN_RECORD_PATH, PRECACHE_NAME, PRECACHE_PREFIX, RECENT_KINDS, SW_URL, WEB_MANIFEST_URL, isPwaCache, parsePinnedCacheName, pinnedCacheName, recentCacheName } from "./names.js";
|
|
2
|
+
import { ASSETS_PREFIX, RECENT_LIMITS, fileKind, routeFor, shouldOfferUpdate } from "./routing.js";
|
|
3
|
+
import { ASSET_MANIFEST_VERSION, AssetFileSchema, FILE_KINDS, GameAssetManifestSchema, formatBytes, parseAssetManifest, planPin, referencedAssets } from "./asset-manifest.js";
|
|
4
|
+
import { decodeSync, validator } from "./validate.js";
|
|
5
|
+
import { ControlSchema, DEFAULT_CONTROL, RELEASE_RECORD_FILE, ReleaseRecordSchema, canonicalReleaseTime, capMinimumRelease, isDisabled, mustUpdate, parseControl, parseReleaseRecord, releaseTimeMs } from "./control.js";
|
|
6
|
+
import { PinRecordSchema, parsePinRecord } from "./pin-record.js";
|
|
7
|
+
import { markOfflineHtml, markOfflineResponse } from "./offline-mark.js";
|
|
8
|
+
export { ASSETS_PREFIX, ASSET_MANIFEST_VERSION, AssetFileSchema, CONTROL_URL, ControlSchema, DEFAULT_CONTROL, FILE_KINDS, GameAssetManifestSchema, MESSAGES, OFFLINE_ATTRIBUTE, OFFLINE_URL, PIN_RECORD_PATH, PRECACHE_NAME, PRECACHE_PREFIX, PinRecordSchema, RECENT_KINDS, RECENT_LIMITS, RELEASE_RECORD_FILE, ReleaseRecordSchema, SW_URL, WEB_MANIFEST_URL, canonicalReleaseTime, capMinimumRelease, decodeSync, fileKind, formatBytes, isDisabled, isPwaCache, markOfflineHtml, markOfflineResponse, mustUpdate, parseAssetManifest, parseControl, parsePinRecord, parsePinnedCacheName, parseReleaseRecord, pinnedCacheName, planPin, recentCacheName, referencedAssets, releaseTimeMs, routeFor, shouldOfferUpdate, validator };
|
package/dist/names.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Tier 1: Workbox's precache, one per release; Workbox tracks revisions inside it. */
|
|
2
|
+
export declare const PRECACHE_PREFIX = "joystyk";
|
|
3
|
+
export declare const PRECACHE_NAME = "shell";
|
|
4
|
+
/** Tier 2 kinds: each has its own capped cache. */
|
|
5
|
+
export declare const RECENT_KINDS: readonly ["code", "art", "audio", "wasm"];
|
|
6
|
+
export type RecentKind = (typeof RECENT_KINDS)[number];
|
|
7
|
+
/** Bump the suffix when a Tier 2 cache's contents change shape; old ones are then deleted. */
|
|
8
|
+
export declare const recentCacheName: (kind: RecentKind) => string;
|
|
9
|
+
/** Tier 3: one cache per pinned game build, `game:<id>:<version>` (offline-and-updates.md). */
|
|
10
|
+
export declare const pinnedCacheName: (id: string, version: string) => string;
|
|
11
|
+
/** The game and version a pinned cache holds, or null for any other cache. */
|
|
12
|
+
export declare const parsePinnedCacheName: (name: string) => {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly version: string;
|
|
15
|
+
} | null;
|
|
16
|
+
/** Every cache this site's PWA may create; the kill switch deletes them all. */
|
|
17
|
+
export declare const isPwaCache: (name: string) => boolean;
|
|
18
|
+
/** The service worker's own URL. Always served `no-cache`. */
|
|
19
|
+
export declare const SW_URL = "/sw.js";
|
|
20
|
+
/** The kill-switch flag file. Always served `no-cache`. */
|
|
21
|
+
export declare const CONTROL_URL = "/pwa-control.json";
|
|
22
|
+
/** The offline fallback page. */
|
|
23
|
+
export declare const OFFLINE_URL = "/offline";
|
|
24
|
+
/**
|
|
25
|
+
* The attribute the worker adds to the <html> tag of a page it answers from a cache because
|
|
26
|
+
* the network failed (offline-mark.ts). @joystyk/ui's tokens.css reads it to keep ad slots
|
|
27
|
+
* collapsed from the first paint.
|
|
28
|
+
*/
|
|
29
|
+
export declare const OFFLINE_ATTRIBUTE = "data-offline";
|
|
30
|
+
/** The web app manifest. */
|
|
31
|
+
export declare const WEB_MANIFEST_URL = "/manifest.webmanifest";
|
|
32
|
+
/**
|
|
33
|
+
* Inside each pinned cache, the pin's own record (game name, version, asset list URL) lives
|
|
34
|
+
* at this path. No request ever asks for it, so routing never serves it.
|
|
35
|
+
*/
|
|
36
|
+
export declare const PIN_RECORD_PATH = "/.joystyk-pin.json";
|
|
37
|
+
/** Messages between the page and the worker. */
|
|
38
|
+
export declare const MESSAGES: {
|
|
39
|
+
/** Page → waiting worker: the player accepted the update. */
|
|
40
|
+
readonly skipWaiting: "SKIP_WAITING";
|
|
41
|
+
/** Page → worker: read the flag file now. */
|
|
42
|
+
readonly checkControl: "CHECK_CONTROL";
|
|
43
|
+
/** Page → worker, with a MessagePort: which release are you? */
|
|
44
|
+
readonly release: "GET_RELEASE";
|
|
45
|
+
/** Worker → pages: the kill switch fired; the worker unregistered and cleared its caches. */
|
|
46
|
+
readonly disabled: "PWA_DISABLED";
|
|
47
|
+
};
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region src/names.ts
|
|
2
|
+
/** Tier 1: Workbox's precache, one per release; Workbox tracks revisions inside it. */
|
|
3
|
+
var PRECACHE_PREFIX = "joystyk";
|
|
4
|
+
var PRECACHE_NAME = "shell";
|
|
5
|
+
/** Tier 2 kinds: each has its own capped cache. */
|
|
6
|
+
var RECENT_KINDS = [
|
|
7
|
+
"code",
|
|
8
|
+
"art",
|
|
9
|
+
"audio",
|
|
10
|
+
"wasm"
|
|
11
|
+
];
|
|
12
|
+
/** Bump the suffix when a Tier 2 cache's contents change shape; old ones are then deleted. */
|
|
13
|
+
var recentCacheName = (kind) => `recent:${kind}:v1`;
|
|
14
|
+
var PINNED = /^game:([a-z0-9]+(?:-[a-z0-9]+)*):([0-9A-Za-z.+-]+)$/;
|
|
15
|
+
/** Tier 3: one cache per pinned game build, `game:<id>:<version>` (offline-and-updates.md). */
|
|
16
|
+
var pinnedCacheName = (id, version) => {
|
|
17
|
+
const name = `game:${id}:${version}`;
|
|
18
|
+
if (!PINNED.test(name)) throw new RangeError(`Can't name a pinned cache for game ${JSON.stringify(id)} at version ${JSON.stringify(version)}.`);
|
|
19
|
+
return name;
|
|
20
|
+
};
|
|
21
|
+
/** The game and version a pinned cache holds, or null for any other cache. */
|
|
22
|
+
var parsePinnedCacheName = (name) => {
|
|
23
|
+
const match = PINNED.exec(name);
|
|
24
|
+
return match === null ? null : {
|
|
25
|
+
id: match[1] ?? "",
|
|
26
|
+
version: match[2] ?? ""
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/** Every cache this site's PWA may create; the kill switch deletes them all. */
|
|
30
|
+
var isPwaCache = (name) => name.startsWith(`joystyk-`) || name.startsWith("recent:") || name.startsWith("game:") || name.startsWith("workbox-");
|
|
31
|
+
/** The service worker's own URL. Always served `no-cache`. */
|
|
32
|
+
var SW_URL = "/sw.js";
|
|
33
|
+
/** The kill-switch flag file. Always served `no-cache`. */
|
|
34
|
+
var CONTROL_URL = "/pwa-control.json";
|
|
35
|
+
/** The offline fallback page. */
|
|
36
|
+
var OFFLINE_URL = "/offline";
|
|
37
|
+
/**
|
|
38
|
+
* The attribute the worker adds to the <html> tag of a page it answers from a cache because
|
|
39
|
+
* the network failed (offline-mark.ts). @joystyk/ui's tokens.css reads it to keep ad slots
|
|
40
|
+
* collapsed from the first paint.
|
|
41
|
+
*/
|
|
42
|
+
var OFFLINE_ATTRIBUTE = "data-offline";
|
|
43
|
+
/** The web app manifest. */
|
|
44
|
+
var WEB_MANIFEST_URL = "/manifest.webmanifest";
|
|
45
|
+
/**
|
|
46
|
+
* Inside each pinned cache, the pin's own record (game name, version, asset list URL) lives
|
|
47
|
+
* at this path. No request ever asks for it, so routing never serves it.
|
|
48
|
+
*/
|
|
49
|
+
var PIN_RECORD_PATH = "/.joystyk-pin.json";
|
|
50
|
+
/** Messages between the page and the worker. */
|
|
51
|
+
var MESSAGES = {
|
|
52
|
+
/** Page → waiting worker: the player accepted the update. */
|
|
53
|
+
skipWaiting: "SKIP_WAITING",
|
|
54
|
+
/** Page → worker: read the flag file now. */
|
|
55
|
+
checkControl: "CHECK_CONTROL",
|
|
56
|
+
/** Page → worker, with a MessagePort: which release are you? */
|
|
57
|
+
release: "GET_RELEASE",
|
|
58
|
+
/** Worker → pages: the kill switch fired; the worker unregistered and cleared its caches. */
|
|
59
|
+
disabled: "PWA_DISABLED"
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { CONTROL_URL, MESSAGES, OFFLINE_ATTRIBUTE, OFFLINE_URL, PIN_RECORD_PATH, PRECACHE_NAME, PRECACHE_PREFIX, RECENT_KINDS, SW_URL, WEB_MANIFEST_URL, isPwaCache, parsePinnedCacheName, pinnedCacheName, recentCacheName };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=names.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"names.js","names":[],"sources":["../src/names.ts"],"sourcesContent":["// Cache names and fixed URLs, shared by the service worker, the page and the build. Every\n// cache the PWA owns says what it holds and, where it can go stale, which version\n// (offline-and-updates.md, \"Cache tiers\"), so cleanup after an update is exact.\n\n/** Tier 1: Workbox's precache, one per release; Workbox tracks revisions inside it. */\nexport const PRECACHE_PREFIX = 'joystyk';\nexport const PRECACHE_NAME = 'shell';\n\n/** Tier 2 kinds: each has its own capped cache. */\nexport const RECENT_KINDS = ['code', 'art', 'audio', 'wasm'] as const;\nexport type RecentKind = (typeof RECENT_KINDS)[number];\n\n/** Bump the suffix when a Tier 2 cache's contents change shape; old ones are then deleted. */\nexport const recentCacheName = (kind: RecentKind): string => `recent:${kind}:v1`;\n\nconst PINNED = /^game:([a-z0-9]+(?:-[a-z0-9]+)*):([0-9A-Za-z.+-]+)$/;\n\n/** Tier 3: one cache per pinned game build, `game:<id>:<version>` (offline-and-updates.md). */\nexport const pinnedCacheName = (id: string, version: string): string => {\n const name = `game:${id}:${version}`;\n if (!PINNED.test(name))\n throw new RangeError(\n `Can't name a pinned cache for game ${JSON.stringify(id)} at version ${JSON.stringify(version)}.`,\n );\n return name;\n};\n\n/** The game and version a pinned cache holds, or null for any other cache. */\nexport const parsePinnedCacheName = (name: string): { readonly id: string; readonly version: string } | null => {\n const match = PINNED.exec(name);\n return match === null ? null : { id: match[1] ?? '', version: match[2] ?? '' };\n};\n\n/** Every cache this site's PWA may create; the kill switch deletes them all. */\nexport const isPwaCache = (name: string): boolean =>\n name.startsWith(`${PRECACHE_PREFIX}-`) ||\n name.startsWith('recent:') ||\n name.startsWith('game:') ||\n name.startsWith('workbox-');\n\n/** The service worker's own URL. Always served `no-cache`. */\nexport const SW_URL = '/sw.js';\n/** The kill-switch flag file. Always served `no-cache`. */\nexport const CONTROL_URL = '/pwa-control.json';\n/** The offline fallback page. */\nexport const OFFLINE_URL = '/offline';\n/**\n * The attribute the worker adds to the <html> tag of a page it answers from a cache because\n * the network failed (offline-mark.ts). @joystyk/ui's tokens.css reads it to keep ad slots\n * collapsed from the first paint.\n */\nexport const OFFLINE_ATTRIBUTE = 'data-offline';\n/** The web app manifest. */\nexport const WEB_MANIFEST_URL = '/manifest.webmanifest';\n/**\n * Inside each pinned cache, the pin's own record (game name, version, asset list URL) lives\n * at this path. No request ever asks for it, so routing never serves it.\n */\nexport const PIN_RECORD_PATH = '/.joystyk-pin.json';\n\n/** Messages between the page and the worker. */\nexport const MESSAGES = {\n /** Page → waiting worker: the player accepted the update. */\n skipWaiting: 'SKIP_WAITING',\n /** Page → worker: read the flag file now. */\n checkControl: 'CHECK_CONTROL',\n /** Page → worker, with a MessagePort: which release are you? */\n release: 'GET_RELEASE',\n /** Worker → pages: the kill switch fired; the worker unregistered and cleared its caches. */\n disabled: 'PWA_DISABLED',\n} as const;\n"],"mappings":";;AAKA,IAAa,kBAAkB;AAC/B,IAAa,gBAAgB;;AAG7B,IAAa,eAAe;CAAC;CAAQ;CAAO;CAAS;AAAM;;AAI3D,IAAa,mBAAmB,SAA6B,UAAU,KAAK;AAE5E,IAAM,SAAS;;AAGf,IAAa,mBAAmB,IAAY,YAA4B;CACtE,MAAM,OAAO,QAAQ,GAAG,GAAG;CAC3B,IAAI,CAAC,OAAO,KAAK,IAAI,GACnB,MAAM,IAAI,WACR,sCAAsC,KAAK,UAAU,EAAE,EAAE,cAAc,KAAK,UAAU,OAAO,EAAE,EACjG;CACF,OAAO;AACT;;AAGA,IAAa,wBAAwB,SAA2E;CAC9G,MAAM,QAAQ,OAAO,KAAK,IAAI;CAC9B,OAAO,UAAU,OAAO,OAAO;EAAE,IAAI,MAAM,MAAM;EAAI,SAAS,MAAM,MAAM;CAAG;AAC/E;;AAGA,IAAa,cAAc,SACzB,KAAK,WAAW,UAAqB,KACrC,KAAK,WAAW,SAAS,KACzB,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,UAAU;;AAG5B,IAAa,SAAS;;AAEtB,IAAa,cAAc;;AAE3B,IAAa,cAAc;;;;;;AAM3B,IAAa,oBAAoB;;AAEjC,IAAa,mBAAmB;;;;;AAKhC,IAAa,kBAAkB;;AAG/B,IAAa,WAAW;;CAEtB,aAAa;;CAEb,cAAc;;CAEd,SAAS;;CAET,UAAU;AACZ"}
|