@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,172 @@
1
+ import { OFFLINE_URL, WEB_MANIFEST_URL } from "../names.js";
2
+ import { iconPng, iconSvg } from "./icons.js";
3
+ import { contentHash, gameAssetManifest, injectManifest, offlinePage, precacheEntries, webManifest } from "./documents.js";
4
+ import { fileSets } from "./graph.js";
5
+ import { DEFAULT_CONTROL, RELEASE_RECORD_FILE, canonicalReleaseTime } from "../control.js";
6
+ import * as z from "zod/mini";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { fileURLToPath } from "node:url";
11
+ import { build } from "vite";
12
+ //#region src/build/plugin.ts
13
+ var SOURCE = fileURLToPath(new URL("..", import.meta.url));
14
+ var script = (folder, name) => {
15
+ const source = join(SOURCE, folder, `${name}.ts`);
16
+ return existsSync(source) ? source : join(SOURCE, folder, `${name}.js`);
17
+ };
18
+ /** Bundles one browser or worker script into a single minified classic script. */
19
+ var bundleScript = async (entry, define = {}) => {
20
+ const result = await build({
21
+ configFile: false,
22
+ root: SOURCE,
23
+ logLevel: "warn",
24
+ publicDir: false,
25
+ define: {
26
+ "process.env.NODE_ENV": JSON.stringify("production"),
27
+ ...define
28
+ },
29
+ build: {
30
+ write: false,
31
+ emptyOutDir: false,
32
+ copyPublicDir: false,
33
+ minify: true,
34
+ target: "es2022",
35
+ sourcemap: false,
36
+ reportCompressedSize: false,
37
+ lib: {
38
+ entry,
39
+ formats: ["iife"],
40
+ name: "joystykPwa",
41
+ fileName: () => "bundle.js"
42
+ }
43
+ }
44
+ });
45
+ for (const output of Array.isArray(result) ? result : [result]) {
46
+ if (!("output" in output)) continue;
47
+ for (const item of output.output) if (item.type === "chunk" && item.isEntry) return item.code;
48
+ }
49
+ throw new Error(`[pwa] Bundling ${entry} produced no script.`);
50
+ };
51
+ var write = async (file, content) => {
52
+ await mkdir(dirname(file), { recursive: true });
53
+ await writeFile(file, content);
54
+ };
55
+ var manifestSchema = z.record(z.string(), z.object({
56
+ file: z.string(),
57
+ name: z.optional(z.string()),
58
+ src: z.optional(z.string()),
59
+ isEntry: z.optional(z.boolean()),
60
+ isDynamicEntry: z.optional(z.boolean()),
61
+ imports: z.optional(z.array(z.string())),
62
+ dynamicImports: z.optional(z.array(z.string())),
63
+ css: z.optional(z.array(z.string())),
64
+ assets: z.optional(z.array(z.string()))
65
+ }));
66
+ /** The plugin. Add it to the site's client build only. */
67
+ var joystykPwa = (options) => {
68
+ const releaseTime = canonicalReleaseTime(options.releaseTime);
69
+ if (releaseTime === null) throw new Error(`[pwa] releaseTime "${options.releaseTime}" isn't a date-time such as 2026-10-09T14:03:12Z.`);
70
+ let config;
71
+ let logger;
72
+ return {
73
+ name: "joystyk-pwa",
74
+ apply: "build",
75
+ enforce: "post",
76
+ configResolved(resolved) {
77
+ config = resolved;
78
+ logger = resolved.logger;
79
+ },
80
+ async writeBundle(output, bundle) {
81
+ if (config === void 0 || config.build.ssr !== false) return;
82
+ const outDir = resolve(config.root, output.dir ?? config.build.outDir);
83
+ const manifestPath = join(outDir, ".vite", "manifest.json");
84
+ if (!existsSync(manifestPath)) throw new Error("[pwa] The client build needs `build.manifest: true`.");
85
+ const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
86
+ const sources = new Map(Object.values(bundle).filter((item) => item.type === "chunk").map((chunk) => [chunk.fileName, chunk.moduleIds]));
87
+ const site = await options.site({ outDir });
88
+ const hashed = async (name, ext, content) => {
89
+ const file = `assets/${name}-${contentHash(content)}.${ext}`;
90
+ await write(join(outDir, file), content);
91
+ return file;
92
+ };
93
+ const iconFiles = {
94
+ svg: await hashed("icon", "svg", iconSvg()),
95
+ png192: await hashed("icon-192", "png", iconPng(192)),
96
+ png512: await hashed("icon-512", "png", iconPng(512)),
97
+ maskable512: await hashed("icon-maskable-512", "png", iconPng(512, true))
98
+ };
99
+ const offlineCss = await hashed("offline", "css", await readFile(join(SOURCE, "offline", "offline.css")));
100
+ const offlineJs = await hashed("offline", "js", await bundleScript(script("offline", "offline")));
101
+ const url = (file) => `/${file}`;
102
+ const manifestJson = `${JSON.stringify(webManifest({
103
+ svg: url(iconFiles.svg),
104
+ png192: url(iconFiles.png192),
105
+ png512: url(iconFiles.png512),
106
+ maskable512: url(iconFiles.maskable512)
107
+ }), null, 2)}\n`;
108
+ await write(join(outDir, "manifest.webmanifest"), manifestJson);
109
+ const offlineHtml = offlinePage({
110
+ css: url(offlineCss),
111
+ js: url(offlineJs),
112
+ icon: url(iconFiles.svg)
113
+ });
114
+ await write(join(outDir, "offline.html"), offlineHtml);
115
+ const sets = fileSets(manifest, site.games, config.root, site.unlisted ?? [], sources);
116
+ const sizeOf = (file) => statSync(join(outDir, file)).size;
117
+ for (const game of site.games) {
118
+ const assets = gameAssetManifest(game, options.release, sets.shell, sets.games.get(game.id) ?? /* @__PURE__ */ new Set(), sizeOf);
119
+ if (!game.assets.startsWith("/") || game.assets.includes("..")) throw new Error(`[pwa] ${game.id}: assets "${game.assets}" must be a site path.`);
120
+ await write(join(outDir, game.assets), `${JSON.stringify(assets, null, 2)}\n`);
121
+ }
122
+ const rendered = [];
123
+ for (const path of site.shellPages) {
124
+ const response = await site.render(path);
125
+ if (response.status !== 200) throw new Error(`[pwa] Precaching ${path}: the server answers ${String(response.status)}, not 200.`);
126
+ rendered.push({
127
+ url: path,
128
+ hashed: false,
129
+ body: new Uint8Array(await response.arrayBuffer())
130
+ });
131
+ }
132
+ const files = [
133
+ ...sets.shell,
134
+ ...Object.values(iconFiles),
135
+ offlineCss,
136
+ offlineJs
137
+ ];
138
+ const entries = precacheEntries([
139
+ ...rendered,
140
+ {
141
+ url: OFFLINE_URL,
142
+ hashed: false,
143
+ body: offlineHtml
144
+ },
145
+ {
146
+ url: WEB_MANIFEST_URL,
147
+ hashed: false,
148
+ body: manifestJson
149
+ },
150
+ ...files.map((file) => ({
151
+ url: url(file),
152
+ hashed: true,
153
+ file
154
+ }))
155
+ ], (file) => existsSync(join(outDir, file)));
156
+ const worker = injectManifest(await bundleScript(script("sw", "sw"), { __JOYSTYK_RELEASE__: JSON.stringify(options.release) }), entries);
157
+ await write(join(outDir, "sw.js"), worker);
158
+ await write(join(outDir, "sw-remove.js"), await bundleScript(script("sw", "remove-sw")));
159
+ await write(join(outDir, "pwa-control.json"), `${JSON.stringify(DEFAULT_CONTROL, null, 2)}\n`);
160
+ const record = {
161
+ release: options.release,
162
+ releaseTime
163
+ };
164
+ await write(join(outDir, RELEASE_RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
165
+ logger?.info(`[pwa] sw.js ${(worker.length / 1024).toFixed(1)} kB for release ${options.release} (${releaseTime}):${String(entries.length)} precache entries; asset manifests for ${site.games.map((g) => g.id).join(", ") || "no games"}.`);
166
+ }
167
+ };
168
+ };
169
+ //#endregion
170
+ export { joystykPwa };
171
+
172
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","names":[],"sources":["../../src/build/plugin.ts"],"sourcesContent":["// The PWA build step, as a Vite plugin for the site's client build. After Vite writes the\n// client, it adds, from the built output:\n//\n// assets/icon-*.svg|png, assets/offline-*.js|css hashed, immutable (Rule 1)\n// manifest.webmanifest, offline.html served no-cache\n// games/<id>/manifest.json every file each game needs (Rule 1)\n// sw.js one classic script, precache list injected\n// sw-remove.js the emergency self-removing worker (Rule 5)\n// pwa-control.json the default flag file\n// release.json the release ID and time, for the server\n//\n// The worker is bundled from Workbox modules with Vite itself, so the build needs no other\n// bundler. Precache revisions hash the rendered output (the blog's lesson): the site renders\n// its shell pages at build time exactly as the server will, and the build fails if any\n// precache entry is missing.\nimport { existsSync, statSync } from 'node:fs';\nimport { mkdir, readFile, writeFile } from 'node:fs/promises';\nimport { dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { build, type Logger, type Plugin, type ResolvedConfig } from 'vite';\nimport * as z from 'zod/mini';\nimport { canonicalReleaseTime, DEFAULT_CONTROL, RELEASE_RECORD_FILE, type ReleaseRecord } from '../control.ts';\nimport { OFFLINE_URL, WEB_MANIFEST_URL } from '../names.ts';\nimport {\n contentHash,\n gameAssetManifest,\n injectManifest,\n offlinePage,\n precacheEntries,\n webManifest,\n type BuildGame,\n type PrecacheSource,\n} from './documents.ts';\nimport { fileSets, type GameSource } from './graph.ts';\nimport { iconPng, iconSvg } from './icons.ts';\n\n/** What the site supplies at build time, once the client build is on disk. */\nexport type SiteBuildInput = {\n /** The games this build lists: each gets an asset manifest. */\n readonly games: readonly BuildGame[];\n /**\n * Games whose code is in the bundle but which this build doesn't list, such as test games\n * outside test builds. Their code is kept out of the shell and gets no asset manifest.\n */\n readonly unlisted?: readonly GameSource[];\n /** Answers a request exactly as the production server will; used to hash the shell pages. */\n readonly render: (path: string) => Promise<Response>;\n /** Server-rendered paths to precache, such as '/' and '/favicon.svg'. */\n readonly shellPages: readonly string[];\n};\n\nexport type PwaPluginOptions = {\n /** The release ID baked into the worker (git SHA + date). */\n readonly release: string;\n /**\n * When the release's code was committed, as an RFC 3339 date-time. Releases are ordered by\n * it: the flag file's minimum release is one (control.ts).\n */\n readonly releaseTime: string;\n /** Loads the site's half, given the output directory. Called after the client is written. */\n readonly site: (context: { readonly outDir: string }) => Promise<SiteBuildInput>;\n};\n\nconst SOURCE = fileURLToPath(new URL('..', import.meta.url));\n\n// Workspace builds use TypeScript; installed tarballs carry the same modules as JS.\nconst script = (folder: string, name: string): string => {\n const source = join(SOURCE, folder, `${name}.ts`);\n return existsSync(source) ? source : join(SOURCE, folder, `${name}.js`);\n};\n\n/** Bundles one browser or worker script into a single minified classic script. */\nconst bundleScript = async (entry: string, define: Record<string, string> = {}): Promise<string> => {\n const result = await build({\n configFile: false,\n root: SOURCE,\n logLevel: 'warn',\n publicDir: false,\n define: { 'process.env.NODE_ENV': JSON.stringify('production'), ...define },\n build: {\n write: false,\n emptyOutDir: false,\n copyPublicDir: false,\n minify: true,\n target: 'es2022',\n sourcemap: false,\n reportCompressedSize: false,\n lib: { entry, formats: ['iife'], name: 'joystykPwa', fileName: () => 'bundle.js' },\n },\n });\n for (const output of Array.isArray(result) ? result : [result]) {\n if (!('output' in output)) continue;\n for (const item of output.output) if (item.type === 'chunk' && item.isEntry) return item.code;\n }\n throw new Error(`[pwa] Bundling ${entry} produced no script.`);\n};\n\nconst write = async (file: string, content: string | Uint8Array): Promise<void> => {\n await mkdir(dirname(file), { recursive: true });\n await writeFile(file, content);\n};\n\nconst manifestSchema = z.record(\n z.string(),\n z.object({\n file: z.string(),\n name: z.optional(z.string()),\n src: z.optional(z.string()),\n isEntry: z.optional(z.boolean()),\n isDynamicEntry: z.optional(z.boolean()),\n imports: z.optional(z.array(z.string())),\n dynamicImports: z.optional(z.array(z.string())),\n css: z.optional(z.array(z.string())),\n assets: z.optional(z.array(z.string())),\n }),\n);\n\n/** The plugin. Add it to the site's client build only. */\nexport const joystykPwa = (options: PwaPluginOptions): Plugin => {\n const releaseTime = canonicalReleaseTime(options.releaseTime);\n if (releaseTime === null)\n throw new Error(`[pwa] releaseTime \"${options.releaseTime}\" isn't a date-time such as 2026-10-09T14:03:12Z.`);\n let config: ResolvedConfig | undefined;\n let logger: Logger | undefined;\n return {\n name: 'joystyk-pwa',\n apply: 'build',\n enforce: 'post',\n configResolved(resolved) {\n config = resolved;\n logger = resolved.logger;\n },\n async writeBundle(output, bundle) {\n if (config === undefined || config.build.ssr !== false) return;\n const outDir = resolve(config.root, output.dir ?? config.build.outDir);\n const manifestPath = join(outDir, '.vite', 'manifest.json');\n if (!existsSync(manifestPath)) throw new Error('[pwa] The client build needs `build.manifest: true`.');\n const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')));\n const sources = new Map(\n Object.values(bundle)\n .filter((item) => item.type === 'chunk')\n .map((chunk) => [chunk.fileName, chunk.moduleIds]),\n );\n const site = await options.site({ outDir });\n\n // Hashed, immutable files beside Vite's own.\n const hashed = async (name: string, ext: string, content: string | Uint8Array): Promise<string> => {\n const file = `assets/${name}-${contentHash(content)}.${ext}`;\n await write(join(outDir, file), content);\n return file;\n };\n const iconFiles = {\n svg: await hashed('icon', 'svg', iconSvg()),\n png192: await hashed('icon-192', 'png', iconPng(192)),\n png512: await hashed('icon-512', 'png', iconPng(512)),\n maskable512: await hashed('icon-maskable-512', 'png', iconPng(512, true)),\n };\n const offlineCss = await hashed('offline', 'css', await readFile(join(SOURCE, 'offline', 'offline.css')));\n const offlineJs = await hashed('offline', 'js', await bundleScript(script('offline', 'offline')));\n const url = (file: string): string => `/${file}`;\n\n const manifestJson = `${JSON.stringify(webManifest({ svg: url(iconFiles.svg), png192: url(iconFiles.png192), png512: url(iconFiles.png512), maskable512: url(iconFiles.maskable512) }), null, 2)}\\n`;\n await write(join(outDir, 'manifest.webmanifest'), manifestJson);\n const offlineHtml = offlinePage({ css: url(offlineCss), js: url(offlineJs), icon: url(iconFiles.svg) });\n await write(join(outDir, 'offline.html'), offlineHtml);\n\n // Each game's asset manifest.\n const sets = fileSets(manifest, site.games, config.root, site.unlisted ?? [], sources);\n const sizeOf = (file: string): number => statSync(join(outDir, file)).size;\n for (const game of site.games) {\n const assets = gameAssetManifest(\n game,\n options.release,\n sets.shell,\n sets.games.get(game.id) ?? new Set(),\n sizeOf,\n );\n if (!game.assets.startsWith('/') || game.assets.includes('..'))\n throw new Error(`[pwa] ${game.id}: assets \"${game.assets}\" must be a site path.`);\n await write(join(outDir, game.assets), `${JSON.stringify(assets, null, 2)}\\n`);\n }\n\n // Tier 1: the shell's code, the icons, the offline page and the rendered shell pages.\n const rendered: PrecacheSource[] = [];\n for (const path of site.shellPages) {\n const response = await site.render(path);\n if (response.status !== 200)\n throw new Error(`[pwa] Precaching ${path}: the server answers ${String(response.status)}, not 200.`);\n rendered.push({ url: path, hashed: false, body: new Uint8Array(await response.arrayBuffer()) });\n }\n const files = [...sets.shell, ...Object.values(iconFiles), offlineCss, offlineJs];\n const entries = precacheEntries(\n [\n ...rendered,\n { url: OFFLINE_URL, hashed: false, body: offlineHtml },\n { url: WEB_MANIFEST_URL, hashed: false, body: manifestJson },\n ...files.map((file): PrecacheSource => ({ url: url(file), hashed: true, file })),\n ],\n (file) => existsSync(join(outDir, file)),\n );\n\n const worker = injectManifest(\n await bundleScript(script('sw', 'sw'), { __JOYSTYK_RELEASE__: JSON.stringify(options.release) }),\n entries,\n );\n await write(join(outDir, 'sw.js'), worker);\n await write(join(outDir, 'sw-remove.js'), await bundleScript(script('sw', 'remove-sw')));\n await write(join(outDir, 'pwa-control.json'), `${JSON.stringify(DEFAULT_CONTROL, null, 2)}\\n`);\n const record: ReleaseRecord = { release: options.release, releaseTime };\n await write(join(outDir, RELEASE_RECORD_FILE), `${JSON.stringify(record, null, 2)}\\n`);\n logger?.info(\n `[pwa] sw.js ${(worker.length / 1024).toFixed(1)} kB for release ${options.release} (${releaseTime}):${String(entries.length)} precache entries; asset manifests for ${site.games.map((g) => g.id).join(', ') || 'no games'}.`,\n );\n },\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export declare const PIN_CHANGED = "joystyk:pin-changed";
2
+ export type AvailabilityPorts = {
3
+ readonly root: Document;
4
+ readonly online: () => boolean;
5
+ readonly pins: () => Promise<readonly {
6
+ readonly id: string;
7
+ }[]>;
8
+ };
9
+ /** Keep catalog labels accurate across SPA navigation and pin changes. One cache read per
10
+ * refresh serves the entire listing. Unavailable links explain their state and do not
11
+ * navigate to a broken game while offline. */
12
+ export declare const watchOfflineAvailability: (ports?: AvailabilityPorts) => (() => void);
@@ -0,0 +1,94 @@
1
+ import { pinnedGames } from "./pinned.js";
2
+ import { isGameId } from "@joystyk/contracts";
3
+ //#region src/client/availability.ts
4
+ var PIN_CHANGED = "joystyk:pin-changed";
5
+ /** Keep catalog labels accurate across SPA navigation and pin changes. One cache read per
6
+ * refresh serves the entire listing. Unavailable links explain their state and do not
7
+ * navigate to a broken game while offline. */
8
+ var watchOfflineAvailability = (ports = {
9
+ root: document,
10
+ online: () => navigator.onLine,
11
+ pins: () => pinnedGames()
12
+ }) => {
13
+ let disposed = false;
14
+ let generation = 0;
15
+ let scheduled = false;
16
+ const roots = () => {
17
+ const found = [];
18
+ const visit = (root) => {
19
+ found.push(root);
20
+ for (const element of root.querySelectorAll("*")) if (element.shadowRoot !== null) visit(element.shadowRoot);
21
+ };
22
+ visit(ports.root);
23
+ return found;
24
+ };
25
+ const refresh = async () => {
26
+ const current = ++generation;
27
+ const ids = new Set((await ports.pins().catch(() => [])).map((pin) => pin.id));
28
+ if (disposed || current !== generation) return;
29
+ const offline = !ports.online();
30
+ const surfaces = roots();
31
+ observer.disconnect();
32
+ for (const surface of surfaces) observer.observe(surface, {
33
+ childList: true,
34
+ subtree: true
35
+ });
36
+ for (const label of surfaces.flatMap((surface) => [...surface.querySelectorAll("jy-offline-availability[game-id]")])) {
37
+ const id = label.getAttribute("game-id");
38
+ if (!isGameId(id)) continue;
39
+ const pinned = ids.has(id);
40
+ const message = pinned ? "Available offline" : offline ? "Unavailable offline" : "";
41
+ if (label.textContent !== message) label.textContent = message;
42
+ label.hidden = message === "";
43
+ label.dataset["available"] = String(pinned);
44
+ const link = (label.closest("article, li, tr") ?? label.parentElement)?.querySelector(`a[href="/games/${id}"]`);
45
+ if (link !== null && link !== void 0) {
46
+ if (offline && !pinned) link.setAttribute("aria-disabled", "true");
47
+ else link.removeAttribute("aria-disabled");
48
+ }
49
+ }
50
+ };
51
+ const schedule = () => {
52
+ if (disposed || scheduled) return;
53
+ scheduled = true;
54
+ queueMicrotask(() => {
55
+ scheduled = false;
56
+ if (!disposed) refresh();
57
+ });
58
+ };
59
+ const onClick = (event) => {
60
+ if (event.composedPath().find((target) => target instanceof Element && target.matches("a[aria-disabled=\"true\"]")) !== void 0 && !ports.online()) {
61
+ event.preventDefault();
62
+ event.stopImmediatePropagation();
63
+ }
64
+ };
65
+ const observer = new MutationObserver((changes) => {
66
+ if (changes.some((change) => [...change.addedNodes].some((node) => node instanceof Element))) schedule();
67
+ });
68
+ observer.observe(ports.root, {
69
+ childList: true,
70
+ subtree: true
71
+ });
72
+ for (const name of [
73
+ "online",
74
+ "offline",
75
+ PIN_CHANGED
76
+ ]) addEventListener(name, schedule);
77
+ ports.root.addEventListener("click", onClick, true);
78
+ schedule();
79
+ return () => {
80
+ disposed = true;
81
+ generation += 1;
82
+ observer.disconnect();
83
+ for (const name of [
84
+ "online",
85
+ "offline",
86
+ PIN_CHANGED
87
+ ]) removeEventListener(name, schedule);
88
+ ports.root.removeEventListener("click", onClick, true);
89
+ };
90
+ };
91
+ //#endregion
92
+ export { PIN_CHANGED, watchOfflineAvailability };
93
+
94
+ //# sourceMappingURL=availability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"availability.js","names":[],"sources":["../../src/client/availability.ts"],"sourcesContent":["import { isGameId } from '@joystyk/contracts';\nimport { pinnedGames } from './pinned.ts';\n\nexport const PIN_CHANGED = 'joystyk:pin-changed';\nexport type AvailabilityPorts = {\n readonly root: Document;\n readonly online: () => boolean;\n readonly pins: () => Promise<readonly { readonly id: string }[]>;\n};\n\n/** Keep catalog labels accurate across SPA navigation and pin changes. One cache read per\n * refresh serves the entire listing. Unavailable links explain their state and do not\n * navigate to a broken game while offline. */\nexport const watchOfflineAvailability = (\n ports: AvailabilityPorts = {\n root: document,\n online: () => navigator.onLine,\n pins: () => pinnedGames(),\n },\n): (() => void) => {\n let disposed = false;\n let generation = 0;\n let scheduled = false;\n const roots = (): (Document | ShadowRoot)[] => {\n const found: (Document | ShadowRoot)[] = [];\n const visit = (root: Document | ShadowRoot): void => {\n found.push(root);\n for (const element of root.querySelectorAll('*')) if (element.shadowRoot !== null) visit(element.shadowRoot);\n };\n visit(ports.root);\n return found;\n };\n const refresh = async (): Promise<void> => {\n const current = ++generation;\n const ids = new Set((await ports.pins().catch(() => [])).map((pin) => pin.id));\n if (disposed || current !== generation) return;\n const offline = !ports.online();\n const surfaces = roots();\n observer.disconnect();\n for (const surface of surfaces) observer.observe(surface, { childList: true, subtree: true });\n for (const label of surfaces.flatMap((surface) => [\n ...surface.querySelectorAll<HTMLElement>('jy-offline-availability[game-id]'),\n ])) {\n const id = label.getAttribute('game-id');\n if (!isGameId(id)) continue;\n const pinned = ids.has(id);\n const message = pinned ? 'Available offline' : offline ? 'Unavailable offline' : '';\n if (label.textContent !== message) label.textContent = message;\n label.hidden = message === '';\n label.dataset['available'] = String(pinned);\n const scope = label.closest('article, li, tr') ?? label.parentElement;\n const link = scope?.querySelector<HTMLAnchorElement>(`a[href=\"/games/${id}\"]`);\n if (link !== null && link !== undefined) {\n if (offline && !pinned) link.setAttribute('aria-disabled', 'true');\n else link.removeAttribute('aria-disabled');\n }\n }\n };\n const schedule = (): void => {\n if (disposed || scheduled) return;\n scheduled = true;\n queueMicrotask(() => {\n scheduled = false;\n if (!disposed) void refresh();\n });\n };\n const onClick = (event: Event): void => {\n const link = event\n .composedPath()\n .find((target) => target instanceof Element && target.matches('a[aria-disabled=\"true\"]'));\n if (link !== undefined && !ports.online()) {\n event.preventDefault();\n event.stopImmediatePropagation();\n }\n };\n const observer = new MutationObserver((changes) => {\n // A newly mounted custom element may hold its labels inside a shadow root;\n // querySelector on the added host cannot see them. Text-only label updates\n // do not schedule another refresh.\n if (changes.some((change) => [...change.addedNodes].some((node) => node instanceof Element))) schedule();\n });\n observer.observe(ports.root, { childList: true, subtree: true });\n for (const name of ['online', 'offline', PIN_CHANGED]) addEventListener(name, schedule);\n ports.root.addEventListener('click', onClick, true);\n schedule();\n return () => {\n disposed = true;\n generation += 1;\n observer.disconnect();\n for (const name of ['online', 'offline', PIN_CHANGED]) removeEventListener(name, schedule);\n ports.root.removeEventListener('click', onClick, true);\n };\n};\n"],"mappings":";;;AAGA,IAAa,cAAc;;;;AAU3B,IAAa,4BACX,QAA2B;CACzB,MAAM;CACN,cAAc,UAAU;CACxB,YAAY,YAAY;AAC1B,MACiB;CACjB,IAAI,WAAW;CACf,IAAI,aAAa;CACjB,IAAI,YAAY;CAChB,MAAM,cAAyC;EAC7C,MAAM,QAAmC,CAAC;EAC1C,MAAM,SAAS,SAAsC;GACnD,MAAM,KAAK,IAAI;GACf,KAAK,MAAM,WAAW,KAAK,iBAAiB,GAAG,GAAG,IAAI,QAAQ,eAAe,MAAM,MAAM,QAAQ,UAAU;EAC7G;EACA,MAAM,MAAM,IAAI;EAChB,OAAO;CACT;CACA,MAAM,UAAU,YAA2B;EACzC,MAAM,UAAU,EAAE;EAClB,MAAM,MAAM,IAAI,KAAK,MAAM,MAAM,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,EAAA,CAAG,KAAK,QAAQ,IAAI,EAAE,CAAC;EAC7E,IAAI,YAAY,YAAY,YAAY;EACxC,MAAM,UAAU,CAAC,MAAM,OAAO;EAC9B,MAAM,WAAW,MAAM;EACvB,SAAS,WAAW;EACpB,KAAK,MAAM,WAAW,UAAU,SAAS,QAAQ,SAAS;GAAE,WAAW;GAAM,SAAS;EAAK,CAAC;EAC5F,KAAK,MAAM,SAAS,SAAS,SAAS,YAAY,CAChD,GAAG,QAAQ,iBAA8B,kCAAkC,CAC7E,CAAC,GAAG;GACF,MAAM,KAAK,MAAM,aAAa,SAAS;GACvC,IAAI,CAAC,SAAS,EAAE,GAAG;GACnB,MAAM,SAAS,IAAI,IAAI,EAAE;GACzB,MAAM,UAAU,SAAS,sBAAsB,UAAU,wBAAwB;GACjF,IAAI,MAAM,gBAAgB,SAAS,MAAM,cAAc;GACvD,MAAM,SAAS,YAAY;GAC3B,MAAM,QAAQ,eAAe,OAAO,MAAM;GAE1C,MAAM,QADQ,MAAM,QAAQ,iBAAiB,KAAK,MAAM,cAAA,EACpC,cAAiC,kBAAkB,GAAG,GAAG;GAC7E,IAAI,SAAS,QAAQ,SAAS,KAAA,GAAW;IACvC,IAAI,WAAW,CAAC,QAAQ,KAAK,aAAa,iBAAiB,MAAM;SAC5D,KAAK,gBAAgB,eAAe;GAC3C;EACF;CACF;CACA,MAAM,iBAAuB;EAC3B,IAAI,YAAY,WAAW;EAC3B,YAAY;EACZ,qBAAqB;GACnB,YAAY;GACZ,IAAI,CAAC,UAAU,QAAa;EAC9B,CAAC;CACH;CACA,MAAM,WAAW,UAAuB;EAItC,IAHa,MACV,aAAa,CAAC,CACd,MAAM,WAAW,kBAAkB,WAAW,OAAO,QAAQ,2BAAyB,CACrF,MAAS,KAAA,KAAa,CAAC,MAAM,OAAO,GAAG;GACzC,MAAM,eAAe;GACrB,MAAM,yBAAyB;EACjC;CACF;CACA,MAAM,WAAW,IAAI,kBAAkB,YAAY;EAIjD,IAAI,QAAQ,MAAM,WAAW,CAAC,GAAG,OAAO,UAAU,CAAC,CAAC,MAAM,SAAS,gBAAgB,OAAO,CAAC,GAAG,SAAS;CACzG,CAAC;CACD,SAAS,QAAQ,MAAM,MAAM;EAAE,WAAW;EAAM,SAAS;CAAK,CAAC;CAC/D,KAAK,MAAM,QAAQ;EAAC;EAAU;EAAW;CAAW,GAAG,iBAAiB,MAAM,QAAQ;CACtF,MAAM,KAAK,iBAAiB,SAAS,SAAS,IAAI;CAClD,SAAS;CACT,aAAa;EACX,WAAW;EACX,cAAc;EACd,SAAS,WAAW;EACpB,KAAK,MAAM,QAAQ;GAAC;GAAU;GAAW;EAAW,GAAG,oBAAoB,MAAM,QAAQ;EACzF,MAAM,KAAK,oBAAoB,SAAS,SAAS,IAAI;CACvD;AACF"}
@@ -0,0 +1,3 @@
1
+ import { type Control } from '../control.js';
2
+ /** Reads the flag file. A failure (offline, a bad file) reads as no instruction. */
3
+ export declare const readControl: () => Promise<Control | null>;
@@ -0,0 +1,19 @@
1
+ import { CONTROL_URL } from "../names.js";
2
+ import { parseControl } from "../control.js";
3
+ //#region src/client/control.ts
4
+ /** Reads the flag file. A failure (offline, a bad file) reads as no instruction. */
5
+ var readControl = async () => {
6
+ try {
7
+ const response = await fetch(CONTROL_URL, {
8
+ cache: "no-store",
9
+ credentials: "omit"
10
+ });
11
+ return response.ok ? parseControl(await response.json()) : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+ //#endregion
17
+ export { readControl };
18
+
19
+ //# sourceMappingURL=control.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control.js","names":[],"sources":["../../src/client/control.ts"],"sourcesContent":["// The page reads the flag file (../control.ts) straight from the network: the worker never\n// caches it, and the request skips the HTTP cache too.\nimport { parseControl, type Control } from '../control.ts';\nimport { CONTROL_URL } from '../names.ts';\n\n/** Reads the flag file. A failure (offline, a bad file) reads as no instruction. */\nexport const readControl = async (): Promise<Control | null> => {\n try {\n const response = await fetch(CONTROL_URL, { cache: 'no-store', credentials: 'omit' });\n return response.ok ? parseControl(await response.json()) : null;\n } catch {\n return null;\n }\n};\n"],"mappings":";;;;AAMA,IAAa,cAAc,YAAqC;CAC9D,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,aAAa;GAAE,OAAO;GAAY,aAAa;EAAO,CAAC;EACpF,OAAO,SAAS,KAAK,aAAa,MAAM,SAAS,KAAK,CAAC,IAAI;CAC7D,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,45 @@
1
+ import { type PinPorts } from './pins.js';
2
+ /** `<jy-update-toast>`: says an update is ready and offers Refresh or Later. */
3
+ export declare class UpdateToast extends HTMLElement {
4
+ #private;
5
+ /** Called with 'refresh' or 'later'. */
6
+ onChoice: (choice: 'refresh' | 'later') => void;
7
+ constructor();
8
+ }
9
+ /**
10
+ * `<jy-security-update>`: the forced update's notice (offline-and-updates.md, Rule 2). A
11
+ * modal dialog that takes focus and blocks the page: it gives the reason and one action,
12
+ * Update now. Escape doesn't close it; the page reloads instead.
13
+ */
14
+ export declare class SecurityUpdate extends HTMLElement {
15
+ #private;
16
+ /** Called once, when the player chooses Update now. */
17
+ onUpdate: () => void;
18
+ constructor();
19
+ connectedCallback(): void;
20
+ }
21
+ /**
22
+ * `<jy-offline-toggle game-id="nim" game-name="Nim" assets="/games/nim/manifest.json">`: a
23
+ * toggle button that pins the game for offline play or removes it. It stays empty (its box
24
+ * reserved by the page's stylesheet) until a service worker is active, so a player never
25
+ * sees a control that can't work.
26
+ */
27
+ export declare class OfflineToggle extends HTMLElement {
28
+ #private;
29
+ static readonly observedAttributes: string[];
30
+ /** Swapped in tests. */
31
+ ports: () => PinPorts;
32
+ constructor();
33
+ connectedCallback(): void;
34
+ disconnectedCallback(): void;
35
+ attributeChangedCallback(): void;
36
+ }
37
+ /** Registers the elements once. */
38
+ export declare const definePwaElements: () => void;
39
+ declare global {
40
+ interface HTMLElementTagNameMap {
41
+ 'jy-update-toast': UpdateToast;
42
+ 'jy-security-update': SecurityUpdate;
43
+ 'jy-offline-toggle': OfflineToggle;
44
+ }
45
+ }