@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,319 @@
1
+ import { formatBytes } from "../asset-manifest.js";
2
+ import { pinStatus, unpinGame } from "./pinned.js";
3
+ import { PIN_CHANGED } from "./availability.js";
4
+ import { browserPinPorts, pinGame } from "./pins.js";
5
+ //#region src/client/elements.ts
6
+ var sheet = (css) => {
7
+ const s = new CSSStyleSheet();
8
+ s.replaceSync(css);
9
+ return s;
10
+ };
11
+ var BUTTON_CSS = `
12
+ button {
13
+ min-block-size: var(--jy-target-min, 2.75rem);
14
+ padding: 0.25rem 1rem;
15
+ border: 2px solid var(--jy-border, GrayText);
16
+ border-radius: var(--jy-radius-pill, 999px);
17
+ background: transparent;
18
+ color: var(--jy-text, CanvasText);
19
+ font: inherit;
20
+ font-weight: 600;
21
+ cursor: pointer;
22
+ }
23
+ button:focus-visible {
24
+ outline: 3px solid var(--jy-focus-ring, Highlight);
25
+ outline-offset: 2px;
26
+ }
27
+ button:disabled {
28
+ cursor: default;
29
+ opacity: 0.6;
30
+ }
31
+ [hidden] {
32
+ display: none !important;
33
+ }
34
+ `;
35
+ var el = (tag, props = {}) => Object.assign(document.createElement(tag), props);
36
+ var TOAST_CSS = sheet(`
37
+ :host {
38
+ position: fixed;
39
+ z-index: 1000;
40
+ inset-block-end: max(1rem, env(safe-area-inset-bottom));
41
+ inset-inline: max(1rem, env(safe-area-inset-left)) max(1rem, env(safe-area-inset-right));
42
+ display: flex;
43
+ justify-content: flex-end;
44
+ pointer-events: none;
45
+ }
46
+ .toast {
47
+ pointer-events: auto;
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ align-items: center;
51
+ gap: 0.5rem 0.75rem;
52
+ max-inline-size: 28rem;
53
+ padding: 0.75rem 1rem;
54
+ border: 1px solid var(--jy-border, GrayText);
55
+ border-radius: var(--jy-radius-m, 0.75rem);
56
+ background: var(--jy-surface-raised, Canvas);
57
+ color: var(--jy-text, CanvasText);
58
+ box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));
59
+ }
60
+ p {
61
+ flex: 1 1 14rem;
62
+ margin: 0;
63
+ }
64
+ .actions {
65
+ display: flex;
66
+ gap: 0.5rem;
67
+ }
68
+ .primary {
69
+ border-color: var(--jy-accent, LinkText);
70
+ background: var(--jy-accent, LinkText);
71
+ color: var(--jy-on-accent, Canvas);
72
+ }
73
+ ${BUTTON_CSS}
74
+ `);
75
+ /** `<jy-update-toast>`: says an update is ready and offers Refresh or Later. */
76
+ var UpdateToast = class extends HTMLElement {
77
+ #message = el("p");
78
+ #refresh = el("button", {
79
+ type: "button",
80
+ className: "primary",
81
+ textContent: "Refresh"
82
+ });
83
+ #later = el("button", {
84
+ type: "button",
85
+ textContent: "Later"
86
+ });
87
+ /** Called with 'refresh' or 'later'. */
88
+ onChoice = () => void 0;
89
+ constructor() {
90
+ super();
91
+ const root = this.attachShadow({ mode: "open" });
92
+ root.adoptedStyleSheets = [TOAST_CSS];
93
+ const box = el("div", { className: "toast" });
94
+ const status = el("div");
95
+ status.setAttribute("role", "status");
96
+ this.#message.append(el("strong", { textContent: "Update ready." }), " Refresh to get the newest Joystyk.");
97
+ status.append(this.#message);
98
+ const actions = el("div", { className: "actions" });
99
+ actions.append(this.#refresh, this.#later);
100
+ box.append(status, actions);
101
+ root.append(box);
102
+ this.#refresh.addEventListener("click", () => this.onChoice("refresh"));
103
+ this.#later.addEventListener("click", () => this.onChoice("later"));
104
+ }
105
+ };
106
+ var SECURITY_CSS = sheet(`
107
+ dialog {
108
+ box-sizing: border-box;
109
+ max-inline-size: min(28rem, calc(100vw - 2rem));
110
+ padding: 1.25rem 1.5rem;
111
+ border: 1px solid var(--jy-border, GrayText);
112
+ border-radius: var(--jy-radius-m, 0.75rem);
113
+ background: var(--jy-surface-raised, Canvas);
114
+ color: var(--jy-text, CanvasText);
115
+ box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));
116
+ font: inherit;
117
+ }
118
+ dialog::backdrop {
119
+ background: rgb(0 0 0 / 0.6);
120
+ }
121
+ h2 {
122
+ margin: 0 0 0.5rem;
123
+ font-size: var(--jy-step-1, 1.25rem);
124
+ }
125
+ p {
126
+ margin: 0 0 1rem;
127
+ }
128
+ .status:empty {
129
+ display: none;
130
+ }
131
+ .primary {
132
+ border-color: var(--jy-accent, LinkText);
133
+ background: var(--jy-accent, LinkText);
134
+ color: var(--jy-on-accent, Canvas);
135
+ }
136
+ ${BUTTON_CSS}
137
+ `);
138
+ /**
139
+ * `<jy-security-update>`: the forced update's notice (offline-and-updates.md, Rule 2). A
140
+ * modal dialog that takes focus and blocks the page: it gives the reason and one action,
141
+ * Update now. Escape doesn't close it; the page reloads instead.
142
+ */
143
+ var SecurityUpdate = class extends HTMLElement {
144
+ #dialog = el("dialog");
145
+ #status = el("p", { className: "status" });
146
+ #button = el("button", {
147
+ type: "button",
148
+ className: "primary",
149
+ textContent: "Update now"
150
+ });
151
+ /** Called once, when the player chooses Update now. */
152
+ onUpdate = () => void 0;
153
+ constructor() {
154
+ super();
155
+ const root = this.attachShadow({ mode: "open" });
156
+ root.adoptedStyleSheets = [SECURITY_CSS];
157
+ const title = el("h2", {
158
+ id: "security-update-title",
159
+ textContent: "Security update"
160
+ });
161
+ const reason = el("p", {
162
+ id: "security-update-reason",
163
+ textContent: "This version of Joystyk has a security problem that a new version fixes. Update to keep playing. Your game is saved first, so you can carry on where you left off."
164
+ });
165
+ this.#dialog.setAttribute("aria-labelledby", title.id);
166
+ this.#dialog.setAttribute("aria-describedby", reason.id);
167
+ this.#dialog.setAttribute("closedby", "none");
168
+ this.#status.setAttribute("role", "status");
169
+ this.#dialog.append(title, reason, this.#status, this.#button);
170
+ root.append(this.#dialog);
171
+ this.#dialog.addEventListener("cancel", (event) => event.preventDefault());
172
+ this.#dialog.addEventListener("close", () => {
173
+ if (this.isConnected) this.#open();
174
+ });
175
+ this.#button.addEventListener("click", () => {
176
+ if (this.#button.disabled) return;
177
+ this.#button.disabled = true;
178
+ this.#status.textContent = "Saving your game, then updating…";
179
+ this.onUpdate();
180
+ });
181
+ }
182
+ connectedCallback() {
183
+ this.#open();
184
+ }
185
+ #open() {
186
+ if (!this.#dialog.open) this.#dialog.showModal();
187
+ this.#button.focus();
188
+ }
189
+ };
190
+ var TOGGLE_CSS = sheet(`
191
+ :host {
192
+ display: block;
193
+ min-block-size: var(--jy-target-min, 2.75rem);
194
+ }
195
+ .wrap {
196
+ display: flex;
197
+ flex-wrap: wrap;
198
+ align-items: center;
199
+ gap: 0.5rem 0.75rem;
200
+ }
201
+ button[aria-pressed='true'] {
202
+ border-color: var(--jy-success, currentColor);
203
+ }
204
+ .status {
205
+ margin: 0;
206
+ font-size: var(--jy-step--1, 0.9rem);
207
+ color: var(--jy-text-muted, inherit);
208
+ }
209
+ ${BUTTON_CSS}
210
+ `);
211
+ /** Whether this browser can pin games at all. */
212
+ var canPin = () => "serviceWorker" in navigator && "caches" in window;
213
+ /**
214
+ * `<jy-offline-toggle game-id="nim" game-name="Nim" assets="/games/nim/manifest.json">`: a
215
+ * toggle button that pins the game for offline play or removes it. It stays empty (its box
216
+ * reserved by the page's stylesheet) until a service worker is active, so a player never
217
+ * sees a control that can't work.
218
+ */
219
+ var OfflineToggle = class extends HTMLElement {
220
+ static observedAttributes = ["game-id", "assets"];
221
+ /** Swapped in tests. */
222
+ ports = browserPinPorts;
223
+ #wrap = el("div", {
224
+ className: "wrap",
225
+ hidden: true
226
+ });
227
+ #button = el("button", { type: "button" });
228
+ #status = el("p", { className: "status" });
229
+ #pinned = false;
230
+ #busy = false;
231
+ #generation = 0;
232
+ #onConnection = () => this.#render();
233
+ constructor() {
234
+ super();
235
+ const root = this.attachShadow({ mode: "open" });
236
+ root.adoptedStyleSheets = [TOGGLE_CSS];
237
+ this.#button.append(el("span", {
238
+ textContent: "⤓ ",
239
+ ariaHidden: "true"
240
+ }), "Make available offline");
241
+ this.#status.setAttribute("role", "status");
242
+ this.#wrap.append(this.#button, this.#status);
243
+ root.append(this.#wrap);
244
+ this.#button.addEventListener("click", () => void this.#toggle());
245
+ }
246
+ get #gameId() {
247
+ return this.getAttribute("game-id") ?? "";
248
+ }
249
+ get #gameName() {
250
+ return this.getAttribute("game-name") ?? "This game";
251
+ }
252
+ connectedCallback() {
253
+ addEventListener("online", this.#onConnection);
254
+ addEventListener("offline", this.#onConnection);
255
+ this.#load();
256
+ }
257
+ disconnectedCallback() {
258
+ removeEventListener("online", this.#onConnection);
259
+ removeEventListener("offline", this.#onConnection);
260
+ this.#generation += 1;
261
+ }
262
+ attributeChangedCallback() {
263
+ if (this.isConnected) this.#load();
264
+ }
265
+ async #load() {
266
+ const generation = ++this.#generation;
267
+ this.#wrap.hidden = true;
268
+ if (!canPin() || this.#gameId === "") return;
269
+ await navigator.serviceWorker.ready;
270
+ const record = await pinStatus(this.#gameId, this.ports()).catch(() => null);
271
+ if (generation !== this.#generation) return;
272
+ this.#pinned = record !== null;
273
+ this.#status.textContent = record === null ? "" : `${this.#gameName} plays offline on this device (${formatBytes(record.totalBytes)}).`;
274
+ this.#wrap.hidden = false;
275
+ this.#render();
276
+ }
277
+ #render() {
278
+ this.#button.setAttribute("aria-pressed", String(this.#pinned));
279
+ const offline = !navigator.onLine && !this.#pinned;
280
+ this.#button.disabled = this.#busy || offline;
281
+ if (offline && !this.#busy) this.#status.textContent = "Connect to the internet to make this game available offline.";
282
+ }
283
+ async #toggle() {
284
+ if (this.#busy) return;
285
+ const generation = this.#generation;
286
+ const id = this.#gameId;
287
+ const name = this.#gameName;
288
+ this.#busy = true;
289
+ this.#render();
290
+ if (this.#pinned) {
291
+ await unpinGame(id, this.ports());
292
+ this.#pinned = false;
293
+ this.#status.textContent = `Removed ${name} from this device.`;
294
+ } else {
295
+ this.#status.textContent = `Saving ${name} for offline play…`;
296
+ const result = await pinGame({
297
+ gameId: id,
298
+ assets: this.getAttribute("assets") ?? `/games/${id}/manifest.json`
299
+ }, this.ports());
300
+ if (result.ok) {
301
+ this.#pinned = true;
302
+ this.#status.textContent = `${name} plays offline on this device (${formatBytes(result.value.record.totalBytes)}).`;
303
+ } else this.#status.textContent = result.error.message;
304
+ }
305
+ this.#busy = false;
306
+ dispatchEvent(new Event(PIN_CHANGED));
307
+ if (generation === this.#generation) this.#render();
308
+ }
309
+ };
310
+ /** Registers the elements once. */
311
+ var definePwaElements = () => {
312
+ if (customElements.get("jy-update-toast") === void 0) customElements.define("jy-update-toast", UpdateToast);
313
+ if (customElements.get("jy-security-update") === void 0) customElements.define("jy-security-update", SecurityUpdate);
314
+ if (customElements.get("jy-offline-toggle") === void 0) customElements.define("jy-offline-toggle", OfflineToggle);
315
+ };
316
+ //#endregion
317
+ export { OfflineToggle, SecurityUpdate, UpdateToast, definePwaElements };
318
+
319
+ //# sourceMappingURL=elements.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elements.js","names":[],"sources":["../../src/client/elements.ts"],"sourcesContent":["// The PWA's frame UI, as small custom elements with no framework: the \"Update ready\" toast,\n// the forced security update's notice and the \"Make available offline\" control. Each renders\n// into its own shadow root and styles itself with a constructed stylesheet, which the site's\n// CSP allows, and reads the frame's design tokens (@joystyk/ui/tokens.css) through\n// inheritance, with plain fallbacks. None renders anything until the page needs it.\nimport { PIN_CHANGED } from './availability.ts';\nimport { formatBytes } from '../asset-manifest.ts';\nimport { pinStatus, unpinGame } from './pinned.ts';\nimport { browserPinPorts, pinGame, type PinPorts } from './pins.ts';\n\nconst sheet = (css: string): CSSStyleSheet => {\n const s = new CSSStyleSheet();\n s.replaceSync(css);\n return s;\n};\n\nconst BUTTON_CSS = `\n button {\n min-block-size: var(--jy-target-min, 2.75rem);\n padding: 0.25rem 1rem;\n border: 2px solid var(--jy-border, GrayText);\n border-radius: var(--jy-radius-pill, 999px);\n background: transparent;\n color: var(--jy-text, CanvasText);\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n }\n button:focus-visible {\n outline: 3px solid var(--jy-focus-ring, Highlight);\n outline-offset: 2px;\n }\n button:disabled {\n cursor: default;\n opacity: 0.6;\n }\n [hidden] {\n display: none !important;\n }\n`;\n\nconst el = <K extends keyof HTMLElementTagNameMap>(\n tag: K,\n props: Partial<HTMLElementTagNameMap[K]> = {},\n): HTMLElementTagNameMap[K] => Object.assign(document.createElement(tag), props);\n\n// --- The update toast ---------------------------------------------------------------------------\n\nconst TOAST_CSS = sheet(`\n :host {\n position: fixed;\n z-index: 1000;\n inset-block-end: max(1rem, env(safe-area-inset-bottom));\n inset-inline: max(1rem, env(safe-area-inset-left)) max(1rem, env(safe-area-inset-right));\n display: flex;\n justify-content: flex-end;\n pointer-events: none;\n }\n .toast {\n pointer-events: auto;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 0.75rem;\n max-inline-size: 28rem;\n padding: 0.75rem 1rem;\n border: 1px solid var(--jy-border, GrayText);\n border-radius: var(--jy-radius-m, 0.75rem);\n background: var(--jy-surface-raised, Canvas);\n color: var(--jy-text, CanvasText);\n box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));\n }\n p {\n flex: 1 1 14rem;\n margin: 0;\n }\n .actions {\n display: flex;\n gap: 0.5rem;\n }\n .primary {\n border-color: var(--jy-accent, LinkText);\n background: var(--jy-accent, LinkText);\n color: var(--jy-on-accent, Canvas);\n }\n ${BUTTON_CSS}\n`);\n\n/** `<jy-update-toast>`: says an update is ready and offers Refresh or Later. */\nexport class UpdateToast extends HTMLElement {\n #message = el('p');\n #refresh = el('button', { type: 'button', className: 'primary', textContent: 'Refresh' });\n #later = el('button', { type: 'button', textContent: 'Later' });\n /** Called with 'refresh' or 'later'. */\n onChoice: (choice: 'refresh' | 'later') => void = () => undefined;\n\n constructor() {\n super();\n const root = this.attachShadow({ mode: 'open' });\n root.adoptedStyleSheets = [TOAST_CSS];\n const box = el('div', { className: 'toast' });\n // A polite status region: announced once, never stealing focus from a game or a page.\n const status = el('div');\n status.setAttribute('role', 'status');\n this.#message.append(el('strong', { textContent: 'Update ready.' }), ' Refresh to get the newest Joystyk.');\n status.append(this.#message);\n const actions = el('div', { className: 'actions' });\n actions.append(this.#refresh, this.#later);\n box.append(status, actions);\n root.append(box);\n this.#refresh.addEventListener('click', () => this.onChoice('refresh'));\n this.#later.addEventListener('click', () => this.onChoice('later'));\n }\n}\n\n// --- The security update notice -----------------------------------------------------------------\n\nconst SECURITY_CSS = sheet(`\n dialog {\n box-sizing: border-box;\n max-inline-size: min(28rem, calc(100vw - 2rem));\n padding: 1.25rem 1.5rem;\n border: 1px solid var(--jy-border, GrayText);\n border-radius: var(--jy-radius-m, 0.75rem);\n background: var(--jy-surface-raised, Canvas);\n color: var(--jy-text, CanvasText);\n box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));\n font: inherit;\n }\n dialog::backdrop {\n background: rgb(0 0 0 / 0.6);\n }\n h2 {\n margin: 0 0 0.5rem;\n font-size: var(--jy-step-1, 1.25rem);\n }\n p {\n margin: 0 0 1rem;\n }\n .status:empty {\n display: none;\n }\n .primary {\n border-color: var(--jy-accent, LinkText);\n background: var(--jy-accent, LinkText);\n color: var(--jy-on-accent, Canvas);\n }\n ${BUTTON_CSS}\n`);\n\n/**\n * `<jy-security-update>`: the forced update's notice (offline-and-updates.md, Rule 2). A\n * modal dialog that takes focus and blocks the page: it gives the reason and one action,\n * Update now. Escape doesn't close it; the page reloads instead.\n */\nexport class SecurityUpdate extends HTMLElement {\n #dialog = el('dialog');\n #status = el('p', { className: 'status' });\n #button = el('button', { type: 'button', className: 'primary', textContent: 'Update now' });\n /** Called once, when the player chooses Update now. */\n onUpdate: () => void = () => undefined;\n\n constructor() {\n super();\n const root = this.attachShadow({ mode: 'open' });\n root.adoptedStyleSheets = [SECURITY_CSS];\n const title = el('h2', { id: 'security-update-title', textContent: 'Security update' });\n const reason = el('p', {\n id: 'security-update-reason',\n textContent:\n 'This version of Joystyk has a security problem that a new version fixes. Update to keep playing. Your game is saved first, so you can carry on where you left off.',\n });\n this.#dialog.setAttribute('aria-labelledby', title.id);\n this.#dialog.setAttribute('aria-describedby', reason.id);\n this.#dialog.setAttribute('closedby', 'none');\n this.#status.setAttribute('role', 'status');\n this.#dialog.append(title, reason, this.#status, this.#button);\n root.append(this.#dialog);\n // Blocking means blocking: Escape and close requests leave it open.\n this.#dialog.addEventListener('cancel', (event) => event.preventDefault());\n this.#dialog.addEventListener('close', () => {\n if (this.isConnected) this.#open();\n });\n this.#button.addEventListener('click', () => {\n if (this.#button.disabled) return;\n this.#button.disabled = true;\n this.#status.textContent = 'Saving your game, then updating…';\n this.onUpdate();\n });\n }\n\n connectedCallback(): void {\n this.#open();\n }\n\n #open(): void {\n if (!this.#dialog.open) this.#dialog.showModal();\n this.#button.focus();\n }\n}\n\n// --- The offline control ------------------------------------------------------------------------\n\nconst TOGGLE_CSS = sheet(`\n :host {\n display: block;\n min-block-size: var(--jy-target-min, 2.75rem);\n }\n .wrap {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 0.75rem;\n }\n button[aria-pressed='true'] {\n border-color: var(--jy-success, currentColor);\n }\n .status {\n margin: 0;\n font-size: var(--jy-step--1, 0.9rem);\n color: var(--jy-text-muted, inherit);\n }\n ${BUTTON_CSS}\n`);\n\n/** Whether this browser can pin games at all. */\nconst canPin = (): boolean => 'serviceWorker' in navigator && 'caches' in window;\n\n/**\n * `<jy-offline-toggle game-id=\"nim\" game-name=\"Nim\" assets=\"/games/nim/manifest.json\">`: a\n * toggle button that pins the game for offline play or removes it. It stays empty (its box\n * reserved by the page's stylesheet) until a service worker is active, so a player never\n * sees a control that can't work.\n */\nexport class OfflineToggle extends HTMLElement {\n static readonly observedAttributes = ['game-id', 'assets'];\n /** Swapped in tests. */\n ports: () => PinPorts = browserPinPorts;\n #wrap = el('div', { className: 'wrap', hidden: true });\n #button = el('button', { type: 'button' });\n #status = el('p', { className: 'status' });\n #pinned = false;\n #busy = false;\n #generation = 0;\n #onConnection = (): void => this.#render();\n\n constructor() {\n super();\n const root = this.attachShadow({ mode: 'open' });\n root.adoptedStyleSheets = [TOGGLE_CSS];\n this.#button.append(el('span', { textContent: '⤓ ', ariaHidden: 'true' }), 'Make available offline');\n this.#status.setAttribute('role', 'status');\n this.#wrap.append(this.#button, this.#status);\n root.append(this.#wrap);\n this.#button.addEventListener('click', () => void this.#toggle());\n }\n\n get #gameId(): string {\n return this.getAttribute('game-id') ?? '';\n }\n\n get #gameName(): string {\n return this.getAttribute('game-name') ?? 'This game';\n }\n\n connectedCallback(): void {\n addEventListener('online', this.#onConnection);\n addEventListener('offline', this.#onConnection);\n void this.#load();\n }\n\n disconnectedCallback(): void {\n removeEventListener('online', this.#onConnection);\n removeEventListener('offline', this.#onConnection);\n this.#generation += 1;\n }\n\n attributeChangedCallback(): void {\n if (this.isConnected) void this.#load();\n }\n\n async #load(): Promise<void> {\n const generation = ++this.#generation;\n this.#wrap.hidden = true;\n if (!canPin() || this.#gameId === '') return;\n // Resolves once a worker is active; never, when the kill switch kept it from registering.\n await navigator.serviceWorker.ready;\n const record = await pinStatus(this.#gameId, this.ports()).catch(() => null);\n if (generation !== this.#generation) return;\n this.#pinned = record !== null;\n this.#status.textContent =\n record === null ? '' : `${this.#gameName} plays offline on this device (${formatBytes(record.totalBytes)}).`;\n this.#wrap.hidden = false;\n this.#render();\n }\n\n #render(): void {\n this.#button.setAttribute('aria-pressed', String(this.#pinned));\n const offline = !navigator.onLine && !this.#pinned;\n this.#button.disabled = this.#busy || offline;\n if (offline && !this.#busy)\n this.#status.textContent = 'Connect to the internet to make this game available offline.';\n }\n\n async #toggle(): Promise<void> {\n if (this.#busy) return;\n const generation = this.#generation;\n const id = this.#gameId;\n const name = this.#gameName;\n this.#busy = true;\n this.#render();\n if (this.#pinned) {\n await unpinGame(id, this.ports());\n this.#pinned = false;\n this.#status.textContent = `Removed ${name} from this device.`;\n } else {\n this.#status.textContent = `Saving ${name} for offline play…`;\n const result = await pinGame(\n { gameId: id, assets: this.getAttribute('assets') ?? `/games/${id}/manifest.json` },\n this.ports(),\n );\n if (result.ok) {\n this.#pinned = true;\n this.#status.textContent = `${name} plays offline on this device (${formatBytes(result.value.record.totalBytes)}).`;\n } else {\n this.#status.textContent = result.error.message;\n }\n }\n this.#busy = false;\n dispatchEvent(new Event(PIN_CHANGED));\n if (generation === this.#generation) this.#render();\n }\n}\n\n/** Registers the elements once. */\nexport const definePwaElements = (): void => {\n if (customElements.get('jy-update-toast') === undefined) customElements.define('jy-update-toast', UpdateToast);\n if (customElements.get('jy-security-update') === undefined)\n customElements.define('jy-security-update', SecurityUpdate);\n if (customElements.get('jy-offline-toggle') === undefined) customElements.define('jy-offline-toggle', OfflineToggle);\n};\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'jy-update-toast': UpdateToast;\n 'jy-security-update': SecurityUpdate;\n 'jy-offline-toggle': OfflineToggle;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ import { type Control } from '../control.js';
2
+ /** How the page shows the notice. The default is `<jy-security-update>`. */
3
+ export interface SecurityNotice {
4
+ /** Shows the blocking notice; `update` runs when the player chooses Update now. */
5
+ show(update: () => void): void;
6
+ }
7
+ export type ForcedUpdateOptions = {
8
+ /** This page's release time (`__JOYSTYK_RELEASE_TIME__`). */
9
+ readonly releaseTime: string;
10
+ /** Saves the game in play: the launcher's `requestSave`. Without it, nothing is saved. */
11
+ readonly saveGame?: () => Promise<void>;
12
+ /** Brings in the new release's worker before the reload, when there is one. */
13
+ readonly prepare?: () => Promise<void>;
14
+ /** How often to read the flag file. Default 15 minutes. */
15
+ readonly checkMs?: number;
16
+ readonly notice?: SecurityNotice;
17
+ /** Reads the flag file; swapped in tests. */
18
+ readonly read?: () => Promise<Control | null>;
19
+ /** Reloads the page; swapped in tests. */
20
+ readonly reload?: () => void;
21
+ };
22
+ export interface ForcedUpdates {
23
+ /** Reads the flag file now and acts on it; resolves with what it said. */
24
+ check(): Promise<Control | null>;
25
+ /** Acts on a flag file read elsewhere. */
26
+ observe(control: Control | null): void;
27
+ /** True once the notice is up. */
28
+ readonly forcing: boolean;
29
+ dispose(): void;
30
+ }
31
+ /** The default notice: `<jy-security-update>` at the end of the body. */
32
+ export declare const securityNotice: () => SecurityNotice;
33
+ export declare const watchForcedUpdates: (options: ForcedUpdateOptions) => ForcedUpdates;
@@ -0,0 +1,57 @@
1
+ import { mustUpdate } from "../control.js";
2
+ import { readControl } from "./control.js";
3
+ //#region src/client/forced.ts
4
+ var DEFAULT_CHECK_MS = 9e5;
5
+ /** The default notice: `<jy-security-update>` at the end of the body. */
6
+ var securityNotice = () => ({ show: (update) => {
7
+ import("./elements.js").then(({ definePwaElements }) => {
8
+ definePwaElements();
9
+ const notice = document.createElement("jy-security-update");
10
+ notice.onUpdate = update;
11
+ document.body.append(notice);
12
+ });
13
+ } });
14
+ var watchForcedUpdates = (options) => {
15
+ const read = options.read ?? readControl;
16
+ const reload = options.reload ?? (() => location.reload());
17
+ const notice = options.notice ?? securityNotice();
18
+ const cleanups = [];
19
+ let forcing = false;
20
+ const force = () => {
21
+ if (forcing) return;
22
+ forcing = true;
23
+ const saved = Promise.resolve().then(() => options.saveGame?.()).catch(() => void 0);
24
+ notice.show(() => {
25
+ saved.then(() => options.prepare?.()).catch(() => void 0).then(reload);
26
+ });
27
+ };
28
+ const observe = (control) => {
29
+ if (mustUpdate(control, options.releaseTime)) force();
30
+ };
31
+ const check = async () => {
32
+ const control = await read();
33
+ observe(control);
34
+ return control;
35
+ };
36
+ const interval = setInterval(() => void check(), options.checkMs ?? DEFAULT_CHECK_MS);
37
+ cleanups.push(() => clearInterval(interval));
38
+ const onVisible = () => {
39
+ if (document.visibilityState === "visible") check();
40
+ };
41
+ document.addEventListener("visibilitychange", onVisible);
42
+ cleanups.push(() => document.removeEventListener("visibilitychange", onVisible));
43
+ return {
44
+ check,
45
+ observe,
46
+ get forcing() {
47
+ return forcing;
48
+ },
49
+ dispose: () => {
50
+ for (const cleanup of cleanups.splice(0)) cleanup();
51
+ }
52
+ };
53
+ };
54
+ //#endregion
55
+ export { securityNotice, watchForcedUpdates };
56
+
57
+ //# sourceMappingURL=forced.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forced.js","names":[],"sources":["../../src/client/forced.ts"],"sourcesContent":["// Forced security updates (offline-and-updates.md, Rule 2). The page reads the flag file at\n// start, every 15 minutes, whenever the tab comes back into view and on each navigation to\n// the index. When the file names a minimum release later than this page's, the page shows a\n// blocking notice and asks the launcher to save the game in play. Only when the player\n// chooses Update now, and the save has landed, does it bring in the new worker and reload: a\n// game never swaps code without that save (Rule 2). A page that can't reach the flag file\n// reads no instruction, so offline play goes on.\nimport { mustUpdate, type Control } from '../control.ts';\nimport { readControl } from './control.ts';\n\n/** How the page shows the notice. The default is `<jy-security-update>`. */\nexport interface SecurityNotice {\n /** Shows the blocking notice; `update` runs when the player chooses Update now. */\n show(update: () => void): void;\n}\n\nexport type ForcedUpdateOptions = {\n /** This page's release time (`__JOYSTYK_RELEASE_TIME__`). */\n readonly releaseTime: string;\n /** Saves the game in play: the launcher's `requestSave`. Without it, nothing is saved. */\n readonly saveGame?: () => Promise<void>;\n /** Brings in the new release's worker before the reload, when there is one. */\n readonly prepare?: () => Promise<void>;\n /** How often to read the flag file. Default 15 minutes. */\n readonly checkMs?: number;\n readonly notice?: SecurityNotice;\n /** Reads the flag file; swapped in tests. */\n readonly read?: () => Promise<Control | null>;\n /** Reloads the page; swapped in tests. */\n readonly reload?: () => void;\n};\n\nexport interface ForcedUpdates {\n /** Reads the flag file now and acts on it; resolves with what it said. */\n check(): Promise<Control | null>;\n /** Acts on a flag file read elsewhere. */\n observe(control: Control | null): void;\n /** True once the notice is up. */\n readonly forcing: boolean;\n dispose(): void;\n}\n\nconst DEFAULT_CHECK_MS = 15 * 60 * 1000;\n\n/** The default notice: `<jy-security-update>` at the end of the body. */\nexport const securityNotice = (): SecurityNotice => ({\n show: (update) => {\n void import('./elements.ts').then(({ definePwaElements }) => {\n definePwaElements();\n const notice = document.createElement('jy-security-update');\n notice.onUpdate = update;\n document.body.append(notice);\n });\n },\n});\n\nexport const watchForcedUpdates = (options: ForcedUpdateOptions): ForcedUpdates => {\n const read = options.read ?? readControl;\n const reload = options.reload ?? (() => location.reload());\n const notice = options.notice ?? securityNotice();\n const cleanups: (() => void)[] = [];\n let forcing = false;\n\n const force = (): void => {\n if (forcing) return;\n forcing = true;\n // Saving starts with the notice: the notice stops input, and pausing stops a real-time\n // game from running on unattended behind it.\n const saved = Promise.resolve()\n .then(() => options.saveGame?.())\n .catch(() => undefined);\n notice.show(() => {\n void saved\n .then(() => options.prepare?.())\n .catch(() => undefined)\n .then(reload);\n });\n };\n\n const observe = (control: Control | null): void => {\n if (mustUpdate(control, options.releaseTime)) force();\n };\n\n const check = async (): Promise<Control | null> => {\n const control = await read();\n observe(control);\n return control;\n };\n\n const interval = setInterval(() => void check(), options.checkMs ?? DEFAULT_CHECK_MS);\n cleanups.push(() => clearInterval(interval));\n const onVisible = (): void => {\n if (document.visibilityState === 'visible') void check();\n };\n document.addEventListener('visibilitychange', onVisible);\n cleanups.push(() => document.removeEventListener('visibilitychange', onVisible));\n\n return {\n check,\n observe,\n get forcing() {\n return forcing;\n },\n dispose: () => {\n for (const cleanup of cleanups.splice(0)) cleanup();\n },\n };\n};\n"],"mappings":";;;AA0CA,IAAM,mBAAmB;;AAGzB,IAAa,wBAAwC,EACnD,OAAO,WAAW;CAChB,OAAY,gBAAgB,CAAC,MAAM,EAAE,wBAAwB;EAC3D,kBAAkB;EAClB,MAAM,SAAS,SAAS,cAAc,oBAAoB;EAC1D,OAAO,WAAW;EAClB,SAAS,KAAK,OAAO,MAAM;CAC7B,CAAC;AACH,EACF;AAEA,IAAa,sBAAsB,YAAgD;CACjF,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,SAAS,QAAQ,iBAAiB,SAAS,OAAO;CACxD,MAAM,SAAS,QAAQ,UAAU,eAAe;CAChD,MAAM,WAA2B,CAAC;CAClC,IAAI,UAAU;CAEd,MAAM,cAAoB;EACxB,IAAI,SAAS;EACb,UAAU;EAGV,MAAM,QAAQ,QAAQ,QAAQ,CAAC,CAC5B,WAAW,QAAQ,WAAW,CAAC,CAAC,CAChC,YAAY,KAAA,CAAS;EACxB,OAAO,WAAW;GAChB,MACG,WAAW,QAAQ,UAAU,CAAC,CAAC,CAC/B,YAAY,KAAA,CAAS,CAAC,CACtB,KAAK,MAAM;EAChB,CAAC;CACH;CAEA,MAAM,WAAW,YAAkC;EACjD,IAAI,WAAW,SAAS,QAAQ,WAAW,GAAG,MAAM;CACtD;CAEA,MAAM,QAAQ,YAAqC;EACjD,MAAM,UAAU,MAAM,KAAK;EAC3B,QAAQ,OAAO;EACf,OAAO;CACT;CAEA,MAAM,WAAW,kBAAkB,KAAK,MAAM,GAAG,QAAQ,WAAW,gBAAgB;CACpF,SAAS,WAAW,cAAc,QAAQ,CAAC;CAC3C,MAAM,kBAAwB;EAC5B,IAAI,SAAS,oBAAoB,WAAW,MAAW;CACzD;CACA,SAAS,iBAAiB,oBAAoB,SAAS;CACvD,SAAS,WAAW,SAAS,oBAAoB,oBAAoB,SAAS,CAAC;CAE/E,OAAO;EACL;EACA;EACA,IAAI,UAAU;GACZ,OAAO;EACT;EACA,eAAe;GACb,KAAK,MAAM,WAAW,SAAS,OAAO,CAAC,GAAG,QAAQ;EACpD;CACF;AACF"}
@@ -0,0 +1,7 @@
1
+ export { definePwaElements, OfflineToggle, SecurityUpdate, UpdateToast } from './elements.js';
2
+ export { browserCachePorts, pinnedGames, pinStatus, unpinGame, type CachePorts } from './pinned.js';
3
+ export { browserPinPorts, PIN_MESSAGES, pinGame, refreshPinnedGames, type Pinned, type PinError, type PinErrorCode, type PinPorts, } from './pins.js';
4
+ export { readControl } from './control.js';
5
+ export { securityNotice, watchForcedUpdates, type ForcedUpdateOptions, type ForcedUpdates, type SecurityNotice, } from './forced.js';
6
+ export { dailyToPrefetch, disablePwa, registerPwa, toastPrompt, type DailyPrefetcher, type PwaController, type PwaOptions, type UpdatePrompt, } from './register.js';
7
+ export { watchOfflineAvailability, PIN_CHANGED, type AvailabilityPorts } from './availability.js';
@@ -0,0 +1,8 @@
1
+ import { browserCachePorts, pinStatus, pinnedGames, unpinGame } from "./pinned.js";
2
+ import { PIN_CHANGED, watchOfflineAvailability } from "./availability.js";
3
+ import { readControl } from "./control.js";
4
+ import { PIN_MESSAGES, browserPinPorts, pinGame, refreshPinnedGames } from "./pins.js";
5
+ import { OfflineToggle, SecurityUpdate, UpdateToast, definePwaElements } from "./elements.js";
6
+ import { securityNotice, watchForcedUpdates } from "./forced.js";
7
+ import { dailyToPrefetch, disablePwa, registerPwa, toastPrompt } from "./register.js";
8
+ export { OfflineToggle, PIN_CHANGED, PIN_MESSAGES, SecurityUpdate, UpdateToast, browserCachePorts, browserPinPorts, dailyToPrefetch, definePwaElements, disablePwa, pinGame, pinStatus, pinnedGames, readControl, refreshPinnedGames, registerPwa, securityNotice, toastPrompt, unpinGame, watchForcedUpdates, watchOfflineAvailability };
@@ -0,0 +1,13 @@
1
+ import { type PinRecord } from '../pin-record.js';
2
+ /** The Cache Storage a pin lives in, and the origin its keys belong to. Tests pass fakes. */
3
+ export type CachePorts = {
4
+ readonly caches: CacheStorage;
5
+ readonly origin: string;
6
+ };
7
+ export declare const browserCachePorts: () => CachePorts;
8
+ /** Every pinned game, read from each pin's own record, sorted by name. */
9
+ export declare const pinnedGames: (ports?: CachePorts) => Promise<PinRecord[]>;
10
+ /** The game's pin, or null when it isn't available offline. */
11
+ export declare const pinStatus: (gameId: string, ports?: CachePorts) => Promise<PinRecord | null>;
12
+ /** Removes every pinned build of the game. */
13
+ export declare const unpinGame: (gameId: string, ports?: CachePorts) => Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { PIN_RECORD_PATH, parsePinnedCacheName } from "../names.js";
2
+ import { parsePinRecord } from "../pin-record.js";
3
+ //#region src/client/pinned.ts
4
+ var browserCachePorts = () => ({
5
+ caches: globalThis.caches,
6
+ origin: location.origin
7
+ });
8
+ /** Every pinned game, read from each pin's own record, sorted by name. */
9
+ var pinnedGames = async (ports = browserCachePorts()) => {
10
+ const found = [];
11
+ for (const name of await ports.caches.keys()) {
12
+ const pinned = parsePinnedCacheName(name);
13
+ if (pinned === null) continue;
14
+ const response = await (await ports.caches.open(name)).match(new URL(PIN_RECORD_PATH, ports.origin).href);
15
+ const record = response === void 0 ? null : parsePinRecord(await response.json().catch(() => null));
16
+ if (record !== null && record.id === pinned.id && record.version === pinned.version) found.push(record);
17
+ }
18
+ return found.sort((a, b) => a.name.localeCompare(b.name, "en"));
19
+ };
20
+ /** The game's pin, or null when it isn't available offline. */
21
+ var pinStatus = async (gameId, ports = browserCachePorts()) => (await pinnedGames(ports)).find((record) => record.id === gameId) ?? null;
22
+ /** Removes every pinned build of the game. */
23
+ var unpinGame = async (gameId, ports = browserCachePorts()) => {
24
+ for (const name of await ports.caches.keys()) if (parsePinnedCacheName(name)?.id === gameId) await ports.caches.delete(name);
25
+ };
26
+ //#endregion
27
+ export { browserCachePorts, pinStatus, pinnedGames, unpinGame };
28
+
29
+ //# sourceMappingURL=pinned.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pinned.js","names":[],"sources":["../../src/client/pinned.ts"],"sourcesContent":["// Reading and removing pins: what the offline page needs, without the code that pins.\nimport { parsePinnedCacheName, PIN_RECORD_PATH } from '../names.ts';\nimport { parsePinRecord, type PinRecord } from '../pin-record.ts';\n\n/** The Cache Storage a pin lives in, and the origin its keys belong to. Tests pass fakes. */\nexport type CachePorts = {\n readonly caches: CacheStorage;\n readonly origin: string;\n};\n\nexport const browserCachePorts = (): CachePorts => ({ caches: globalThis.caches, origin: location.origin });\n\n/** Every pinned game, read from each pin's own record, sorted by name. */\nexport const pinnedGames = async (ports: CachePorts = browserCachePorts()): Promise<PinRecord[]> => {\n const found: PinRecord[] = [];\n for (const name of await ports.caches.keys()) {\n const pinned = parsePinnedCacheName(name);\n if (pinned === null) continue;\n const response = await (await ports.caches.open(name)).match(new URL(PIN_RECORD_PATH, ports.origin).href);\n const record = response === undefined ? null : parsePinRecord(await response.json().catch(() => null));\n if (record !== null && record.id === pinned.id && record.version === pinned.version) found.push(record);\n }\n return found.sort((a, b) => a.name.localeCompare(b.name, 'en'));\n};\n\n/** The game's pin, or null when it isn't available offline. */\nexport const pinStatus = async (gameId: string, ports: CachePorts = browserCachePorts()): Promise<PinRecord | null> =>\n (await pinnedGames(ports)).find((record) => record.id === gameId) ?? null;\n\n/** Removes every pinned build of the game. */\nexport const unpinGame = async (gameId: string, ports: CachePorts = browserCachePorts()): Promise<void> => {\n for (const name of await ports.caches.keys()) {\n if (parsePinnedCacheName(name)?.id === gameId) await ports.caches.delete(name);\n }\n};\n"],"mappings":";;;AAUA,IAAa,2BAAuC;CAAE,QAAQ,WAAW;CAAQ,QAAQ,SAAS;AAAO;;AAGzG,IAAa,cAAc,OAAO,QAAoB,kBAAkB,MAA4B;CAClG,MAAM,QAAqB,CAAC;CAC5B,KAAK,MAAM,QAAQ,MAAM,MAAM,OAAO,KAAK,GAAG;EAC5C,MAAM,SAAS,qBAAqB,IAAI;EACxC,IAAI,WAAW,MAAM;EACrB,MAAM,WAAW,OAAO,MAAM,MAAM,OAAO,KAAK,IAAI,EAAA,CAAG,MAAM,IAAI,IAAI,iBAAiB,MAAM,MAAM,CAAC,CAAC,IAAI;EACxG,MAAM,SAAS,aAAa,KAAA,IAAY,OAAO,eAAe,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,IAAI,CAAC;EACrG,IAAI,WAAW,QAAQ,OAAO,OAAO,OAAO,MAAM,OAAO,YAAY,OAAO,SAAS,MAAM,KAAK,MAAM;CACxG;CACA,OAAO,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,IAAI,CAAC;AAChE;;AAGA,IAAa,YAAY,OAAO,QAAgB,QAAoB,kBAAkB,OACnF,MAAM,YAAY,KAAK,EAAA,CAAG,MAAM,WAAW,OAAO,OAAO,MAAM,KAAK;;AAGvE,IAAa,YAAY,OAAO,QAAgB,QAAoB,kBAAkB,MAAqB;CACzG,KAAK,MAAM,QAAQ,MAAM,MAAM,OAAO,KAAK,GACzC,IAAI,qBAAqB,IAAI,CAAC,EAAE,OAAO,QAAQ,MAAM,MAAM,OAAO,OAAO,IAAI;AAEjF"}
@@ -0,0 +1,43 @@
1
+ import { type Result } from '@joystyk/contracts';
2
+ import type { PersistResult } from '@joystyk/storage/browser';
3
+ import type { PinRecord } from '../pin-record.js';
4
+ import { type CachePorts } from './pinned.js';
5
+ /** What pinning touches; tests pass fakes. */
6
+ export type PinPorts = CachePorts & {
7
+ readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;
8
+ /** Asks the browser to keep the site's data; called on the first pin only. */
9
+ readonly persist: () => Promise<PersistResult>;
10
+ };
11
+ export declare const browserPinPorts: () => PinPorts;
12
+ export type PinErrorCode = 'Offline' | 'Quota' | 'Invalid' | 'Incomplete' | 'Unsupported';
13
+ export type PinError = {
14
+ readonly code: PinErrorCode;
15
+ /** For players. */
16
+ readonly message: string;
17
+ /** For developers. */
18
+ readonly detail: string;
19
+ };
20
+ export declare const PIN_MESSAGES: Readonly<Record<PinErrorCode, string>>;
21
+ export type Pinned = {
22
+ readonly record: PinRecord;
23
+ /** Set on the first pin, when persistent storage was requested; null otherwise. */
24
+ readonly persisted: PersistResult | null;
25
+ };
26
+ /**
27
+ * Makes a game available offline, or brings its pin up to date: reads the asset manifest at
28
+ * `assets` (the game manifest's `assets`), caches every file and the game's page, then
29
+ * removes older builds. The first pin asks for persistent storage.
30
+ */
31
+ export declare const pinGame: (input: {
32
+ readonly gameId: string;
33
+ readonly assets: string;
34
+ }, ports?: PinPorts) => Promise<Result<Pinned, PinError>>;
35
+ /**
36
+ * Brings every pin up to the build the server has now: same version, missing files added and
37
+ * stale ones dropped; a new version, a new cache, then the old one removed. Call it at a safe
38
+ * point while online (Rule 2); a pin that fails to refresh keeps its old build.
39
+ */
40
+ export declare const refreshPinnedGames: (ports?: PinPorts) => Promise<{
41
+ refreshed: string[];
42
+ failed: string[];
43
+ }>;