@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +215 -2
  3. package/dist/asset-manifest.d.ts +76 -0
  4. package/dist/asset-manifest.js +80 -0
  5. package/dist/asset-manifest.js.map +1 -0
  6. package/dist/build/documents.d.ts +79 -0
  7. package/dist/build/documents.js +135 -0
  8. package/dist/build/documents.js.map +1 -0
  9. package/dist/build/graph.d.ts +39 -0
  10. package/dist/build/graph.js +70 -0
  11. package/dist/build/graph.js.map +1 -0
  12. package/dist/build/icons.d.ts +30 -0
  13. package/dist/build/icons.js +192 -0
  14. package/dist/build/icons.js.map +1 -0
  15. package/dist/build/index.d.ts +4 -0
  16. package/dist/build/index.js +5 -0
  17. package/dist/build/plugin.d.ts +32 -0
  18. package/dist/build/plugin.js +172 -0
  19. package/dist/build/plugin.js.map +1 -0
  20. package/dist/client/availability.d.ts +12 -0
  21. package/dist/client/availability.js +94 -0
  22. package/dist/client/availability.js.map +1 -0
  23. package/dist/client/control.d.ts +3 -0
  24. package/dist/client/control.js +19 -0
  25. package/dist/client/control.js.map +1 -0
  26. package/dist/client/elements.d.ts +45 -0
  27. package/dist/client/elements.js +319 -0
  28. package/dist/client/elements.js.map +1 -0
  29. package/dist/client/forced.d.ts +33 -0
  30. package/dist/client/forced.js +57 -0
  31. package/dist/client/forced.js.map +1 -0
  32. package/dist/client/index.d.ts +7 -0
  33. package/dist/client/index.js +8 -0
  34. package/dist/client/pinned.d.ts +13 -0
  35. package/dist/client/pinned.js +29 -0
  36. package/dist/client/pinned.js.map +1 -0
  37. package/dist/client/pins.d.ts +43 -0
  38. package/dist/client/pins.js +140 -0
  39. package/dist/client/pins.js.map +1 -0
  40. package/dist/client/register.d.ts +64 -0
  41. package/dist/client/register.js +255 -0
  42. package/dist/client/register.js.map +1 -0
  43. package/dist/control.d.ts +46 -0
  44. package/dist/control.js +88 -0
  45. package/dist/control.js.map +1 -0
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.js +8 -0
  48. package/dist/names.d.ts +47 -0
  49. package/dist/names.js +64 -0
  50. package/dist/names.js.map +1 -0
  51. package/dist/offline/offline.css +94 -0
  52. package/dist/offline/offline.d.ts +1 -0
  53. package/dist/offline/offline.js +26 -0
  54. package/dist/offline/offline.js.map +1 -0
  55. package/dist/offline-mark.d.ts +15 -0
  56. package/dist/offline-mark.js +51 -0
  57. package/dist/offline-mark.js.map +1 -0
  58. package/dist/pin-record.d.ts +14 -0
  59. package/dist/pin-record.js +36 -0
  60. package/dist/pin-record.js.map +1 -0
  61. package/dist/preloads.d.ts +2 -0
  62. package/dist/preloads.js +26 -0
  63. package/dist/preloads.js.map +1 -0
  64. package/dist/routing.d.ts +57 -0
  65. package/dist/routing.js +81 -0
  66. package/dist/routing.js.map +1 -0
  67. package/dist/sw/remove-sw.d.ts +1 -0
  68. package/dist/sw/remove-sw.js +16 -0
  69. package/dist/sw/remove-sw.js.map +1 -0
  70. package/dist/sw/sw.d.ts +1 -0
  71. package/dist/sw/sw.js +165 -0
  72. package/dist/sw/sw.js.map +1 -0
  73. package/dist/validate.d.ts +8 -0
  74. package/dist/validate.js +24 -0
  75. package/dist/validate.js.map +1 -0
  76. package/package.json +65 -11
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":";;;;;;;;;;;;AA2CA,IAAM,UAAU;AAIhB,oBAAoB;CAAE,QAAQ;CAAiB,UAAU;CAAe,QAAQ;AAAK,CAAC;AAGtF,SAAS,KAAK,aAAa;AAC3B,sBAAsB;AAItB,IAAM,SAAS,OAAO,YACpB,aAAa,KAAK,SAAS,CACzB,MACA,IAAI,qBAAqB;CACvB,WAAW,gBAAgB,IAAI;CAC/B,SAAS,CAAC,IAAI,iBAAiB;EAAE,GAAG,cAAc;EAAO,mBAAmB;CAAK,CAAC,CAAC;AACrF,CAAC,CACH,CAAC,CACH;;;;;;;AAQA,IAAM,wBAAwB,UAAoB,OAAwB,YAAwC;CAChH,MAAM,CAAC,UAAU,QAAQ,SAAS,UAAU;EAAE;EAAO;CAAQ,CAAC;CAC9D,MAAM,UAAU,IAAI;CACpB,OAAO;AACT;;AAKA,IAAM,cAAc,OAAO,SAA2B,YAA+D;CACnH,KAAK,MAAM,QAAQ,MAAM,OAAO,KAAK,GAAG;EACtC,IAAI,qBAAqB,IAAI,MAAM,MAAM;EACzC,MAAM,MAAM,OAAO,MAAM,OAAO,KAAK,IAAI,EAAA,CAAG,MAAM,SAAS,OAAO;EAClE,IAAI,QAAQ,KAAA,GAAW,OAAO;CAChC;AAEF;;AAKA,IAAM,qBAAqB;AAE3B,IAAM,eAAe,OACnB,SACA,aACkC;CAClC,MAAM,UAAU,MAAM,OAAO;CAC7B,IAAI;CACJ,MAAM,UAAU,IAAI,SAA+B,YAAY;EAC7D,QAAQ,iBAAiB,KAAK,SAAS,CAAC,CAAC,KAAK,eAAe,QAAQ,KAAA,CAAS,CAAC,GAAG,kBAAkB;CACtG,CAAC;CACD,IAAI;EACF,OAAQ,MAAM,QAAQ,KAAK,CAAC,SAAS,OAAO,CAAC,KAAO,MAAM;CAC5D,QAAQ;EACN,OAAO,SAAS;CAClB,UAAU;EACR,aAAa,KAAK;CACpB;AACF;AAEA,IAAM,oBACJ,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,UAAU,CAAC,GAAG;CACjD,QAAQ;CACR,SAAS;EAAE,gBAAgB;EAAmC,iBAAiB;CAAW;AAC5F,CAAC;AAEH,IAAM,SAAS,OACb,OACA,EAAE,OAAO,SAAS,UACI;CACtB,QAAQ,MAAM,MAAd;EACE,KAAK,YAAY;GAIf,MAAM,WAAW,MAAM,aAAa,SAAS,YAAY;IACvD,MAAM,SACH,MAAM,YAAY,SAAS,EAAE,cAAc,KAAK,CAAC,KACjD,MAAM,cAAc,IAAI,QAAQ,KAChC,MAAM,cAAA,UAAyB;IAClC,IAAI,WAAW,KAAA,GAAW,OAAO,KAAA;IACjC,MAAM,OAAO,MAAM,YAAY;KAAE;KAAQ,QAAQ,KAAK,SAAS;IAAO,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;IACvF,OAAO,oBACL,QACA,KAAK,KAAK,QAAQ,IAAI,EAAE,CAC1B;GACF,CAAC;GACD,OAAO,aAAa,KAAA,IAAY,SAAS,MAAM,IAAI,uBAAuB,QAAQ;EACpF;EACA,KAAK,OACH,OAAQ,MAAM,aAAa,eAAe,YAAY,OAAO,CAAC,KAAM,YAAY;EAClF,KAAK,SACH,OACG,MAAM,cAAc,OAAO,KAC3B,MAAM,YAAY,OAAO,KAC1B,qBAAqB,OAAO,MAAM,OAAO,OAAO,OAAO;EAE3D,KAAK,UACH,OAAQ,MAAM,cAAc,OAAO,KAAO,MAAM,YAAY,OAAO,KAAM,MAAM,OAAO;EACxF,KAAK,WACH,OAAO,MAAM,OAAO;CACxB;AACF;AAEA,eACG,EAAE,SAAS,UACV,SAAS;CACP,QAAQ,QAAQ;CAChB,OAAO,QAAQ;CACf,YAAY,QAAQ,QAAQ,IAAI,uBAAuB,MAAM;CAC7D,aAAa,QAAQ;CACrB,MAAM,QAAQ;CACd;CACA,QAAQ,KAAK,SAAS;AACxB,CAAC,CAAC,CAAC,SAAS,YACb,YAAY;CACX,MAAM,UAAU,QAAQ,mBAAmB,UAAU,QAAQ,UAAU,IAAI,QAAQ,QAAQ,OAAO;CAUlG,OAAO,OATO,SAAS;EACrB,QAAQ,QAAQ;EAChB,OAAO,QAAQ;EACf,YAAY,QAAQ,QAAQ,IAAI,uBAAuB,MAAM;EAC7D,aAAa,QAAQ;EACrB,MAAM,QAAQ;EACd,KAAK,QAAQ;EACb,QAAQ,KAAK,SAAS;CACxB,CACc,GAAO;EAAE,GAAG;EAAS;CAAQ,CAAC;AAC9C,CACF;;AAKA,IAAM,sBAAsB;AAC5B,IAAI,mBAAmB;;AAGvB,IAAM,UAAU,YAA2B;CACzC,MAAM,KAAK,aAAa,WAAW;CACnC,MAAM,SAAS,MAAM,OAAO,KAAK,EAAA,CAAG,OAAO,UAAU;CACrD,MAAM,QAAQ,IAAI,MAAM,KAAK,SAAS,OAAO,OAAO,IAAI,CAAC,CAAC;CAC1D,KAAK,MAAM,UAAU,MAAM,KAAK,QAAQ,SAAS,EAAE,qBAAqB,KAAK,CAAC,GAC5E,OAAO,YAAY,EAAE,MAAM,SAAS,SAAS,CAAC;AAClD;;AAGA,IAAM,eAAe,OAAO,UAAqC;CAC/D,MAAM,MAAM,KAAK,IAAI;CACrB,IAAI,CAAC,SAAS,MAAM,mBAAmB,qBAAqB,OAAO;CACnE,mBAAmB;CACnB,IAAI,UAAU;CACd,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,aAAa;GAAE,OAAO;GAAY,aAAa;EAAO,CAAC;EACpF,IAAI,SAAS,IAAI,UAAU,aAAa,MAAM,SAAS,KAAK,CAAC;CAC/D,QAAQ;EACN,OAAO;CACT;CACA,IAAI,CAAC,WAAW,OAAO,GAAG,OAAO;CACjC,MAAM,QAAQ;CACd,OAAO;AACT;AAEA,KAAK,iBAAiB,YAAY,UAAU;CAE1C,MAAM,UACJ,aAAa,IAAI,CAAC,CAAC,MAAM,aAAa;EACpC,IAAI,UAAU,MAAM,IAAI,MAAM,2CAA2C;CAC3E,CAAC,CACH;AACF,CAAC;AAED,KAAK,iBAAiB,aAAa,UAAU;CAC3C,MAAM,UAAU,aAAa,IAAI,CAAC;AACpC,CAAC;AAGD,KAAK,iBAAiB,UAAU,UAAU;CACxC,IAAI,MAAM,QAAQ,SAAS,YAAY,MAAM,UAAU,aAAa,KAAK,CAAC;AAC5E,CAAC;AAID,KAAK,iBAAiB,YAAY,UAAU;CAC1C,MAAM,OAAQ,MAAM,MAAoC;CACxD,IAAI,SAAS,SAAS,aAAa,KAAU,YAAY;MACpD,IAAI,SAAS,SAAS,cAAc,MAAM,UAAU,aAAa,IAAI,CAAC;MACtE,IAAI,SAAS,SAAS,SAAS,MAAM,MAAM,EAAE,EAAE,YAAY,EAAE,SAAS,QAAQ,CAAC;AACtF,CAAC"}
@@ -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,20 +1,74 @@
1
1
  {
2
2
  "name": "@joystyk/pwa",
3
- "version": "0.0.0",
4
- "description": "Reserved for the first Joystyk library release.",
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
+ },
5
60
  "license": "MIT",
6
61
  "repository": {
7
62
  "type": "git",
8
63
  "url": "git+https://github.com/joystyk-games/game-libraries.git",
9
64
  "directory": "packages/pwa"
10
65
  },
11
- "homepage": "https://github.com/joystyk-games/game-libraries",
12
- "files": [
13
- "README.md",
14
- "LICENSE",
15
- "NOTICE"
16
- ],
17
- "publishConfig": {
18
- "access": "public"
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"
19
73
  }
20
- }
74
+ }