@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.
- package/CHANGELOG.md +24 -0
- package/README.md +215 -2
- package/dist/asset-manifest.d.ts +76 -0
- package/dist/asset-manifest.js +80 -0
- package/dist/asset-manifest.js.map +1 -0
- package/dist/build/documents.d.ts +79 -0
- package/dist/build/documents.js +135 -0
- package/dist/build/documents.js.map +1 -0
- package/dist/build/graph.d.ts +39 -0
- package/dist/build/graph.js +70 -0
- package/dist/build/graph.js.map +1 -0
- package/dist/build/icons.d.ts +30 -0
- package/dist/build/icons.js +192 -0
- package/dist/build/icons.js.map +1 -0
- package/dist/build/index.d.ts +4 -0
- package/dist/build/index.js +5 -0
- package/dist/build/plugin.d.ts +32 -0
- package/dist/build/plugin.js +172 -0
- package/dist/build/plugin.js.map +1 -0
- package/dist/client/availability.d.ts +12 -0
- package/dist/client/availability.js +94 -0
- package/dist/client/availability.js.map +1 -0
- package/dist/client/control.d.ts +3 -0
- package/dist/client/control.js +19 -0
- package/dist/client/control.js.map +1 -0
- package/dist/client/elements.d.ts +45 -0
- package/dist/client/elements.js +319 -0
- package/dist/client/elements.js.map +1 -0
- package/dist/client/forced.d.ts +33 -0
- package/dist/client/forced.js +57 -0
- package/dist/client/forced.js.map +1 -0
- package/dist/client/index.d.ts +7 -0
- package/dist/client/index.js +8 -0
- package/dist/client/pinned.d.ts +13 -0
- package/dist/client/pinned.js +29 -0
- package/dist/client/pinned.js.map +1 -0
- package/dist/client/pins.d.ts +43 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/pins.js.map +1 -0
- package/dist/client/register.d.ts +64 -0
- package/dist/client/register.js +255 -0
- package/dist/client/register.js.map +1 -0
- package/dist/control.d.ts +46 -0
- package/dist/control.js +88 -0
- package/dist/control.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/names.d.ts +47 -0
- package/dist/names.js +64 -0
- package/dist/names.js.map +1 -0
- package/dist/offline/offline.css +94 -0
- package/dist/offline/offline.d.ts +1 -0
- package/dist/offline/offline.js +26 -0
- package/dist/offline/offline.js.map +1 -0
- package/dist/offline-mark.d.ts +15 -0
- package/dist/offline-mark.js +51 -0
- package/dist/offline-mark.js.map +1 -0
- package/dist/pin-record.d.ts +14 -0
- package/dist/pin-record.js +36 -0
- package/dist/pin-record.js.map +1 -0
- package/dist/preloads.d.ts +2 -0
- package/dist/preloads.js +26 -0
- package/dist/preloads.js.map +1 -0
- package/dist/routing.d.ts +57 -0
- package/dist/routing.js +81 -0
- package/dist/routing.js.map +1 -0
- package/dist/sw/remove-sw.d.ts +1 -0
- package/dist/sw/remove-sw.js +16 -0
- package/dist/sw/remove-sw.js.map +1 -0
- package/dist/sw/sw.d.ts +1 -0
- package/dist/sw/sw.js +165 -0
- package/dist/sw/sw.js.map +1 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -0
- package/package.json +65 -11
|
@@ -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
|
+
}>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { PIN_RECORD_PATH, parsePinnedCacheName, pinnedCacheName } from "../names.js";
|
|
2
|
+
import { parseAssetManifest, planPin, referencedAssets } from "../asset-manifest.js";
|
|
3
|
+
import { pinnedGames } from "./pinned.js";
|
|
4
|
+
import { err, ok } from "@joystyk/contracts";
|
|
5
|
+
//#region src/client/pins.ts
|
|
6
|
+
var browserPinPorts = () => ({
|
|
7
|
+
caches: globalThis.caches,
|
|
8
|
+
fetch: (url, init) => fetch(url, init),
|
|
9
|
+
origin: location.origin,
|
|
10
|
+
persist: async () => (await import("@joystyk/storage/browser")).requestPersistentStorage()
|
|
11
|
+
});
|
|
12
|
+
var PIN_MESSAGES = {
|
|
13
|
+
Offline: "You're offline. Connect to the internet, then try again.",
|
|
14
|
+
Quota: "This device is out of storage space, so the game couldn't be saved for offline play.",
|
|
15
|
+
Invalid: "This game can't be saved for offline play right now.",
|
|
16
|
+
Incomplete: "This game couldn't be saved for offline play right now. Try again in a minute.",
|
|
17
|
+
Unsupported: "This browser can't keep games for offline play."
|
|
18
|
+
};
|
|
19
|
+
var pinError = (code, detail) => ({
|
|
20
|
+
code,
|
|
21
|
+
message: PIN_MESSAGES[code],
|
|
22
|
+
detail
|
|
23
|
+
});
|
|
24
|
+
var PinFailure = class extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, detail) {
|
|
27
|
+
super(detail);
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var toPinError = (cause) => {
|
|
32
|
+
if (cause instanceof PinFailure) return pinError(cause.code, cause.message);
|
|
33
|
+
if (cause instanceof DOMException && cause.name === "QuotaExceededError") return pinError("Quota", cause.message);
|
|
34
|
+
if (cause instanceof TypeError) return pinError("Offline", cause.message);
|
|
35
|
+
return pinError("Incomplete", String(cause));
|
|
36
|
+
};
|
|
37
|
+
var pathsIn = async (cache) => (await cache.keys()).map((request) => new URL(request.url).pathname);
|
|
38
|
+
var fetchOk = async (ports, url, cache) => {
|
|
39
|
+
const response = await ports.fetch(url, {
|
|
40
|
+
cache,
|
|
41
|
+
credentials: "same-origin",
|
|
42
|
+
...cache === "no-cache" ? { headers: { "X-Joystyk-Pin-Refresh": "1" } } : {}
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok || response.redirected) throw new PinFailure("Incomplete", `${url}: HTTP ${String(response.status)}${response.redirected ? " (redirected)" : ""}`);
|
|
45
|
+
return response;
|
|
46
|
+
};
|
|
47
|
+
var loadManifest = async (ports, gameId, assets) => {
|
|
48
|
+
const response = await fetchOk(ports, assets, "no-cache").catch((cause) => {
|
|
49
|
+
throw cause instanceof PinFailure ? new PinFailure("Invalid", cause.message) : cause;
|
|
50
|
+
});
|
|
51
|
+
const raw = await response.clone().json().catch(() => null);
|
|
52
|
+
const parsed = parseAssetManifest(raw);
|
|
53
|
+
if (!parsed.ok) throw new PinFailure("Invalid", `${assets}: ${parsed.error.join("; ")}`);
|
|
54
|
+
if (parsed.value.id !== gameId) throw new PinFailure("Invalid", `${assets} describes ${parsed.value.id}, not ${gameId}`);
|
|
55
|
+
return {
|
|
56
|
+
manifest: parsed.value,
|
|
57
|
+
response
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
var pinOnce = async (ports, gameId, assets) => {
|
|
61
|
+
const { manifest, response: manifestResponse } = await loadManifest(ports, gameId, assets);
|
|
62
|
+
const names = await ports.caches.keys();
|
|
63
|
+
const firstPin = !names.some((name) => parsePinnedCacheName(name) !== null);
|
|
64
|
+
const existing = [];
|
|
65
|
+
for (const name of names) if (parsePinnedCacheName(name)?.id === gameId) existing.push({
|
|
66
|
+
cacheName: name,
|
|
67
|
+
paths: await pathsIn(await ports.caches.open(name))
|
|
68
|
+
});
|
|
69
|
+
const target = pinnedCacheName(manifest.id, manifest.version);
|
|
70
|
+
const created = !names.includes(target);
|
|
71
|
+
const plan = planPin(manifest, assets, target, existing, PIN_RECORD_PATH);
|
|
72
|
+
const key = (path) => new URL(path, ports.origin).href;
|
|
73
|
+
const cache = await ports.caches.open(target);
|
|
74
|
+
try {
|
|
75
|
+
for (const url of plan.fetch) await cache.put(key(url), await fetchOk(ports, url, "default"));
|
|
76
|
+
const files = new Set(manifest.files.map((file) => file.url));
|
|
77
|
+
const documents = [];
|
|
78
|
+
for (const url of plan.documents) documents.push([url, url === assets ? manifestResponse : await fetchOk(ports, url, "no-cache")]);
|
|
79
|
+
const page = documents.find(([url]) => url === manifest.page)?.[1];
|
|
80
|
+
if (page !== void 0) {
|
|
81
|
+
const missing = referencedAssets(await page.clone().text()).filter((url) => !files.has(url));
|
|
82
|
+
if (missing.length > 0) throw new PinFailure("Incomplete", `${manifest.page} needs files the asset manifest doesn't list: ${missing.join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
for (const [url, response] of documents) await cache.put(key(url), response);
|
|
85
|
+
const record = {
|
|
86
|
+
id: manifest.id,
|
|
87
|
+
name: manifest.name,
|
|
88
|
+
version: manifest.version,
|
|
89
|
+
release: manifest.release,
|
|
90
|
+
page: manifest.page,
|
|
91
|
+
assets,
|
|
92
|
+
totalBytes: manifest.totalBytes
|
|
93
|
+
};
|
|
94
|
+
await cache.put(key(PIN_RECORD_PATH), new Response(JSON.stringify(record), { headers: { "content-type": "application/json" } }));
|
|
95
|
+
for (const url of plan.remove) await cache.delete(key(url));
|
|
96
|
+
for (const name of plan.dropCaches) await ports.caches.delete(name);
|
|
97
|
+
return {
|
|
98
|
+
record,
|
|
99
|
+
persisted: firstPin ? await ports.persist() : null
|
|
100
|
+
};
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
if (created) await ports.caches.delete(target);
|
|
103
|
+
throw cause;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Makes a game available offline, or brings its pin up to date: reads the asset manifest at
|
|
108
|
+
* `assets` (the game manifest's `assets`), caches every file and the game's page, then
|
|
109
|
+
* removes older builds. The first pin asks for persistent storage.
|
|
110
|
+
*/
|
|
111
|
+
var pinGame = async (input, ports = browserPinPorts()) => {
|
|
112
|
+
if (ports.caches === void 0) return err(pinError("Unsupported", "no Cache Storage"));
|
|
113
|
+
for (let attempt = 1;; attempt += 1) try {
|
|
114
|
+
return ok(await pinOnce(ports, input.gameId, input.assets));
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
const error = toPinError(cause);
|
|
117
|
+
if (error.code !== "Incomplete" || attempt === 2) return err(error);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Brings every pin up to the build the server has now: same version, missing files added and
|
|
122
|
+
* stale ones dropped; a new version, a new cache, then the old one removed. Call it at a safe
|
|
123
|
+
* point while online (Rule 2); a pin that fails to refresh keeps its old build.
|
|
124
|
+
*/
|
|
125
|
+
var refreshPinnedGames = async (ports = browserPinPorts()) => {
|
|
126
|
+
const refreshed = [];
|
|
127
|
+
const failed = [];
|
|
128
|
+
for (const record of await pinnedGames(ports)) ((await pinGame({
|
|
129
|
+
gameId: record.id,
|
|
130
|
+
assets: record.assets
|
|
131
|
+
}, ports)).ok ? refreshed : failed).push(record.id);
|
|
132
|
+
return {
|
|
133
|
+
refreshed,
|
|
134
|
+
failed
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
export { PIN_MESSAGES, browserPinPorts, pinGame, refreshPinnedGames };
|
|
139
|
+
|
|
140
|
+
//# sourceMappingURL=pins.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pins.js","names":[],"sources":["../../src/client/pins.ts"],"sourcesContent":["// Tier 3, from the page: \"Make available offline\" writes a game's whole build into its own\n// cache, `game:<id>:<version>`, which the worker checks before anything else it doesn't\n// precache. The page and the worker share the origin's Cache Storage, so no message passes\n// between them (the blog's design). A pin holds every file in the game's asset manifest and\n// the game's page, so it is complete or it isn't kept.\nimport { err, ok, type Result } from '@joystyk/contracts';\nimport type { PersistResult } from '@joystyk/storage/browser';\nimport { parseAssetManifest, planPin, referencedAssets, type GameAssetManifest } from '../asset-manifest.ts';\nimport { parsePinnedCacheName, PIN_RECORD_PATH, pinnedCacheName } from '../names.ts';\nimport type { PinRecord } from '../pin-record.ts';\nimport { pinnedGames, type CachePorts } from './pinned.ts';\n\n/** What pinning touches; tests pass fakes. */\nexport type PinPorts = CachePorts & {\n readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;\n /** Asks the browser to keep the site's data; called on the first pin only. */\n readonly persist: () => Promise<PersistResult>;\n};\n\nexport const browserPinPorts = (): PinPorts => ({\n caches: globalThis.caches,\n fetch: (url, init) => fetch(url, init),\n origin: location.origin,\n // Loaded on first use: the storage package's browser code is a chunk of its own.\n persist: async () => (await import('@joystyk/storage/browser')).requestPersistentStorage(),\n});\n\nexport type PinErrorCode = 'Offline' | 'Quota' | 'Invalid' | 'Incomplete' | 'Unsupported';\n\nexport type PinError = {\n readonly code: PinErrorCode;\n /** For players. */\n readonly message: string;\n /** For developers. */\n readonly detail: string;\n};\n\nexport const PIN_MESSAGES: Readonly<Record<PinErrorCode, string>> = {\n Offline: \"You're offline. Connect to the internet, then try again.\",\n Quota: \"This device is out of storage space, so the game couldn't be saved for offline play.\",\n Invalid: \"This game can't be saved for offline play right now.\",\n Incomplete: \"This game couldn't be saved for offline play right now. Try again in a minute.\",\n Unsupported: \"This browser can't keep games for offline play.\",\n};\n\nconst pinError = (code: PinErrorCode, detail: string): PinError => ({ code, message: PIN_MESSAGES[code], detail });\n\nclass PinFailure extends Error {\n readonly code: PinErrorCode;\n constructor(code: PinErrorCode, detail: string) {\n super(detail);\n this.code = code;\n }\n}\n\nconst toPinError = (cause: unknown): PinError => {\n if (cause instanceof PinFailure) return pinError(cause.code, cause.message);\n if (cause instanceof DOMException && cause.name === 'QuotaExceededError') return pinError('Quota', cause.message);\n if (cause instanceof TypeError) return pinError('Offline', cause.message);\n return pinError('Incomplete', String(cause));\n};\n\nexport type Pinned = {\n readonly record: PinRecord;\n /** Set on the first pin, when persistent storage was requested; null otherwise. */\n readonly persisted: PersistResult | null;\n};\n\nconst pathsIn = async (cache: Cache): Promise<string[]> =>\n (await cache.keys()).map((request) => new URL(request.url).pathname);\n\nconst fetchOk = async (ports: PinPorts, url: string, cache: RequestCache): Promise<Response> => {\n const response = await ports.fetch(url, {\n cache,\n credentials: 'same-origin',\n ...(cache === 'no-cache' ? { headers: { 'X-Joystyk-Pin-Refresh': '1' } } : {}),\n });\n if (!response.ok || response.redirected)\n throw new PinFailure(\n 'Incomplete',\n `${url}: HTTP ${String(response.status)}${response.redirected ? ' (redirected)' : ''}`,\n );\n return response;\n};\n\nconst loadManifest = async (\n ports: PinPorts,\n gameId: string,\n assets: string,\n): Promise<{ manifest: GameAssetManifest; response: Response }> => {\n const response = await fetchOk(ports, assets, 'no-cache').catch((cause: unknown) => {\n throw cause instanceof PinFailure ? new PinFailure('Invalid', cause.message) : cause;\n });\n const raw: unknown = await response\n .clone()\n .json()\n .catch(() => null);\n const parsed = parseAssetManifest(raw);\n if (!parsed.ok) throw new PinFailure('Invalid', `${assets}: ${parsed.error.join('; ')}`);\n if (parsed.value.id !== gameId)\n throw new PinFailure('Invalid', `${assets} describes ${parsed.value.id}, not ${gameId}`);\n return { manifest: parsed.value, response };\n};\n\nconst pinOnce = async (ports: PinPorts, gameId: string, assets: string): Promise<Pinned> => {\n const { manifest, response: manifestResponse } = await loadManifest(ports, gameId, assets);\n const names = await ports.caches.keys();\n const firstPin = !names.some((name) => parsePinnedCacheName(name) !== null);\n const existing: { cacheName: string; paths: string[] }[] = [];\n for (const name of names) {\n if (parsePinnedCacheName(name)?.id === gameId)\n existing.push({ cacheName: name, paths: await pathsIn(await ports.caches.open(name)) });\n }\n const target = pinnedCacheName(manifest.id, manifest.version);\n const created = !names.includes(target);\n const plan = planPin(manifest, assets, target, existing, PIN_RECORD_PATH);\n const key = (path: string): string => new URL(path, ports.origin).href;\n const cache = await ports.caches.open(target);\n try {\n // Hashed files first: they never change, so the browser's HTTP cache may answer.\n for (const url of plan.fetch) await cache.put(key(url), await fetchOk(ports, url, 'default'));\n // Then the pages, fresh. A deploy between reading the manifest and the page would leave a\n // page naming files the pin lacks; refuse it, and the caller tries once more.\n const files = new Set(manifest.files.map((file) => file.url));\n const documents: [string, Response][] = [];\n for (const url of plan.documents) {\n documents.push([url, url === assets ? manifestResponse : await fetchOk(ports, url, 'no-cache')]);\n }\n const page = documents.find(([url]) => url === manifest.page)?.[1];\n if (page !== undefined) {\n const missing = referencedAssets(await page.clone().text()).filter((url) => !files.has(url));\n if (missing.length > 0)\n throw new PinFailure(\n 'Incomplete',\n `${manifest.page} needs files the asset manifest doesn't list: ${missing.join(', ')}`,\n );\n }\n for (const [url, response] of documents) await cache.put(key(url), response);\n const record: PinRecord = {\n id: manifest.id,\n name: manifest.name,\n version: manifest.version,\n release: manifest.release,\n page: manifest.page,\n assets,\n totalBytes: manifest.totalBytes,\n };\n await cache.put(\n key(PIN_RECORD_PATH),\n new Response(JSON.stringify(record), { headers: { 'content-type': 'application/json' } }),\n );\n // Only now, with the new build complete, drop what it replaced.\n for (const url of plan.remove) await cache.delete(key(url));\n for (const name of plan.dropCaches) await ports.caches.delete(name);\n return { record, persisted: firstPin ? await ports.persist() : null };\n } catch (cause) {\n if (created) await ports.caches.delete(target);\n throw cause;\n }\n};\n\n/**\n * Makes a game available offline, or brings its pin up to date: reads the asset manifest at\n * `assets` (the game manifest's `assets`), caches every file and the game's page, then\n * removes older builds. The first pin asks for persistent storage.\n */\nexport const pinGame = async (\n input: { readonly gameId: string; readonly assets: string },\n ports: PinPorts = browserPinPorts(),\n): Promise<Result<Pinned, PinError>> => {\n if ((ports.caches as CacheStorage | undefined) === undefined) return err(pinError('Unsupported', 'no Cache Storage'));\n for (let attempt = 1; ; attempt += 1) {\n try {\n return ok(await pinOnce(ports, input.gameId, input.assets));\n } catch (cause) {\n const error = toPinError(cause);\n if (error.code !== 'Incomplete' || attempt === 2) return err(error);\n }\n }\n};\n\n/**\n * Brings every pin up to the build the server has now: same version, missing files added and\n * stale ones dropped; a new version, a new cache, then the old one removed. Call it at a safe\n * point while online (Rule 2); a pin that fails to refresh keeps its old build.\n */\nexport const refreshPinnedGames = async (\n ports: PinPorts = browserPinPorts(),\n): Promise<{ refreshed: string[]; failed: string[] }> => {\n const refreshed: string[] = [];\n const failed: string[] = [];\n for (const record of await pinnedGames(ports)) {\n const result = await pinGame({ gameId: record.id, assets: record.assets }, ports);\n (result.ok ? refreshed : failed).push(record.id);\n }\n return { refreshed, failed };\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type SecurityNotice } from './forced.js';
|
|
2
|
+
/** A daily game's content client, as `@joystyk/daily/browser`'s `createDailyClient` makes it. */
|
|
3
|
+
export type DailyPrefetcher = {
|
|
4
|
+
readonly gameId: string;
|
|
5
|
+
prefetch(today: string): Promise<unknown>;
|
|
6
|
+
};
|
|
7
|
+
/** How the page shows "Update ready". The default is `<jy-update-toast>`. */
|
|
8
|
+
export interface UpdatePrompt {
|
|
9
|
+
show(choose: (choice: 'refresh' | 'later') => void): void;
|
|
10
|
+
hide(): void;
|
|
11
|
+
}
|
|
12
|
+
export type PwaOptions = {
|
|
13
|
+
/** This build's release ID, for diagnostics. */
|
|
14
|
+
readonly release: string;
|
|
15
|
+
/**
|
|
16
|
+
* Subscribes to safe-point reports: pass the launcher's `watchSafePoints`
|
|
17
|
+
* (`@joystyk/launcher/browser`). Without it, every moment counts as safe.
|
|
18
|
+
*/
|
|
19
|
+
readonly watchSafePoints?: (listener: (detail: {
|
|
20
|
+
readonly safe: boolean;
|
|
21
|
+
}) => void) => () => void;
|
|
22
|
+
/** How often to look for a new worker. Default 45 minutes, as the blog does. */
|
|
23
|
+
readonly updateCheckMs?: number;
|
|
24
|
+
/**
|
|
25
|
+
* The daily games' content clients. At a safe point, an installed app prefetches every
|
|
26
|
+
* daily game, and a browser tab only the pinned ones (D-46: today and tomorrow, no further).
|
|
27
|
+
*/
|
|
28
|
+
readonly daily?: readonly DailyPrefetcher[];
|
|
29
|
+
readonly prompt?: UpdatePrompt;
|
|
30
|
+
/**
|
|
31
|
+
* When this release's code was committed (`__JOYSTYK_RELEASE_TIME__`). With it, the page
|
|
32
|
+
* honors the flag file's minimum release and forces a security update when it is older.
|
|
33
|
+
*/
|
|
34
|
+
readonly releaseTime?: string;
|
|
35
|
+
/** Saves the game in play before a forced update: pass the launcher's `requestSave`. */
|
|
36
|
+
readonly saveGame?: () => Promise<void>;
|
|
37
|
+
/** How often to read the flag file for a minimum release. Default 15 minutes. */
|
|
38
|
+
readonly controlCheckMs?: number;
|
|
39
|
+
readonly securityNotice?: SecurityNotice;
|
|
40
|
+
};
|
|
41
|
+
export interface PwaController {
|
|
42
|
+
/** The release this page runs. */
|
|
43
|
+
readonly release: string;
|
|
44
|
+
/** The registration, or null without a worker (unsupported, failed, or switched off). */
|
|
45
|
+
readonly ready: Promise<ServiceWorkerRegistration | null>;
|
|
46
|
+
/** Looks for a new worker now. */
|
|
47
|
+
checkForUpdate(): Promise<void>;
|
|
48
|
+
dispose(): void;
|
|
49
|
+
}
|
|
50
|
+
/** The daily games to prefetch: all of them in an installed app, otherwise the pinned ones. */
|
|
51
|
+
export declare const dailyToPrefetch: (prefetchers: readonly DailyPrefetcher[], context: {
|
|
52
|
+
readonly installed: boolean;
|
|
53
|
+
readonly pinned: readonly string[];
|
|
54
|
+
}) => DailyPrefetcher[];
|
|
55
|
+
/** Unregisters every worker of this origin and deletes the PWA's caches. Saves are untouched. */
|
|
56
|
+
export declare const disablePwa: () => Promise<void>;
|
|
57
|
+
/** The default prompt: `<jy-update-toast>` at the end of the body. */
|
|
58
|
+
export declare const toastPrompt: () => UpdatePrompt;
|
|
59
|
+
/**
|
|
60
|
+
* Registers /sw.js and runs the update flow. Call once per page, in production builds only.
|
|
61
|
+
* A new worker installs and waits; the page shows "Update ready" only while the launcher
|
|
62
|
+
* reports a safe point, then posts SKIP_WAITING and reloads once the new worker controls it.
|
|
63
|
+
*/
|
|
64
|
+
export declare const registerPwa: (options: PwaOptions) => PwaController;
|