@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,81 @@
1
+ //#region src/routing.ts
2
+ /** The hashed, immutable files the site serves (apps/site's assets volume). */
3
+ var ASSETS_PREFIX = "/assets/";
4
+ var EXTENSION_KINDS = {
5
+ js: "code",
6
+ mjs: "code",
7
+ css: "code",
8
+ json: "code",
9
+ map: "code",
10
+ png: "art",
11
+ jpg: "art",
12
+ jpeg: "art",
13
+ webp: "art",
14
+ avif: "art",
15
+ gif: "art",
16
+ svg: "art",
17
+ ktx2: "art",
18
+ glb: "art",
19
+ gltf: "art",
20
+ mp3: "audio",
21
+ ogg: "audio",
22
+ opus: "audio",
23
+ wav: "audio",
24
+ m4a: "audio",
25
+ flac: "audio",
26
+ wasm: "wasm",
27
+ woff2: "font",
28
+ woff: "font"
29
+ };
30
+ /** The kind of a file by its extension: a Tier 2 kind, `font`, or null for anything else. */
31
+ var fileKind = (pathname) => {
32
+ const dot = pathname.lastIndexOf(".");
33
+ if (dot === -1 || dot < pathname.lastIndexOf("/")) return null;
34
+ return EXTENSION_KINDS[pathname.slice(dot + 1).toLowerCase()] ?? null;
35
+ };
36
+ /** Tier 2 budgets: entries per kind and their age limit. Least recently used goes first. */
37
+ var RECENT_LIMITS = {
38
+ code: {
39
+ maxEntries: 120,
40
+ maxAgeSeconds: 2592e3
41
+ },
42
+ art: {
43
+ maxEntries: 80,
44
+ maxAgeSeconds: 2592e3
45
+ },
46
+ audio: {
47
+ maxEntries: 40,
48
+ maxAgeSeconds: 2592e3
49
+ },
50
+ wasm: {
51
+ maxEntries: 6,
52
+ maxAgeSeconds: 2592e3
53
+ }
54
+ };
55
+ var NEVER_CACHED = /* @__PURE__ */ new Set([
56
+ "/sw.js",
57
+ "/pwa-control.json",
58
+ "/healthz"
59
+ ]);
60
+ var routeFor = (request) => {
61
+ const { url } = request;
62
+ if (request.pinRefresh === true || request.mode !== "navigate" && request.cache === "no-cache") return { _tag: "Network" };
63
+ if (request.method !== "GET" || url.origin !== request.origin || NEVER_CACHED.has(url.pathname)) return { _tag: "Network" };
64
+ if (url.pathname === "/ws" || url.pathname === "/api/rooms" || url.pathname.startsWith("/api/rooms/")) return { _tag: "Network" };
65
+ if (url.pathname.startsWith("/api/ads/") || url.pathname.startsWith("/ads/")) return { _tag: "Network" };
66
+ if (request.mode === "navigate" || request.destination === "document") return { _tag: "Document" };
67
+ if (url.pathname === "/api/games" || url.pathname.startsWith("/api/games/")) return { _tag: "Api" };
68
+ if (url.pathname.startsWith("/assets/")) {
69
+ const kind = fileKind(url.pathname);
70
+ return kind === null || kind === "font" ? { _tag: "Pinned" } : {
71
+ _tag: "Asset",
72
+ kind
73
+ };
74
+ }
75
+ return { _tag: "Pinned" };
76
+ };
77
+ var shouldOfferUpdate = (input) => input.ready && input.safe && !input.dismissed;
78
+ //#endregion
79
+ export { ASSETS_PREFIX, RECENT_LIMITS, fileKind, routeFor, shouldOfferUpdate };
80
+
81
+ //# sourceMappingURL=routing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing.js","names":[],"sources":["../src/routing.ts"],"sourcesContent":["// Which tier answers a request. Pure, so the service worker's routing decisions are tested\n// in Node; sw/sw.ts wires each answer to a Workbox strategy.\nimport type { RecentKind } from './names.ts';\n\n/** The hashed, immutable files the site serves (apps/site's assets volume). */\nexport const ASSETS_PREFIX = '/assets/';\n\nconst EXTENSION_KINDS: Readonly<Record<string, RecentKind | 'font'>> = {\n js: 'code',\n mjs: 'code',\n css: 'code',\n json: 'code',\n map: 'code',\n png: 'art',\n jpg: 'art',\n jpeg: 'art',\n webp: 'art',\n avif: 'art',\n gif: 'art',\n svg: 'art',\n ktx2: 'art',\n glb: 'art',\n gltf: 'art',\n mp3: 'audio',\n ogg: 'audio',\n opus: 'audio',\n wav: 'audio',\n m4a: 'audio',\n flac: 'audio',\n wasm: 'wasm',\n woff2: 'font',\n woff: 'font',\n};\n\n/** The kind of a file by its extension: a Tier 2 kind, `font`, or null for anything else. */\nexport const fileKind = (pathname: string): RecentKind | 'font' | null => {\n const dot = pathname.lastIndexOf('.');\n if (dot === -1 || dot < pathname.lastIndexOf('/')) return null;\n return EXTENSION_KINDS[pathname.slice(dot + 1).toLowerCase()] ?? null;\n};\n\n/** Tier 2 budgets: entries per kind and their age limit. Least recently used goes first. */\nexport const RECENT_LIMITS: Readonly<\n Record<RecentKind, { readonly maxEntries: number; readonly maxAgeSeconds: number }>\n> = {\n code: { maxEntries: 120, maxAgeSeconds: 30 * 24 * 60 * 60 },\n art: { maxEntries: 80, maxAgeSeconds: 30 * 24 * 60 * 60 },\n audio: { maxEntries: 40, maxAgeSeconds: 30 * 24 * 60 * 60 },\n wasm: { maxEntries: 6, maxAgeSeconds: 30 * 24 * 60 * 60 },\n};\n\n/** How the worker answers one request. */\nexport type Route =\n /** Not ours: cross-origin, not a GET, the worker's own files or the flag file. The network answers. */\n | { readonly _tag: 'Network' }\n /** A page. Network first, then a pinned copy, then the precached shell, then the offline page. */\n | { readonly _tag: 'Document' }\n /** Catalog data for client-side navigation. Network first, then a pinned copy. */\n | { readonly _tag: 'Api' }\n /** A hashed file. The precache, then the pinned caches, then this kind's Tier 2 cache. */\n | { readonly _tag: 'Asset'; readonly kind: RecentKind }\n /** A hashed font or another file with no Tier 2 cache: the precache or a pin, else the network. */\n | { readonly _tag: 'Pinned' };\n\nexport type RouteInput = {\n readonly method: string;\n /** Explicit revalidation (pin refreshes) must reach the network, bypassing Cache Storage. */\n readonly cache?: RequestCache;\n readonly pinRefresh?: boolean;\n /** `Request.destination`: 'document' for navigations. */\n readonly destination: string;\n /** `Request.mode`: 'navigate' for navigations. */\n readonly mode: string;\n readonly url: URL;\n /** The worker's origin. */\n readonly origin: string;\n};\n\nconst NEVER_CACHED = new Set(['/sw.js', '/pwa-control.json', '/healthz']);\n\nexport const routeFor = (request: RouteInput): Route => {\n const { url } = request;\n if (request.pinRefresh === true || (request.mode !== 'navigate' && request.cache === 'no-cache'))\n return { _tag: 'Network' };\n if (request.method !== 'GET' || url.origin !== request.origin || NEVER_CACHED.has(url.pathname))\n return { _tag: 'Network' };\n // The room server (same host, routed by Traefik) answers live: never from a cache.\n if (url.pathname === '/ws' || url.pathname === '/api/rooms' || url.pathname.startsWith('/api/rooms/'))\n return { _tag: 'Network' };\n // Ads (monetization.md): the slot answer stays fresh by its own short Cache-Control, the click\n // notice and the beacon must reach the server to count, and a creative is only worth\n // showing online (slots collapse offline). The worker keeps none of them.\n if (url.pathname.startsWith('/api/ads/') || url.pathname.startsWith('/ads/')) return { _tag: 'Network' };\n if (request.mode === 'navigate' || request.destination === 'document') return { _tag: 'Document' };\n if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) return { _tag: 'Api' };\n if (url.pathname.startsWith(ASSETS_PREFIX)) {\n const kind = fileKind(url.pathname);\n return kind === null || kind === 'font' ? { _tag: 'Pinned' } : { _tag: 'Asset', kind };\n }\n // Per-game asset lists, the web manifest, the favicon: whatever a pin or the precache holds.\n return { _tag: 'Pinned' };\n};\n\n/** Whether a page's updates may be offered now (Rule 2): only at a safe point, never in play. */\nexport type PromptInput = {\n /** A new worker is installed and waiting, or another tab already switched workers. */\n readonly ready: boolean;\n /** The launcher's last safe-point report; true on pages without a launcher. */\n readonly safe: boolean;\n /** The player chose \"Later\" for this update. */\n readonly dismissed: boolean;\n};\n\nexport const shouldOfferUpdate = (input: PromptInput): boolean => input.ready && input.safe && !input.dismissed;\n"],"mappings":";;AAKA,IAAa,gBAAgB;AAE7B,IAAM,kBAAiE;CACrE,IAAI;CACJ,KAAK;CACL,KAAK;CACL,MAAM;CACN,KAAK;CACL,KAAK;CACL,KAAK;CACL,MAAM;CACN,MAAM;CACN,MAAM;CACN,KAAK;CACL,KAAK;CACL,MAAM;CACN,KAAK;CACL,MAAM;CACN,KAAK;CACL,KAAK;CACL,MAAM;CACN,KAAK;CACL,KAAK;CACL,MAAM;CACN,MAAM;CACN,OAAO;CACP,MAAM;AACR;;AAGA,IAAa,YAAY,aAAiD;CACxE,MAAM,MAAM,SAAS,YAAY,GAAG;CACpC,IAAI,QAAQ,MAAM,MAAM,SAAS,YAAY,GAAG,GAAG,OAAO;CAC1D,OAAO,gBAAgB,SAAS,MAAM,MAAM,CAAC,CAAC,CAAC,YAAY,MAAM;AACnE;;AAGA,IAAa,gBAET;CACF,MAAM;EAAE,YAAY;EAAK,eAAe;CAAkB;CAC1D,KAAK;EAAE,YAAY;EAAI,eAAe;CAAkB;CACxD,OAAO;EAAE,YAAY;EAAI,eAAe;CAAkB;CAC1D,MAAM;EAAE,YAAY;EAAG,eAAe;CAAkB;AAC1D;AA6BA,IAAM,+BAAe,IAAI,IAAI;CAAC;CAAU;CAAqB;AAAU,CAAC;AAExE,IAAa,YAAY,YAA+B;CACtD,MAAM,EAAE,QAAQ;CAChB,IAAI,QAAQ,eAAe,QAAS,QAAQ,SAAS,cAAc,QAAQ,UAAU,YACnF,OAAO,EAAE,MAAM,UAAU;CAC3B,IAAI,QAAQ,WAAW,SAAS,IAAI,WAAW,QAAQ,UAAU,aAAa,IAAI,IAAI,QAAQ,GAC5F,OAAO,EAAE,MAAM,UAAU;CAE3B,IAAI,IAAI,aAAa,SAAS,IAAI,aAAa,gBAAgB,IAAI,SAAS,WAAW,aAAa,GAClG,OAAO,EAAE,MAAM,UAAU;CAI3B,IAAI,IAAI,SAAS,WAAW,WAAW,KAAK,IAAI,SAAS,WAAW,OAAO,GAAG,OAAO,EAAE,MAAM,UAAU;CACvG,IAAI,QAAQ,SAAS,cAAc,QAAQ,gBAAgB,YAAY,OAAO,EAAE,MAAM,WAAW;CACjG,IAAI,IAAI,aAAa,gBAAgB,IAAI,SAAS,WAAW,aAAa,GAAG,OAAO,EAAE,MAAM,MAAM;CAClG,IAAI,IAAI,SAAS,WAAA,UAAwB,GAAG;EAC1C,MAAM,OAAO,SAAS,IAAI,QAAQ;EAClC,OAAO,SAAS,QAAQ,SAAS,SAAS,EAAE,MAAM,SAAS,IAAI;GAAE,MAAM;GAAS;EAAK;CACvF;CAEA,OAAO,EAAE,MAAM,SAAS;AAC1B;AAYA,IAAa,qBAAqB,UAAgC,MAAM,SAAS,MAAM,QAAQ,CAAC,MAAM"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import { isPwaCache } from "../names.js";
2
+ //#region src/sw/remove-sw.ts
3
+ self.addEventListener("install", () => {
4
+ self.skipWaiting();
5
+ });
6
+ self.addEventListener("activate", (event) => {
7
+ event.waitUntil((async () => {
8
+ const names = (await caches.keys()).filter(isPwaCache);
9
+ await Promise.all(names.map((name) => caches.delete(name)));
10
+ await self.registration.unregister();
11
+ })());
12
+ });
13
+ //#endregion
14
+ export {};
15
+
16
+ //# sourceMappingURL=remove-sw.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remove-sw.js","names":[],"sources":["../../src/sw/remove-sw.ts"],"sourcesContent":["// The emergency worker (offline-and-updates.md, Rule 5). The build writes it as\n// `sw-remove.js` beside sw.js, and the site serves it as /sw.js while the kill switch is on.\n// The browser's own update check then replaces a broken worker on every device, even one too\n// broken to read the flag file: it activates at once, deletes the PWA's caches and\n// unregisters itself. Open pages keep running (a game in progress is never reloaded,\n// Rule 2); their next load comes straight from the network. Saves in IndexedDB are untouched.\nimport { isPwaCache } from '../names.ts';\n\ndeclare const self: ServiceWorkerGlobalScope;\n\nself.addEventListener('install', () => {\n void self.skipWaiting();\n});\n\nself.addEventListener('activate', (event) => {\n event.waitUntil(\n (async () => {\n const names = (await caches.keys()).filter(isPwaCache);\n await Promise.all(names.map((name) => caches.delete(name)));\n await self.registration.unregister();\n })(),\n );\n});\n"],"mappings":";;AAUA,KAAK,iBAAiB,iBAAiB;CACrC,KAAU,YAAY;AACxB,CAAC;AAED,KAAK,iBAAiB,aAAa,UAAU;CAC3C,MAAM,WACH,YAAY;EACX,MAAM,SAAS,MAAM,OAAO,KAAK,EAAA,CAAG,OAAO,UAAU;EACrD,MAAM,QAAQ,IAAI,MAAM,KAAK,SAAS,OAAO,OAAO,IAAI,CAAC,CAAC;EAC1D,MAAM,KAAK,aAAa,WAAW;CACrC,EAAA,CAAG,CACL;AACF,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
package/dist/sw/sw.js ADDED
@@ -0,0 +1,165 @@
1
+ import { CONTROL_URL, MESSAGES, PRECACHE_NAME, PRECACHE_PREFIX, RECENT_KINDS, isPwaCache, parsePinnedCacheName, recentCacheName } from "../names.js";
2
+ import { RECENT_LIMITS, routeFor } from "../routing.js";
3
+ import { isDisabled, parseControl } from "../control.js";
4
+ import { pinnedGames } from "../client/pinned.js";
5
+ import { markOfflineResponse } from "../offline-mark.js";
6
+ import { withoutPreloadResponse } from "../preloads.js";
7
+ import { setCacheNameDetails } from "workbox-core";
8
+ import { ExpirationPlugin } from "workbox-expiration";
9
+ import { cleanupOutdatedCaches, matchPrecache, precache } from "workbox-precaching";
10
+ import { registerRoute } from "workbox-routing";
11
+ import { StaleWhileRevalidate } from "workbox-strategies";
12
+ //#region src/sw/sw.ts
13
+ var RELEASE = __JOYSTYK_RELEASE__;
14
+ setCacheNameDetails({
15
+ prefix: PRECACHE_PREFIX,
16
+ precache: PRECACHE_NAME,
17
+ suffix: "v1"
18
+ });
19
+ precache(self.__WB_MANIFEST);
20
+ cleanupOutdatedCaches();
21
+ var recent = Object.fromEntries(RECENT_KINDS.map((kind) => [kind, new StaleWhileRevalidate({
22
+ cacheName: recentCacheName(kind),
23
+ plugins: [new ExpirationPlugin({
24
+ ...RECENT_LIMITS[kind],
25
+ purgeOnQuotaError: true
26
+ })]
27
+ })]));
28
+ /**
29
+ * The blog's fix: a strategy's background work (the revalidating fetch, the cache write and
30
+ * the expiration that follows) is only surfaced through `handleAll()`'s second promise.
31
+ * Without handing it to the fetch event's `waitUntil`, the browser may stop the worker
32
+ * mid-write, and the caps stop holding.
33
+ */
34
+ var withExtendedLifetime = (strategy, event, request) => {
35
+ const [response, done] = strategy.handleAll({
36
+ event,
37
+ request
38
+ });
39
+ event.waitUntil(done);
40
+ return response;
41
+ };
42
+ /** A pinned game's copy of `request`, from any `game:<id>:<version>` cache. */
43
+ var matchPinned = async (request, options) => {
44
+ for (const name of await caches.keys()) {
45
+ if (parsePinnedCacheName(name) === null) continue;
46
+ const hit = await (await caches.open(name)).match(request, options);
47
+ if (hit !== void 0) return hit;
48
+ }
49
+ };
50
+ /** Wait this long for the network before a page falls back to a cached copy, when one exists. */
51
+ var NETWORK_TIMEOUT_MS = 8e3;
52
+ var networkFirst = async (request, fallback) => {
53
+ const network = fetch(request);
54
+ let timer;
55
+ const timeout = new Promise((resolve) => {
56
+ timer = setTimeout(() => void fallback().then(resolve, () => resolve(void 0)), NETWORK_TIMEOUT_MS);
57
+ });
58
+ try {
59
+ return await Promise.race([network, timeout]) ?? await network;
60
+ } catch {
61
+ return fallback();
62
+ } finally {
63
+ clearTimeout(timer);
64
+ }
65
+ };
66
+ var offlineJson = () => new Response(JSON.stringify({ error: "offline" }), {
67
+ status: 503,
68
+ headers: {
69
+ "content-type": "application/json; charset=utf-8",
70
+ "cache-control": "no-store"
71
+ }
72
+ });
73
+ var handle = async (route, { event, request, url }) => {
74
+ switch (route._tag) {
75
+ case "Document": {
76
+ const response = await networkFirst(request, async () => {
77
+ const cached = await matchPinned(request, { ignoreSearch: true }) ?? await matchPrecache(url.pathname) ?? await matchPrecache("/offline");
78
+ if (cached === void 0) return void 0;
79
+ const pins = await pinnedGames({
80
+ caches,
81
+ origin: self.location.origin
82
+ }).catch(() => []);
83
+ return markOfflineResponse(cached, pins.map((pin) => pin.id));
84
+ });
85
+ return response === void 0 ? Response.error() : withoutPreloadResponse(response);
86
+ }
87
+ case "Api": return await networkFirst(request, () => matchPinned(request)) ?? offlineJson();
88
+ case "Asset": return await matchPrecache(request) ?? await matchPinned(request) ?? withExtendedLifetime(recent[route.kind], event, request);
89
+ case "Pinned": return await matchPrecache(request) ?? await matchPinned(request) ?? fetch(request);
90
+ case "Network": return fetch(request);
91
+ }
92
+ };
93
+ registerRoute(({ request, url }) => routeFor({
94
+ method: request.method,
95
+ cache: request.cache,
96
+ pinRefresh: request.headers.get("X-Joystyk-Pin-Refresh") === "1",
97
+ destination: request.destination,
98
+ mode: request.mode,
99
+ url,
100
+ origin: self.location.origin
101
+ })._tag !== "Network", (options) => {
102
+ const request = options.request instanceof Request ? options.request : new Request(options.request);
103
+ return handle(routeFor({
104
+ method: request.method,
105
+ cache: request.cache,
106
+ pinRefresh: request.headers.get("X-Joystyk-Pin-Refresh") === "1",
107
+ destination: request.destination,
108
+ mode: request.mode,
109
+ url: options.url,
110
+ origin: self.location.origin
111
+ }), {
112
+ ...options,
113
+ request
114
+ });
115
+ });
116
+ /** How often page loads may make the worker reread the flag file. */
117
+ var CONTROL_INTERVAL_MS = 3e5;
118
+ var lastControlCheck = 0;
119
+ /** Unregisters this worker, deletes every PWA cache and tells open pages. Saves are untouched. */
120
+ var disable = async () => {
121
+ await self.registration.unregister();
122
+ const names = (await caches.keys()).filter(isPwaCache);
123
+ await Promise.all(names.map((name) => caches.delete(name)));
124
+ for (const client of await self.clients.matchAll({ includeUncontrolled: true })) client.postMessage({ type: MESSAGES.disabled });
125
+ };
126
+ /** Reads the flag file; true when it disabled the worker. A failed read changes nothing. */
127
+ var checkControl = async (force) => {
128
+ const now = Date.now();
129
+ if (!force && now - lastControlCheck < CONTROL_INTERVAL_MS) return false;
130
+ lastControlCheck = now;
131
+ let control = null;
132
+ try {
133
+ const response = await fetch(CONTROL_URL, {
134
+ cache: "no-store",
135
+ credentials: "omit"
136
+ });
137
+ if (response.ok) control = parseControl(await response.json());
138
+ } catch {
139
+ return false;
140
+ }
141
+ if (!isDisabled(control)) return false;
142
+ await disable();
143
+ return true;
144
+ };
145
+ self.addEventListener("install", (event) => {
146
+ event.waitUntil(checkControl(true).then((disabled) => {
147
+ if (disabled) throw new Error("The PWA is disabled by /pwa-control.json.");
148
+ }));
149
+ });
150
+ self.addEventListener("activate", (event) => {
151
+ event.waitUntil(checkControl(true));
152
+ });
153
+ self.addEventListener("fetch", (event) => {
154
+ if (event.request.mode === "navigate") event.waitUntil(checkControl(false));
155
+ });
156
+ self.addEventListener("message", (event) => {
157
+ const type = event.data?.type;
158
+ if (type === MESSAGES.skipWaiting) self.skipWaiting();
159
+ else if (type === MESSAGES.checkControl) event.waitUntil(checkControl(true));
160
+ else if (type === MESSAGES.release) event.ports[0]?.postMessage({ release: RELEASE });
161
+ });
162
+ //#endregion
163
+ export {};
164
+
165
+ //# sourceMappingURL=sw.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sw.js","names":[],"sources":["../../src/sw/sw.ts"],"sourcesContent":["// The service worker, built from Workbox modules and bundled into one classic /sw.js by\n// '@joystyk/pwa/build', which also injects the precache list and the release ID. The\n// decisions live in ../routing.ts (pure and tested in Node); this file wires them to caches.\n// The design is offline-and-updates.md; the blog's worker (references/pwa-offline.md) is the\n// model, including its waitUntil fix.\n//\n// Tier 1 precache: the shell, the launcher, the offline page, icons. Replaced each release.\n// Tier 2 recent: capped stale-while-revalidate caches per kind (code, art, audio, wasm).\n// Tier 3 pinned: `game:<id>:<version>` caches the page fills; never evicted here.\n//\n// A page answered from a cache because the network failed is marked `data-offline` on its\n// <html> tag (../offline-mark.ts).\n//\n// It never activates itself: a new worker waits until the page, at a safe point, posts\n// SKIP_WAITING (Rule 2). A `disabled` flag file unregisters it and clears its caches (Rule 5).\nimport { setCacheNameDetails } from 'workbox-core';\nimport type { RouteHandlerCallbackOptions } from 'workbox-core/types.js';\nimport { ExpirationPlugin } from 'workbox-expiration';\nimport { cleanupOutdatedCaches, matchPrecache, precache, type PrecacheEntry } from 'workbox-precaching';\nimport { registerRoute } from 'workbox-routing';\nimport { StaleWhileRevalidate, type Strategy } from 'workbox-strategies';\nimport { isDisabled, parseControl } from '../control.ts';\nimport {\n CONTROL_URL,\n isPwaCache,\n MESSAGES,\n OFFLINE_URL,\n parsePinnedCacheName,\n PRECACHE_NAME,\n PRECACHE_PREFIX,\n RECENT_KINDS,\n recentCacheName,\n type RecentKind,\n} from '../names.ts';\nimport { markOfflineResponse } from '../offline-mark.ts';\nimport { pinnedGames } from '../client/pinned.ts';\nimport { withoutPreloadResponse } from '../preloads.ts';\nimport { RECENT_LIMITS, routeFor, type Route } from '../routing.ts';\n\ndeclare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: (PrecacheEntry | string)[] };\n/** The platform release this worker belongs to; replaced at build time. */\ndeclare const __JOYSTYK_RELEASE__: string;\n\nconst RELEASE = __JOYSTYK_RELEASE__;\n\n// --- Tier 1 -------------------------------------------------------------------------------------\n\nsetCacheNameDetails({ prefix: PRECACHE_PREFIX, precache: PRECACHE_NAME, suffix: 'v1' });\n// Injected by the build from the built output. If any entry fails to download, the install\n// fails and the previous worker stays in charge; the build checks every entry exists first.\nprecache(self.__WB_MANIFEST);\ncleanupOutdatedCaches();\n\n// --- Tier 2 -------------------------------------------------------------------------------------\n\nconst recent = Object.fromEntries(\n RECENT_KINDS.map((kind) => [\n kind,\n new StaleWhileRevalidate({\n cacheName: recentCacheName(kind),\n plugins: [new ExpirationPlugin({ ...RECENT_LIMITS[kind], purgeOnQuotaError: true })],\n }),\n ]),\n) as Record<RecentKind, StaleWhileRevalidate>;\n\n/**\n * The blog's fix: a strategy's background work (the revalidating fetch, the cache write and\n * the expiration that follows) is only surfaced through `handleAll()`'s second promise.\n * Without handing it to the fetch event's `waitUntil`, the browser may stop the worker\n * mid-write, and the caps stop holding.\n */\nconst withExtendedLifetime = (strategy: Strategy, event: ExtendableEvent, request: Request): Promise<Response> => {\n const [response, done] = strategy.handleAll({ event, request });\n event.waitUntil(done);\n return response;\n};\n\n// --- Tier 3 -------------------------------------------------------------------------------------\n\n/** A pinned game's copy of `request`, from any `game:<id>:<version>` cache. */\nconst matchPinned = async (request: Request | string, options?: CacheQueryOptions): Promise<Response | undefined> => {\n for (const name of await caches.keys()) {\n if (parsePinnedCacheName(name) === null) continue;\n const hit = await (await caches.open(name)).match(request, options);\n if (hit !== undefined) return hit;\n }\n return undefined;\n};\n\n// --- Routes -------------------------------------------------------------------------------------\n\n/** Wait this long for the network before a page falls back to a cached copy, when one exists. */\nconst NETWORK_TIMEOUT_MS = 8_000;\n\nconst networkFirst = async (\n request: Request,\n fallback: () => Promise<Response | undefined>,\n): Promise<Response | undefined> => {\n const network = fetch(request);\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<Response | undefined>((resolve) => {\n timer = setTimeout(() => void fallback().then(resolve, () => resolve(undefined)), NETWORK_TIMEOUT_MS);\n });\n try {\n return (await Promise.race([network, timeout])) ?? (await network);\n } catch {\n return fallback();\n } finally {\n clearTimeout(timer);\n }\n};\n\nconst offlineJson = (): Response =>\n new Response(JSON.stringify({ error: 'offline' }), {\n status: 503,\n headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },\n });\n\nconst handle = async (\n route: Route,\n { event, request, url }: RouteHandlerCallbackOptions & { request: Request },\n): Promise<Response> => {\n switch (route._tag) {\n case 'Document': {\n // Pages are never stale: a fresh page names this release's hashed files. Offline, a\n // pinned game's page, the precached shell, or the offline page listing what plays,\n // marked as served offline so its CSS reserves no ad space at first paint.\n const response = await networkFirst(request, async () => {\n const cached =\n (await matchPinned(request, { ignoreSearch: true })) ??\n (await matchPrecache(url.pathname)) ??\n (await matchPrecache(OFFLINE_URL));\n if (cached === undefined) return undefined;\n const pins = await pinnedGames({ caches, origin: self.location.origin }).catch(() => []);\n return markOfflineResponse(\n cached,\n pins.map((pin) => pin.id),\n );\n });\n return response === undefined ? Response.error() : withoutPreloadResponse(response);\n }\n case 'Api':\n return (await networkFirst(request, () => matchPinned(request))) ?? offlineJson();\n case 'Asset':\n return (\n (await matchPrecache(request)) ??\n (await matchPinned(request)) ??\n withExtendedLifetime(recent[route.kind], event, request)\n );\n case 'Pinned':\n return (await matchPrecache(request)) ?? (await matchPinned(request)) ?? fetch(request);\n case 'Network':\n return fetch(request);\n }\n};\n\nregisterRoute(\n ({ request, url }) =>\n routeFor({\n method: request.method,\n cache: request.cache,\n pinRefresh: request.headers.get('X-Joystyk-Pin-Refresh') === '1',\n destination: request.destination,\n mode: request.mode,\n url,\n origin: self.location.origin,\n })._tag !== 'Network',\n (options) => {\n const request = options.request instanceof Request ? options.request : new Request(options.request);\n const route = routeFor({\n method: request.method,\n cache: request.cache,\n pinRefresh: request.headers.get('X-Joystyk-Pin-Refresh') === '1',\n destination: request.destination,\n mode: request.mode,\n url: options.url,\n origin: self.location.origin,\n });\n return handle(route, { ...options, request });\n },\n);\n\n// --- The kill switch (Rule 5) -------------------------------------------------------------------\n\n/** How often page loads may make the worker reread the flag file. */\nconst CONTROL_INTERVAL_MS = 5 * 60_000;\nlet lastControlCheck = 0;\n\n/** Unregisters this worker, deletes every PWA cache and tells open pages. Saves are untouched. */\nconst disable = async (): Promise<void> => {\n await self.registration.unregister();\n const names = (await caches.keys()).filter(isPwaCache);\n await Promise.all(names.map((name) => caches.delete(name)));\n for (const client of await self.clients.matchAll({ includeUncontrolled: true }))\n client.postMessage({ type: MESSAGES.disabled });\n};\n\n/** Reads the flag file; true when it disabled the worker. A failed read changes nothing. */\nconst checkControl = async (force: boolean): Promise<boolean> => {\n const now = Date.now();\n if (!force && now - lastControlCheck < CONTROL_INTERVAL_MS) return false;\n lastControlCheck = now;\n let control = null;\n try {\n const response = await fetch(CONTROL_URL, { cache: 'no-store', credentials: 'omit' });\n if (response.ok) control = parseControl(await response.json());\n } catch {\n return false;\n }\n if (!isDisabled(control)) return false;\n await disable();\n return true;\n};\n\nself.addEventListener('install', (event) => {\n // A worker installing while the switch is on must never activate.\n event.waitUntil(\n checkControl(true).then((disabled) => {\n if (disabled) throw new Error('The PWA is disabled by /pwa-control.json.');\n }),\n );\n});\n\nself.addEventListener('activate', (event) => {\n event.waitUntil(checkControl(true));\n});\n\n// Beside the router's listener: it never answers, only extends navigations to reread the flag.\nself.addEventListener('fetch', (event) => {\n if (event.request.mode === 'navigate') event.waitUntil(checkControl(false));\n});\n\n// --- Updates (Rule 2) ---------------------------------------------------------------------------\n\nself.addEventListener('message', (event) => {\n const type = (event.data as { type?: unknown } | null)?.type;\n if (type === MESSAGES.skipWaiting) void self.skipWaiting();\n else if (type === MESSAGES.checkControl) event.waitUntil(checkControl(true));\n else if (type === MESSAGES.release) event.ports[0]?.postMessage({ release: RELEASE });\n});\n"],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ import type { Validator } from '@joystyk/contracts';
2
+ /** A synchronous Standard Schema validator from a check that reports issues by message. */
3
+ export declare const validator: <T>(check: (value: unknown, issues: string[]) => T | undefined) => Validator<T>;
4
+ /** Decodes with a synchronous validator: the value, or null when it fails. */
5
+ export declare const decodeSync: <T>(schema: Validator<T>, value: unknown) => T | null;
6
+ export declare const isRecord: (value: unknown) => value is Readonly<Record<string, unknown>>;
7
+ /** A same-origin path: starts with one slash. */
8
+ export declare const isPath: (value: unknown) => value is string;
@@ -0,0 +1,24 @@
1
+ //#region src/validate.ts
2
+ /** A synchronous Standard Schema validator from a check that reports issues by message. */
3
+ var validator = (check) => ({ "~standard": {
4
+ version: 1,
5
+ vendor: "joystyk-pwa",
6
+ validate: (value) => {
7
+ const issues = [];
8
+ const output = check(value, issues);
9
+ return issues.length === 0 && output !== void 0 ? { value: output } : { issues: (issues.length === 0 ? ["Invalid value"] : issues).map((message) => ({ message })) };
10
+ }
11
+ } });
12
+ /** Decodes with a synchronous validator: the value, or null when it fails. */
13
+ var decodeSync = (schema, value) => {
14
+ const result = schema["~standard"].validate(value);
15
+ if (result instanceof Promise) return null;
16
+ return result.issues === void 0 ? result.value : null;
17
+ };
18
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
19
+ /** A same-origin path: starts with one slash. */
20
+ var isPath = (value) => typeof value === "string" && value.startsWith("/") && !value.startsWith("//");
21
+ //#endregion
22
+ export { decodeSync, isPath, isRecord, validator };
23
+
24
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","names":[],"sources":["../src/validate.ts"],"sourcesContent":["// A tiny Standard Schema (D-18) for the two small files the worker and the offline page read:\n// the kill-switch flag and a pin's record. A schema library would outweigh both scripts, and\n// the worker has to stay small (Rule 5). The page decodes the larger asset manifest with Zod.\nimport type { Validator } from '@joystyk/contracts';\n\n/** A synchronous Standard Schema validator from a check that reports issues by message. */\nexport const validator = <T>(check: (value: unknown, issues: string[]) => T | undefined): Validator<T> => ({\n '~standard': {\n version: 1,\n vendor: 'joystyk-pwa',\n validate: (value: unknown) => {\n const issues: string[] = [];\n const output = check(value, issues);\n return issues.length === 0 && output !== undefined\n ? { value: output }\n : { issues: (issues.length === 0 ? ['Invalid value'] : issues).map((message) => ({ message })) };\n },\n },\n});\n\n/** Decodes with a synchronous validator: the value, or null when it fails. */\nexport const decodeSync = <T>(schema: Validator<T>, value: unknown): T | null => {\n const result = schema['~standard'].validate(value);\n if (result instanceof Promise) return null;\n return result.issues === undefined ? result.value : null;\n};\n\nexport const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/** A same-origin path: starts with one slash. */\nexport const isPath = (value: unknown): value is string =>\n typeof value === 'string' && value.startsWith('/') && !value.startsWith('//');\n"],"mappings":";;AAMA,IAAa,aAAgB,WAA8E,EACzG,aAAa;CACX,SAAS;CACT,QAAQ;CACR,WAAW,UAAmB;EAC5B,MAAM,SAAmB,CAAC;EAC1B,MAAM,SAAS,MAAM,OAAO,MAAM;EAClC,OAAO,OAAO,WAAW,KAAK,WAAW,KAAA,IACrC,EAAE,OAAO,OAAO,IAChB,EAAE,SAAS,OAAO,WAAW,IAAI,CAAC,eAAe,IAAI,OAAA,CAAQ,KAAK,aAAa,EAAE,QAAQ,EAAE,EAAE;CACnG;AACF,EACF;;AAGA,IAAa,cAAiB,QAAsB,UAA6B;CAC/E,MAAM,SAAS,OAAO,YAAY,CAAC,SAAS,KAAK;CACjD,IAAI,kBAAkB,SAAS,OAAO;CACtC,OAAO,OAAO,WAAW,KAAA,IAAY,OAAO,QAAQ;AACtD;AAEA,IAAa,YAAY,UACvB,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;;AAGrE,IAAa,UAAU,UACrB,OAAO,UAAU,YAAY,MAAM,WAAW,GAAG,KAAK,CAAC,MAAM,WAAW,IAAI"}
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "@joystyk/pwa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "The Joystyk PWA: the service worker and its cache tiers, offline pinning, update prompts at safe points, the kill switch, and the build step that emits sw.js and the asset manifests.",
5
+ "private": false,
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./dist/client/index.d.ts",
15
+ "default": "./dist/client/index.js"
16
+ },
17
+ "./build": {
18
+ "types": "./dist/build/index.d.ts",
19
+ "default": "./dist/build/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "workbox-core": "7.4.1",
24
+ "workbox-expiration": "7.4.1",
25
+ "workbox-precaching": "7.4.1",
26
+ "workbox-routing": "7.4.1",
27
+ "workbox-strategies": "7.4.1",
28
+ "zod": "4.6.5",
29
+ "@joystyk/contracts": "0.2.0",
30
+ "@joystyk/daily": "0.2.0",
31
+ "@joystyk/storage": "0.2.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "24.10.1",
35
+ "typescript": "5.9.3",
36
+ "vite": "8.3.3",
37
+ "vitest": "4.1.11"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "README.md",
45
+ "LICENSE",
46
+ "NOTICE",
47
+ "CHANGELOG.md"
48
+ ],
49
+ "peerDependencies": {
50
+ "vite": "8.3.3"
51
+ },
52
+ "engines": {
53
+ "node": ">=24.15.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "vite": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "license": "MIT",
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/joystyk-games/game-libraries.git",
64
+ "directory": "packages/pwa"
65
+ },
66
+ "homepage": "https://github.com/joystyk-games/game-libraries/tree/main/packages/pwa#readme",
67
+ "bugs": {
68
+ "url": "https://github.com/joystyk-games/game-libraries/issues"
69
+ },
70
+ "scripts": {
71
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
72
+ "build": "node ../../scripts/build-package.ts"
73
+ }
6
74
  }