@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,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"}
@@ -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 };
@@ -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"}
@@ -0,0 +1,94 @@
1
+ /* The offline page stands alone: it can't count on the site's inlined stylesheets, so it
2
+ carries the few Joystyk colors it needs. */
3
+ :root {
4
+ color-scheme: light dark;
5
+ --surface: light-dark(oklch(98.5% 0.006 285), oklch(17% 0.02 285));
6
+ --text: light-dark(oklch(22% 0.03 285), oklch(94% 0.012 285));
7
+ --muted: color-mix(in oklch, var(--text) 68%, var(--surface));
8
+ --accent: light-dark(oklch(50% 0.2 285), oklch(76% 0.13 285));
9
+ --on-accent: light-dark(oklch(99% 0.005 285), oklch(18% 0.03 285));
10
+ --magenta: oklch(68% 0.25 350);
11
+ }
12
+
13
+ body {
14
+ margin: 0;
15
+ background: var(--surface);
16
+ color: var(--text);
17
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
18
+ font-size: 1.0625rem;
19
+ line-height: 1.5;
20
+ }
21
+
22
+ main {
23
+ max-inline-size: 40rem;
24
+ margin-inline: auto;
25
+ padding: 2rem 1rem 3rem;
26
+ }
27
+
28
+ .kicker {
29
+ margin: 0;
30
+ font-weight: 800;
31
+ letter-spacing: 0.14em;
32
+ text-transform: uppercase;
33
+ color: var(--magenta);
34
+ }
35
+
36
+ h1 {
37
+ margin-block: 0.25rem 0.5rem;
38
+ font-size: clamp(1.9rem, 1.5rem + 2vw, 2.8rem);
39
+ line-height: 1.1;
40
+ }
41
+
42
+ h2 {
43
+ font-size: 1.25rem;
44
+ margin-block: 2rem 0.5rem;
45
+ }
46
+
47
+ .lede,
48
+ #none {
49
+ color: var(--muted);
50
+ }
51
+
52
+ a {
53
+ color: var(--accent);
54
+ }
55
+
56
+ #games {
57
+ padding-inline-start: 1.25rem;
58
+ }
59
+
60
+ #games a {
61
+ display: inline-block;
62
+ min-block-size: 2.75rem;
63
+ padding-block: 0.6rem;
64
+ font-weight: 600;
65
+ }
66
+
67
+ .actions {
68
+ display: flex;
69
+ flex-wrap: wrap;
70
+ align-items: center;
71
+ gap: 1rem;
72
+ margin-block-start: 2rem;
73
+ }
74
+
75
+ button {
76
+ min-block-size: 2.75rem;
77
+ padding: 0.25rem 1.25rem;
78
+ border: 0;
79
+ border-radius: 999px;
80
+ background: var(--accent);
81
+ color: var(--on-accent);
82
+ font: inherit;
83
+ font-weight: 600;
84
+ cursor: pointer;
85
+ }
86
+
87
+ :focus-visible {
88
+ outline: 3px solid var(--accent);
89
+ outline-offset: 2px;
90
+ }
91
+
92
+ [hidden] {
93
+ display: none !important;
94
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import "../names.js";
2
+ import { pinnedGames } from "../client/pinned.js";
3
+ //#region src/offline/offline.ts
4
+ var where = document.getElementById("where");
5
+ var list = document.getElementById("games");
6
+ var none = document.getElementById("none");
7
+ if (where !== null && location.pathname !== "/offline") where.textContent = `This page (${decodeURI(location.pathname)}) isn't available offline. It needs an internet connection.`;
8
+ document.getElementById("retry")?.addEventListener("click", () => location.reload());
9
+ (async () => {
10
+ if (list === null || none === null || !("caches" in window)) return;
11
+ const games = await pinnedGames().catch(() => []);
12
+ for (const game of games) {
13
+ const item = document.createElement("li");
14
+ const link = document.createElement("a");
15
+ link.href = game.page;
16
+ link.textContent = game.name;
17
+ item.append(link);
18
+ list.append(item);
19
+ }
20
+ list.hidden = games.length === 0;
21
+ none.hidden = games.length > 0;
22
+ })();
23
+ //#endregion
24
+ export {};
25
+
26
+ //# sourceMappingURL=offline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"offline.js","names":[],"sources":["../../src/offline/offline.ts"],"sourcesContent":["// The offline page's script: lists the games this device can play offline, read from the\n// pinned caches' own records. The worker serves this page, precached, for any page that\n// failed to load, so it also names the page that wasn't available.\nimport { OFFLINE_URL } from '../names.ts';\nimport { pinnedGames } from '../client/pinned.ts';\n\nconst where = document.getElementById('where');\nconst list = document.getElementById('games');\nconst none = document.getElementById('none');\n\nif (where !== null && location.pathname !== OFFLINE_URL) {\n where.textContent = `This page (${decodeURI(location.pathname)}) isn't available offline. It needs an internet connection.`;\n}\n\ndocument.getElementById('retry')?.addEventListener('click', () => location.reload());\n\nvoid (async () => {\n if (list === null || none === null || !('caches' in window)) return;\n const games = await pinnedGames().catch(() => []);\n for (const game of games) {\n const item = document.createElement('li');\n const link = document.createElement('a');\n link.href = game.page;\n link.textContent = game.name;\n item.append(link);\n list.append(item);\n }\n list.hidden = games.length === 0;\n none.hidden = games.length > 0;\n})();\n"],"mappings":";;;AAMA,IAAM,QAAQ,SAAS,eAAe,OAAO;AAC7C,IAAM,OAAO,SAAS,eAAe,OAAO;AAC5C,IAAM,OAAO,SAAS,eAAe,MAAM;AAE3C,IAAI,UAAU,QAAQ,SAAS,aAAA,YAC7B,MAAM,cAAc,cAAc,UAAU,SAAS,QAAQ,EAAE;AAGjE,SAAS,eAAe,OAAO,CAAC,EAAE,iBAAiB,eAAe,SAAS,OAAO,CAAC;CAE7E,YAAY;CAChB,IAAI,SAAS,QAAQ,SAAS,QAAQ,EAAE,YAAY,SAAS;CAC7D,MAAM,QAAQ,MAAM,YAAY,CAAC,CAAC,YAAY,CAAC,CAAC;CAChD,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,OAAO,SAAS,cAAc,IAAI;EACxC,MAAM,OAAO,SAAS,cAAc,GAAG;EACvC,KAAK,OAAO,KAAK;EACjB,KAAK,cAAc,KAAK;EACxB,KAAK,OAAO,IAAI;EAChB,KAAK,OAAO,IAAI;CAClB;CACA,KAAK,SAAS,MAAM,WAAW;CAC/B,KAAK,SAAS,MAAM,SAAS;AAC/B,EAAA,CAAG"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `html` with `data-offline` on its <html> tag. A document without an <html> tag, or already
3
+ * marked, comes back unchanged. Only the tag changes: no script or style is added, so the
4
+ * page's Content-Security-Policy hashes still hold.
5
+ */
6
+ export declare const markOfflineHtml: (html: string) => string;
7
+ /** Render pin-derived labels before first paint, so hydration need not grow every
8
+ * catalog card and lineup row. Raw text and executable content keep their bytes. */
9
+ export declare const markOfflineAvailability: (html: string, pinned: readonly string[]) => string;
10
+ /**
11
+ * A cached page, marked as served offline. Status and headers carry over, except
12
+ * Content-Length, which no longer fits the body; anything that isn't an HTML document comes
13
+ * back as it is.
14
+ */
15
+ export declare const markOfflineResponse: (response: Response, pinned?: readonly string[]) => Promise<Response>;
@@ -0,0 +1,51 @@
1
+ import { OFFLINE_ATTRIBUTE } from "./names.js";
2
+ import { isGameId } from "@joystyk/contracts";
3
+ //#region src/offline-mark.ts
4
+ /** The <html> start tag: `<html` followed by whitespace, `>` or `/`. Never `<!doctype html>`. */
5
+ var HTML_TAG = /<html(?=[\s/>])[^>]*>/i;
6
+ var MARKED = new RegExp(`\\s${OFFLINE_ATTRIBUTE}(?=[\\s=/>])`, "i");
7
+ /**
8
+ * `html` with `data-offline` on its <html> tag. A document without an <html> tag, or already
9
+ * marked, comes back unchanged. Only the tag changes: no script or style is added, so the
10
+ * page's Content-Security-Policy hashes still hold.
11
+ */
12
+ var markOfflineHtml = (html) => {
13
+ const match = HTML_TAG.exec(html);
14
+ if (match === null || MARKED.test(match[0])) return html;
15
+ const at = match.index + 5;
16
+ return `${html.slice(0, at)} ${OFFLINE_ATTRIBUTE}${html.slice(at)}`;
17
+ };
18
+ /** Render pin-derived labels before first paint, so hydration need not grow every
19
+ * catalog card and lineup row. Raw text and executable content keep their bytes. */
20
+ var markOfflineAvailability = (html, pinned) => {
21
+ const ids = new Set(pinned);
22
+ return html.replace(/<!--[\s\S]*?-->|<(script|style|textarea|title|xmp)\b[^>]*>[\s\S]*?<\/\1\s*>|<jy-offline-availability\b[^>]*>[\s\S]*?<\/jy-offline-availability\s*>/gi, (element) => {
23
+ if (!/^<jy-offline-availability\b/i.test(element)) return element;
24
+ const id = /\s+game-id\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(element);
25
+ const game = id?.[1] ?? id?.[2];
26
+ if (!isGameId(game)) return element;
27
+ const available = ids.has(game);
28
+ return `<jy-offline-availability game-id="${game}" data-available="${String(available)}">${available ? "Available offline" : "Unavailable offline"}</jy-offline-availability>`;
29
+ });
30
+ };
31
+ /** Whether a response is an HTML document, by its Content-Type. */
32
+ var isHtml = (response) => /^\s*text\/html\b/i.test(response.headers.get("content-type") ?? "");
33
+ /**
34
+ * A cached page, marked as served offline. Status and headers carry over, except
35
+ * Content-Length, which no longer fits the body; anything that isn't an HTML document comes
36
+ * back as it is.
37
+ */
38
+ var markOfflineResponse = async (response, pinned = []) => {
39
+ if (!isHtml(response) || response.body === null) return response;
40
+ const headers = new Headers(response.headers);
41
+ headers.delete("content-length");
42
+ return new Response(markOfflineAvailability(markOfflineHtml(await response.text()), pinned), {
43
+ status: response.status,
44
+ statusText: response.statusText,
45
+ headers
46
+ });
47
+ };
48
+ //#endregion
49
+ export { markOfflineAvailability, markOfflineHtml, markOfflineResponse };
50
+
51
+ //# sourceMappingURL=offline-mark.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"offline-mark.js","names":[],"sources":["../src/offline-mark.ts"],"sourcesContent":["// Marking pages served offline (offline-and-updates.md, \"Pages served offline\"). When the\n// network fails and the worker answers a page from a cache, it adds `data-offline` to the\n// page's <html> tag, so the document's own CSS can lay the page out for offline from the first\n// paint: @joystyk/ui's tokens.css collapses every <ad-slot> under `:root[data-offline]` until\n// the slot hears the browser is online. Pure, so it is tested in Node; sw/sw.ts applies it.\nimport { OFFLINE_ATTRIBUTE } from './names.ts';\nimport { isGameId } from '@joystyk/contracts';\n\n/** The <html> start tag: `<html` followed by whitespace, `>` or `/`. Never `<!doctype html>`. */\nconst HTML_TAG = /<html(?=[\\s/>])[^>]*>/i;\nconst MARKED = new RegExp(`\\\\s${OFFLINE_ATTRIBUTE}(?=[\\\\s=/>])`, 'i');\n\n/**\n * `html` with `data-offline` on its <html> tag. A document without an <html> tag, or already\n * marked, comes back unchanged. Only the tag changes: no script or style is added, so the\n * page's Content-Security-Policy hashes still hold.\n */\nexport const markOfflineHtml = (html: string): string => {\n const match = HTML_TAG.exec(html);\n if (match === null || MARKED.test(match[0])) return html;\n const at = match.index + '<html'.length;\n return `${html.slice(0, at)} ${OFFLINE_ATTRIBUTE}${html.slice(at)}`;\n};\n\n/** Render pin-derived labels before first paint, so hydration need not grow every\n * catalog card and lineup row. Raw text and executable content keep their bytes. */\nexport const markOfflineAvailability = (html: string, pinned: readonly string[]): string => {\n const ids = new Set(pinned);\n return html.replace(\n /<!--[\\s\\S]*?-->|<(script|style|textarea|title|xmp)\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>|<jy-offline-availability\\b[^>]*>[\\s\\S]*?<\\/jy-offline-availability\\s*>/gi,\n (element) => {\n if (!/^<jy-offline-availability\\b/i.test(element)) return element;\n const id = /\\s+game-id\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')/i.exec(element);\n const game = id?.[1] ?? id?.[2];\n if (!isGameId(game)) return element;\n const available = ids.has(game);\n return `<jy-offline-availability game-id=\"${game}\" data-available=\"${String(available)}\">${available ? 'Available offline' : 'Unavailable offline'}</jy-offline-availability>`;\n },\n );\n};\n\n/** Whether a response is an HTML document, by its Content-Type. */\nconst isHtml = (response: Response): boolean => /^\\s*text\\/html\\b/i.test(response.headers.get('content-type') ?? '');\n\n/**\n * A cached page, marked as served offline. Status and headers carry over, except\n * Content-Length, which no longer fits the body; anything that isn't an HTML document comes\n * back as it is.\n */\nexport const markOfflineResponse = async (response: Response, pinned: readonly string[] = []): Promise<Response> => {\n if (!isHtml(response) || response.body === null) return response;\n const headers = new Headers(response.headers);\n headers.delete('content-length');\n return new Response(markOfflineAvailability(markOfflineHtml(await response.text()), pinned), {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n};\n"],"mappings":";;;;AASA,IAAM,WAAW;AACjB,IAAM,SAAS,IAAI,OAAO,MAAM,kBAAkB,eAAe,GAAG;;;;;;AAOpE,IAAa,mBAAmB,SAAyB;CACvD,MAAM,QAAQ,SAAS,KAAK,IAAI;CAChC,IAAI,UAAU,QAAQ,OAAO,KAAK,MAAM,EAAE,GAAG,OAAO;CACpD,MAAM,KAAK,MAAM,QAAQ;CACzB,OAAO,GAAG,KAAK,MAAM,GAAG,EAAE,EAAE,GAAG,oBAAoB,KAAK,MAAM,EAAE;AAClE;;;AAIA,IAAa,2BAA2B,MAAc,WAAsC;CAC1F,MAAM,MAAM,IAAI,IAAI,MAAM;CAC1B,OAAO,KAAK,QACV,yJACC,YAAY;EACX,IAAI,CAAC,+BAA+B,KAAK,OAAO,GAAG,OAAO;EAC1D,MAAM,KAAK,4CAA4C,KAAK,OAAO;EACnE,MAAM,OAAO,KAAK,MAAM,KAAK;EAC7B,IAAI,CAAC,SAAS,IAAI,GAAG,OAAO;EAC5B,MAAM,YAAY,IAAI,IAAI,IAAI;EAC9B,OAAO,qCAAqC,KAAK,oBAAoB,OAAO,SAAS,EAAE,IAAI,YAAY,sBAAsB,sBAAsB;CACrJ,CACF;AACF;;AAGA,IAAM,UAAU,aAAgC,oBAAoB,KAAK,SAAS,QAAQ,IAAI,cAAc,KAAK,EAAE;;;;;;AAOnH,IAAa,sBAAsB,OAAO,UAAoB,SAA4B,CAAC,MAAyB;CAClH,IAAI,CAAC,OAAO,QAAQ,KAAK,SAAS,SAAS,MAAM,OAAO;CACxD,MAAM,UAAU,IAAI,QAAQ,SAAS,OAAO;CAC5C,QAAQ,OAAO,gBAAgB;CAC/B,OAAO,IAAI,SAAS,wBAAwB,gBAAgB,MAAM,SAAS,KAAK,CAAC,GAAG,MAAM,GAAG;EAC3F,QAAQ,SAAS;EACjB,YAAY,SAAS;EACrB;CACF,CAAC;AACH"}
@@ -0,0 +1,14 @@
1
+ export type PinRecord = {
2
+ readonly id: string;
3
+ readonly name: string;
4
+ /** The game build the cache holds. */
5
+ readonly version: string;
6
+ /** The platform release that built it. */
7
+ readonly release: string;
8
+ readonly page: string;
9
+ /** Where the asset manifest came from (the game manifest's `assets`). */
10
+ readonly assets: string;
11
+ readonly totalBytes: number;
12
+ };
13
+ export declare const PinRecordSchema: import("@joystyk/contracts").Validator<PinRecord>;
14
+ export declare const parsePinRecord: (value: unknown) => PinRecord | null;
@@ -0,0 +1,36 @@
1
+ import { decodeSync, isPath, isRecord, validator } from "./validate.js";
2
+ import { isGameId } from "@joystyk/contracts";
3
+ //#region src/pin-record.ts
4
+ var PinRecordSchema = validator((value, issues) => {
5
+ if (!isRecord(value)) {
6
+ issues.push("Expected an object");
7
+ return;
8
+ }
9
+ const { id, name, version, release, page, assets, totalBytes } = value;
10
+ if (!isGameId(id)) issues.push("id: expected a kebab-case game id");
11
+ for (const [key, field] of [
12
+ ["name", name],
13
+ ["version", version],
14
+ ["release", release]
15
+ ]) if (typeof field !== "string" || field === "") issues.push(`${key}: expected text`);
16
+ if (!isPath(page)) issues.push("page: expected a site path");
17
+ if (!isPath(assets)) issues.push("assets: expected a site path");
18
+ if (typeof totalBytes !== "number" || !Number.isInteger(totalBytes) || totalBytes < 0) issues.push("totalBytes: expected a whole number");
19
+ return issues.length === 0 ? value : void 0;
20
+ });
21
+ var parsePinRecord = (value) => {
22
+ const record = decodeSync(PinRecordSchema, value);
23
+ return record === null ? null : {
24
+ id: record.id,
25
+ name: record.name,
26
+ version: record.version,
27
+ release: record.release,
28
+ page: record.page,
29
+ assets: record.assets,
30
+ totalBytes: record.totalBytes
31
+ };
32
+ };
33
+ //#endregion
34
+ export { PinRecordSchema, parsePinRecord };
35
+
36
+ //# sourceMappingURL=pin-record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pin-record.js","names":[],"sources":["../src/pin-record.ts"],"sourcesContent":["// What a pinned cache keeps beside the files, at PIN_RECORD_PATH: enough to list the pin on\n// the offline page, refresh it and remove it. Read back from Cache Storage, so decoded.\nimport { isGameId } from '@joystyk/contracts';\nimport { decodeSync, isPath, isRecord, validator } from './validate.ts';\n\nexport type PinRecord = {\n readonly id: string;\n readonly name: string;\n /** The game build the cache holds. */\n readonly version: string;\n /** The platform release that built it. */\n readonly release: string;\n readonly page: string;\n /** Where the asset manifest came from (the game manifest's `assets`). */\n readonly assets: string;\n readonly totalBytes: number;\n};\n\nexport const PinRecordSchema = validator<PinRecord>((value, issues) => {\n if (!isRecord(value)) {\n issues.push('Expected an object');\n return undefined;\n }\n const { id, name, version, release, page, assets, totalBytes } = value;\n if (!isGameId(id)) issues.push('id: expected a kebab-case game id');\n for (const [key, field] of [\n ['name', name],\n ['version', version],\n ['release', release],\n ] as const) {\n if (typeof field !== 'string' || field === '') issues.push(`${key}: expected text`);\n }\n if (!isPath(page)) issues.push('page: expected a site path');\n if (!isPath(assets)) issues.push('assets: expected a site path');\n if (typeof totalBytes !== 'number' || !Number.isInteger(totalBytes) || totalBytes < 0)\n issues.push('totalBytes: expected a whole number');\n return issues.length === 0 ? (value as PinRecord) : undefined;\n});\n\nexport const parsePinRecord = (value: unknown): PinRecord | null => {\n const record = decodeSync(PinRecordSchema, value);\n return record === null\n ? null\n : {\n id: record.id,\n name: record.name,\n version: record.version,\n release: record.release,\n page: record.page,\n assets: record.assets,\n totalBytes: record.totalBytes,\n };\n};\n"],"mappings":";;;AAkBA,IAAa,kBAAkB,WAAsB,OAAO,WAAW;CACrE,IAAI,CAAC,SAAS,KAAK,GAAG;EACpB,OAAO,KAAK,oBAAoB;EAChC;CACF;CACA,MAAM,EAAE,IAAI,MAAM,SAAS,SAAS,MAAM,QAAQ,eAAe;CACjE,IAAI,CAAC,SAAS,EAAE,GAAG,OAAO,KAAK,mCAAmC;CAClE,KAAK,MAAM,CAAC,KAAK,UAAU;EACzB,CAAC,QAAQ,IAAI;EACb,CAAC,WAAW,OAAO;EACnB,CAAC,WAAW,OAAO;CACrB,GACE,IAAI,OAAO,UAAU,YAAY,UAAU,IAAI,OAAO,KAAK,GAAG,IAAI,gBAAgB;CAEpF,IAAI,CAAC,OAAO,IAAI,GAAG,OAAO,KAAK,4BAA4B;CAC3D,IAAI,CAAC,OAAO,MAAM,GAAG,OAAO,KAAK,8BAA8B;CAC/D,IAAI,OAAO,eAAe,YAAY,CAAC,OAAO,UAAU,UAAU,KAAK,aAAa,GAClF,OAAO,KAAK,qCAAqC;CACnD,OAAO,OAAO,WAAW,IAAK,QAAsB,KAAA;AACtD,CAAC;AAED,IAAa,kBAAkB,UAAqC;CAClE,MAAM,SAAS,WAAW,iBAAiB,KAAK;CAChD,OAAO,WAAW,OACd,OACA;EACE,IAAI,OAAO;EACX,MAAM,OAAO;EACb,SAAS,OAAO;EAChB,SAAS,OAAO;EAChB,MAAM,OAAO;EACb,QAAQ,OAAO;EACf,YAAY,OAAO;CACrB;AACN"}
@@ -0,0 +1,2 @@
1
+ export declare const withoutModulePreloads: (html: string) => string;
2
+ export declare const withoutPreloadResponse: (response: Response) => Promise<Response>;
@@ -0,0 +1,26 @@
1
+ //#region src/preloads.ts
2
+ /** Remove document preloads before a service-worker response reaches the HTML parser.
3
+ * A controlled page imports through its worker; cross-world preload hints can fetch the
4
+ * same modules twice. Scripts, stylesheet links and CSP-hashed inline content stay intact.
5
+ */
6
+ var modulePreloadLink = (tag) => {
7
+ if (!/^<link\b/i.test(tag)) return false;
8
+ const attributes = tag.matchAll(/\s+([^\s="'<>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g);
9
+ for (const attribute of attributes) if (attribute[1]?.toLowerCase() === "rel") return (attribute[2] ?? attribute[3] ?? attribute[4] ?? "").toLowerCase().split(/\s+/).includes("modulepreload");
10
+ return false;
11
+ };
12
+ var withoutModulePreloads = (html) => html.replace(/<!--[\s\S]*?-->|<(script|style|textarea|title|xmp)\b[^>]*>[\s\S]*?<\/\1\s*>|<link\b[^>]*>/gi, (tag) => modulePreloadLink(tag) ? "" : tag);
13
+ var withoutPreloadResponse = async (response) => {
14
+ if (response.body === null || !/^\s*text\/html\b/i.test(response.headers.get("content-type") ?? "")) return response;
15
+ const headers = new Headers(response.headers);
16
+ headers.delete("content-length");
17
+ return new Response(withoutModulePreloads(await response.text()), {
18
+ status: response.status,
19
+ statusText: response.statusText,
20
+ headers
21
+ });
22
+ };
23
+ //#endregion
24
+ export { withoutModulePreloads, withoutPreloadResponse };
25
+
26
+ //# sourceMappingURL=preloads.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preloads.js","names":[],"sources":["../src/preloads.ts"],"sourcesContent":["/** Remove document preloads before a service-worker response reaches the HTML parser.\n * A controlled page imports through its worker; cross-world preload hints can fetch the\n * same modules twice. Scripts, stylesheet links and CSP-hashed inline content stay intact.\n */\nconst modulePreloadLink = (tag: string): boolean => {\n if (!/^<link\\b/i.test(tag)) return false;\n const attributes = tag.matchAll(/\\s+([^\\s=\"'<>/]+)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+)))?/g);\n for (const attribute of attributes) {\n if (attribute[1]?.toLowerCase() === 'rel') {\n return (attribute[2] ?? attribute[3] ?? attribute[4] ?? '').toLowerCase().split(/\\s+/).includes('modulepreload');\n }\n }\n return false;\n};\n\nexport const withoutModulePreloads = (html: string): string =>\n html.replace(/<!--[\\s\\S]*?-->|<(script|style|textarea|title|xmp)\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>|<link\\b[^>]*>/gi, (tag) =>\n modulePreloadLink(tag) ? '' : tag,\n );\n\nexport const withoutPreloadResponse = async (response: Response): Promise<Response> => {\n if (response.body === null || !/^\\s*text\\/html\\b/i.test(response.headers.get('content-type') ?? '')) return response;\n const headers = new Headers(response.headers);\n headers.delete('content-length');\n return new Response(withoutModulePreloads(await response.text()), {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n};\n"],"mappings":";;;;;AAIA,IAAM,qBAAqB,QAAyB;CAClD,IAAI,CAAC,YAAY,KAAK,GAAG,GAAG,OAAO;CACnC,MAAM,aAAa,IAAI,SAAS,sEAAsE;CACtG,KAAK,MAAM,aAAa,YACtB,IAAI,UAAU,EAAE,EAAE,YAAY,MAAM,OAClC,QAAQ,UAAU,MAAM,UAAU,MAAM,UAAU,MAAM,GAAA,CAAI,YAAY,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,SAAS,eAAe;CAGnH,OAAO;AACT;AAEA,IAAa,yBAAyB,SACpC,KAAK,QAAQ,gGAAgG,QAC3G,kBAAkB,GAAG,IAAI,KAAK,GAChC;AAEF,IAAa,yBAAyB,OAAO,aAA0C;CACrF,IAAI,SAAS,SAAS,QAAQ,CAAC,oBAAoB,KAAK,SAAS,QAAQ,IAAI,cAAc,KAAK,EAAE,GAAG,OAAO;CAC5G,MAAM,UAAU,IAAI,QAAQ,SAAS,OAAO;CAC5C,QAAQ,OAAO,gBAAgB;CAC/B,OAAO,IAAI,SAAS,sBAAsB,MAAM,SAAS,KAAK,CAAC,GAAG;EAChE,QAAQ,SAAS;EACjB,YAAY,SAAS;EACrB;CACF,CAAC;AACH"}
@@ -0,0 +1,57 @@
1
+ import type { RecentKind } from './names.js';
2
+ /** The hashed, immutable files the site serves (apps/site's assets volume). */
3
+ export declare const ASSETS_PREFIX = "/assets/";
4
+ /** The kind of a file by its extension: a Tier 2 kind, `font`, or null for anything else. */
5
+ export declare const fileKind: (pathname: string) => RecentKind | "font" | null;
6
+ /** Tier 2 budgets: entries per kind and their age limit. Least recently used goes first. */
7
+ export declare const RECENT_LIMITS: Readonly<Record<RecentKind, {
8
+ readonly maxEntries: number;
9
+ readonly maxAgeSeconds: number;
10
+ }>>;
11
+ /** How the worker answers one request. */
12
+ export type Route =
13
+ /** Not ours: cross-origin, not a GET, the worker's own files or the flag file. The network answers. */
14
+ {
15
+ readonly _tag: 'Network';
16
+ }
17
+ /** A page. Network first, then a pinned copy, then the precached shell, then the offline page. */
18
+ | {
19
+ readonly _tag: 'Document';
20
+ }
21
+ /** Catalog data for client-side navigation. Network first, then a pinned copy. */
22
+ | {
23
+ readonly _tag: 'Api';
24
+ }
25
+ /** A hashed file. The precache, then the pinned caches, then this kind's Tier 2 cache. */
26
+ | {
27
+ readonly _tag: 'Asset';
28
+ readonly kind: RecentKind;
29
+ }
30
+ /** A hashed font or another file with no Tier 2 cache: the precache or a pin, else the network. */
31
+ | {
32
+ readonly _tag: 'Pinned';
33
+ };
34
+ export type RouteInput = {
35
+ readonly method: string;
36
+ /** Explicit revalidation (pin refreshes) must reach the network, bypassing Cache Storage. */
37
+ readonly cache?: RequestCache;
38
+ readonly pinRefresh?: boolean;
39
+ /** `Request.destination`: 'document' for navigations. */
40
+ readonly destination: string;
41
+ /** `Request.mode`: 'navigate' for navigations. */
42
+ readonly mode: string;
43
+ readonly url: URL;
44
+ /** The worker's origin. */
45
+ readonly origin: string;
46
+ };
47
+ export declare const routeFor: (request: RouteInput) => Route;
48
+ /** Whether a page's updates may be offered now (Rule 2): only at a safe point, never in play. */
49
+ export type PromptInput = {
50
+ /** A new worker is installed and waiting, or another tab already switched workers. */
51
+ readonly ready: boolean;
52
+ /** The launcher's last safe-point report; true on pages without a launcher. */
53
+ readonly safe: boolean;
54
+ /** The player chose "Later" for this update. */
55
+ readonly dismissed: boolean;
56
+ };
57
+ export declare const shouldOfferUpdate: (input: PromptInput) => boolean;