@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
@@ -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;
@@ -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 {};