@joystyk/pwa 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +215 -2
  3. package/dist/asset-manifest.d.ts +76 -0
  4. package/dist/asset-manifest.js +80 -0
  5. package/dist/asset-manifest.js.map +1 -0
  6. package/dist/build/documents.d.ts +79 -0
  7. package/dist/build/documents.js +135 -0
  8. package/dist/build/documents.js.map +1 -0
  9. package/dist/build/graph.d.ts +39 -0
  10. package/dist/build/graph.js +70 -0
  11. package/dist/build/graph.js.map +1 -0
  12. package/dist/build/icons.d.ts +30 -0
  13. package/dist/build/icons.js +192 -0
  14. package/dist/build/icons.js.map +1 -0
  15. package/dist/build/index.d.ts +4 -0
  16. package/dist/build/index.js +5 -0
  17. package/dist/build/plugin.d.ts +32 -0
  18. package/dist/build/plugin.js +172 -0
  19. package/dist/build/plugin.js.map +1 -0
  20. package/dist/client/availability.d.ts +12 -0
  21. package/dist/client/availability.js +94 -0
  22. package/dist/client/availability.js.map +1 -0
  23. package/dist/client/control.d.ts +3 -0
  24. package/dist/client/control.js +19 -0
  25. package/dist/client/control.js.map +1 -0
  26. package/dist/client/elements.d.ts +45 -0
  27. package/dist/client/elements.js +319 -0
  28. package/dist/client/elements.js.map +1 -0
  29. package/dist/client/forced.d.ts +33 -0
  30. package/dist/client/forced.js +57 -0
  31. package/dist/client/forced.js.map +1 -0
  32. package/dist/client/index.d.ts +7 -0
  33. package/dist/client/index.js +8 -0
  34. package/dist/client/pinned.d.ts +13 -0
  35. package/dist/client/pinned.js +29 -0
  36. package/dist/client/pinned.js.map +1 -0
  37. package/dist/client/pins.d.ts +43 -0
  38. package/dist/client/pins.js +140 -0
  39. package/dist/client/pins.js.map +1 -0
  40. package/dist/client/register.d.ts +64 -0
  41. package/dist/client/register.js +255 -0
  42. package/dist/client/register.js.map +1 -0
  43. package/dist/control.d.ts +46 -0
  44. package/dist/control.js +88 -0
  45. package/dist/control.js.map +1 -0
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.js +8 -0
  48. package/dist/names.d.ts +47 -0
  49. package/dist/names.js +64 -0
  50. package/dist/names.js.map +1 -0
  51. package/dist/offline/offline.css +94 -0
  52. package/dist/offline/offline.d.ts +1 -0
  53. package/dist/offline/offline.js +26 -0
  54. package/dist/offline/offline.js.map +1 -0
  55. package/dist/offline-mark.d.ts +15 -0
  56. package/dist/offline-mark.js +51 -0
  57. package/dist/offline-mark.js.map +1 -0
  58. package/dist/pin-record.d.ts +14 -0
  59. package/dist/pin-record.js +36 -0
  60. package/dist/pin-record.js.map +1 -0
  61. package/dist/preloads.d.ts +2 -0
  62. package/dist/preloads.js +26 -0
  63. package/dist/preloads.js.map +1 -0
  64. package/dist/routing.d.ts +57 -0
  65. package/dist/routing.js +81 -0
  66. package/dist/routing.js.map +1 -0
  67. package/dist/sw/remove-sw.d.ts +1 -0
  68. package/dist/sw/remove-sw.js +16 -0
  69. package/dist/sw/remove-sw.js.map +1 -0
  70. package/dist/sw/sw.d.ts +1 -0
  71. package/dist/sw/sw.js +165 -0
  72. package/dist/sw/sw.js.map +1 -0
  73. package/dist/validate.d.ts +8 -0
  74. package/dist/validate.js +24 -0
  75. package/dist/validate.js.map +1 -0
  76. package/package.json +65 -11
package/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # @joystyk/pwa
2
+
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [bb095c7]
8
+ - Updated dependencies [bb095c7]
9
+ - @joystyk/contracts@0.2.0
10
+ - @joystyk/storage@0.2.0
11
+ - @joystyk/daily@0.2.0
12
+
13
+ ## 0.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies
22
+ - @joystyk/contracts@0.1.0
23
+ - @joystyk/storage@0.1.0
24
+ - @joystyk/daily@0.1.0
package/README.md CHANGED
@@ -1,5 +1,218 @@
1
1
  # @joystyk/pwa
2
2
 
3
- The first Joystyk library release is not yet available. This placeholder reserves the package name.
3
+ The installable, offline-capable Joystyk (host policy). A service worker built from Workbox keeps
4
+ the site's shell, recently played games and the games a player pins; the page offers updates
5
+ only where nothing can be lost; a flag file can switch the whole thing off. The design is
6
+ offline-and-updates.md (Joystyk host reference); the blog's worker
7
+ (pwa-offline.md (Joystyk host reference)) is the model.
4
8
 
5
- Source: https://github.com/joystyk-games/game-libraries
9
+ ## npm package
10
+
11
+ The first `0.1.0` release is being prepared; it has not been published. Once available:
12
+
13
+ ```sh
14
+ npm install @joystyk/pwa
15
+ ```
16
+
17
+ Node consumers require Node 24.15 or later. Browser entry points require a modern
18
+ browser and should be loaded in the browser. The build entry uses Vite 8.3.3, declared
19
+ as a peer dependency.
20
+
21
+ | Import | Format |
22
+ | --- | --- |
23
+ | `@joystyk/pwa` | ES module and TypeScript declarations |
24
+ | `@joystyk/pwa/client` | ES module and TypeScript declarations |
25
+ | `@joystyk/pwa/build` | ES module and TypeScript declarations |
26
+
27
+ Only the entries listed above are public API. Installed packages contain compiled ESM,
28
+ type declarations and runtime assets. Repository-relative links describe the Joystyk
29
+ workspace and are not needed to use the npm package.
30
+
31
+ ## Entry points
32
+
33
+ | Import | Runs in | Contents |
34
+ | --- | --- | --- |
35
+ | `@joystyk/pwa` | Anywhere | Cache names, routing decisions, the asset-manifest, pin-record and flag-file formats |
36
+ | `@joystyk/pwa/client` | Browser page | `registerPwa`, `pinGame`, `unpinGame`, `pinnedGames`, `refreshPinnedGames`, `<jy-offline-toggle>`, `<jy-update-toast>`, `<jy-security-update>` |
37
+ | `@joystyk/pwa/build` | Node, at build time | `joystykPwa`, the Vite plugin that writes `sw.js` and the manifests |
38
+
39
+ The worker's source is `src/sw/sw.ts`. It is never imported: the build bundles it into one
40
+ classic `sw.js`. `tsconfig.sw.json` type-checks it with the worker's globals instead of the DOM.
41
+
42
+ Workbox (`workbox-core`, `-precaching`, `-routing`, `-strategies`, `-expiration`, pinned to
43
+ one exact version in `pnpm-workspace.yaml`) is the one non-Gyral runtime dependency here,
44
+ as host policy and the blog notes allow. It runs only inside the worker.
45
+
46
+ ## Use
47
+
48
+ The site's client build adds the plugin, and the browser entry registers the worker in
49
+ production builds (`apps/site/vite.config.ts` and `src/entry-client.ts` do both):
50
+
51
+ ```ts
52
+ // vite.config.ts
53
+ import { joystykPwa } from '@joystyk/pwa/build';
54
+
55
+ plugins: [joystykPwa({ release, releaseTime, site: ({ outDir }) => pwaBuildInput({ clientDir: outDir }) })];
56
+
57
+ // entry-client.ts
58
+ import { requestSave, watchSafePoints } from '@joystyk/launcher/browser';
59
+ import { registerPwa } from '@joystyk/pwa/client';
60
+
61
+ registerPwa({
62
+ release: __JOYSTYK_RELEASE__,
63
+ releaseTime: __JOYSTYK_RELEASE_TIME__,
64
+ watchSafePoints,
65
+ saveGame: requestSave,
66
+ daily: [/* daily content clients */],
67
+ });
68
+ ```
69
+
70
+ `site` gives the build the games it ships (id, versions, the folder holding the game's code,
71
+ its asset-manifest path and page) and a `render(path)` that answers exactly as the production
72
+ server will. `unlisted` names games whose code is bundled but not listed (the site's test
73
+ games outside test builds); their code never enters the precache. The site serves the files
74
+ the build writes (see below), all `no-cache` except the hashed ones.
75
+
76
+ A game page renders `<jy-offline-toggle game-id="nim" game-name="Nim" assets="/games/nim/manifest.json">`;
77
+ the catalog does this for every playable game and reserves its box, so nothing shifts when
78
+ the control appears.
79
+
80
+ Catalog cards and lineup rows include `<jy-offline-availability game-id="nim" hidden>`.
81
+ The client reads completed pins, updates labels after pin changes and navigation, and
82
+ marks unpinned game links unavailable while offline. Completed pins read “Available
83
+ offline”; unpinned games read “Unavailable offline” when the network is down.
84
+ For a cached offline page, the worker renders those labels from completed pin records
85
+ before first paint, keeping catalog cards and table rows stable through hydration.
86
+
87
+ ## What the build writes
88
+
89
+ After Vite writes the client build, the plugin reads `.vite/manifest.json` and adds:
90
+
91
+ | File | Served | What it is |
92
+ | --- | --- | --- |
93
+ | `sw.js` | `/sw.js`, no-cache | The worker, one classic script, with the precache list and the release ID injected |
94
+ | `games/<id>/manifest.json` | the game manifest's `assets`, no-cache | Every file the game's page needs to play: the shell's and the game's own, with sizes, kinds, the game's build, rules and save versions, and the release |
95
+ | `manifest.webmanifest` | `/manifest.webmanifest`, no-cache | Name, colors and icons for installing Joystyk |
96
+ | `offline.html` | `/offline`, no-cache | The offline page; its script lists the games this device can play offline |
97
+ | `assets/icon-*.svg`, `assets/icon-*.png` | `/assets/…`, immutable | The joystick icon: SVG, 192 and 512 pixel PNGs and a maskable 512, drawn by the build (no image library) |
98
+ | `assets/offline-*.js`, `assets/offline-*.css` | `/assets/…`, immutable | The offline page's script and styles (no inline code: the site's CSP) |
99
+ | `pwa-control.json` | `/pwa-control.json`, no-cache | The default flag file: `{ "schemaVersion": 1, "serviceWorker": "enabled" }` |
100
+ | `sw-remove.js` | `/sw.js` while the kill switch is on | The emergency worker: it removes itself and the PWA's caches |
101
+ | `release.json` | Never | The release ID and release time; the site reads it to cap the minimum release it serves |
102
+
103
+ **Which files a game needs.** The shell is everything the site's entry reaches, including
104
+ the launcher's lazily loaded stage, storage and identity code, but no game: a dynamic import
105
+ whose source lies in a game's folder starts that game. A game needs the shell plus everything
106
+ its own dynamic entries reach. Static imports are always followed, even into another game's
107
+ chunk, because the bundler may put shared code there.
108
+
109
+ A file the bundler emits for a module (a worker named with `new Worker(new URL(…))`, a font)
110
+ is an asset of the chunk holding that module, so it belongs to whoever reaches that chunk. The
111
+ site therefore imports a game's job worker factory lazily (`apps/site/src/games.ts`): Greed's
112
+ 4 MB physics worker is then Greed's own file, not part of the shell that every visitor
113
+ precaches and every pin copies. The build fails if the shell reaches any worker script.
114
+
115
+ **The precache list** holds the shell's files, the icons, the offline page and its files, the
116
+ web app manifest, and the server-rendered `/` and `/favicon.svg`. Hashed files carry no
117
+ revision; the rest carry a hash of their rendered bytes, so a revision changes exactly when
118
+ what is served changes. The build fails if any entry is missing, because one bad entry would
119
+ fail the worker's install for every visitor.
120
+
121
+ ## The cache tiers
122
+
123
+ | Tier | Cache | Holds | Eviction |
124
+ | --- | --- | --- | --- |
125
+ | 1 | `joystyk-shell-v1-<scope>` (Workbox) | The precache list | Replaced each release; Workbox removes outdated entries on activation |
126
+ | 2 | `recent:code:v1`, `recent:art:v1`, `recent:audio:v1`, `recent:wasm:v1` | Hashed files of games played but not pinned | Capped per kind (`RECENT_LIMITS`), least recently used first, 30 days, `purgeOnQuotaError` |
127
+ | 3 | `game:<id>:<version>` | A pinned game's whole build, its page and catalog data, and the pin's record | Never evicted by the worker; refreshed and replaced at safe points |
128
+
129
+ How the worker answers (`routeFor` in `src/routing.ts`):
130
+
131
+ - **Pages** go to the network first. If it fails (or takes over 8 seconds while a copy
132
+ exists), a pinned copy of the page, then the precached shell, then the offline page. A
133
+ page answered from a cache gets `data-offline` on its `<html>` tag (`src/offline-mark.ts`),
134
+ so its CSS reserves no ad space at first paint.
135
+ Worker-controlled HTML also removes `modulepreload` links before the browser parses
136
+ it, avoiding duplicate fetches across the page and worker. Initial network responses
137
+ keep the server's preload hints. Module scripts and CSP hashes remain intact.
138
+ The site disables Vite's runtime JavaScript preload hints to prevent lazy imports
139
+ from adding them back; stylesheet dependency loading stays enabled.
140
+ - **Catalog data** (`/api/games…`) goes to the network first, then to a pinned copy.
141
+ - **Hashed files** come from the precache, then the pins, then their kind's Tier 2 cache
142
+ (stale-while-revalidate; the revalidating request is answered by the browser's HTTP cache,
143
+ since the files are immutable). Each strategy's background work is handed to the fetch
144
+ event's `waitUntil`, the blog's fix that keeps the caps holding.
145
+ - `/sw.js`, `/pwa-control.json`, `/healthz`, other origins and other methods are never cached.
146
+
147
+ ## Pinning
148
+
149
+ `pinGame({ gameId, assets })` reads the asset manifest, fills `game:<id>:<version>` with every
150
+ file, then the page and its data, then the pin's record. It checks that the page names no
151
+ hashed file the manifest lacks (a deploy mid-pin), and tries once more if so. Only after the
152
+ new build is complete does it drop stale files and older versions. The first pin asks for
153
+ persistent storage (`@joystyk/storage/browser`). Errors come back as a `PinError` with a
154
+ player-facing message: offline, out of space, or not available right now.
155
+
156
+ A pin is self-sufficient: it holds the shell files its page names, so a pinned game still
157
+ plays offline after a worker update replaced the precache. `refreshPinnedGames` brings every
158
+ pin to the server's current build; `registerPwa` runs it at safe points while online.
159
+
160
+ ## Updates
161
+
162
+ - The worker never activates itself. `registerPwa` watches the registration; when a new
163
+ worker is installed and waiting, it shows "Update ready" **only while the launcher reports a
164
+ safe point** (`joystyk:safe-point`, through `watchSafePoints`), and hides it when play
165
+ starts. Refresh posts `SKIP_WAITING`; the page reloads on `controllerchange`.
166
+ - If another tab accepted the update, this page runs the old release under the new worker
167
+ (safe: hashed files outlive their release). It offers a reload at its next safe point and
168
+ never reloads by itself.
169
+ - Checks: every 45 minutes, when the tab becomes visible, and on each navigation to the index.
170
+ - **Forced security updates** (`forced.ts`). Given `releaseTime`, the page reads the flag
171
+ file at load, every 15 minutes, when the tab becomes visible and on each navigation to the
172
+ index. If its `minimumRelease` is later than this release, `<jy-security-update>` opens: a
173
+ modal dialog with the reason and one action, Update now, which Escape can't close.
174
+ `saveGame` (the launcher's `requestSave`) runs at once and pauses play; Update now waits for
175
+ the save, brings in the new worker when it can (a few seconds at most) and reloads. A failed
176
+ read changes nothing, so offline play goes on. The design is
177
+ Forced security updates (Joystyk host reference).
178
+ - At safe points while online, at most every 30 minutes: refresh pins, then prefetch daily
179
+ puzzles (today and tomorrow, host policy) for every daily client in an installed app, or for the
180
+ pinned ones in a browser tab. The site passes no daily clients yet; no daily game exists.
181
+
182
+ ## The kill switch
183
+
184
+ The site serves `/pwa-control.json` from `PWA_CONTROL_FILE` (default: the build's own file).
185
+ Setting `"serviceWorker": "disabled"`:
186
+
187
+ - a page reads it before registering, unregisters every worker and deletes the PWA's caches;
188
+ - a worker reads it on install (and refuses to install), on activation, and on navigations
189
+ (at most every five minutes), then unregisters itself and deletes the PWA's caches.
190
+ - the site serves `sw-remove.js` as `/sw.js`, so the browser's own update check replaces even
191
+ a worker too broken to read the flag with one that removes itself and its caches.
192
+
193
+ IndexedDB (saves, stats, preferences) is never touched. A missing or unreadable flag file
194
+ changes nothing, so a broken file can't wipe every player's offline games.
195
+
196
+ The same file's optional `minimumRelease` forces older pages to update (see Updates above).
197
+ The site serves a minimum later than its own release as its own release's time.
198
+
199
+ ## Tests
200
+
201
+ - `src/*.test.ts` and `src/build/*.test.ts` (Node): routing decisions and cache names, the
202
+ offline mark on cached pages, the update-prompt rule, asset-manifest decoding and pin planning, the flag file (release-time
203
+ comparison, the minimum release and its cap) and pin records, the shell/game split over a Vite manifest, the asset manifest, precache list and
204
+ injection the build writes, the web app manifest, the offline page, and the PNG encoder and
205
+ icon drawing.
206
+ - `src/client/*.browser.test.ts` (Chromium): pinning against real Cache Storage with a fake
207
+ server (first pin, refresh in place, a version switch, a page from a newer deploy, offline
208
+ and bad manifests, unpinning), the elements, and the forced update's order: the notice,
209
+ the save, then the reload, never before the save.
210
+ - `apps/site/src/pwa.e2e.test.ts`: two real releases on the production server. With the
211
+ network off, the shell loads, a pinned Nim plays to the end and an unpinned game shows the
212
+ offline page; the index served offline reserves no ad space and shifts nothing, and its
213
+ slots return with the connection; a pinned Greed throws its dice in the physics worker, which only Greed's asset
214
+ manifest lists; the update waits through a game of Nim and appears at the results; a raised
215
+ minimum release blocks a game of Nim with the notice, saves it and reloads into release B,
216
+ which resumes it; an old tab
217
+ lazily loads its own release's chunks after a deploy; the kill switch unregisters the worker
218
+ and empties the caches.
@@ -0,0 +1,76 @@
1
+ import { type Result } from '@joystyk/contracts';
2
+ import * as z from 'zod/mini';
3
+ export declare const ASSET_MANIFEST_VERSION = 1;
4
+ export declare const FILE_KINDS: readonly ["code", "art", "audio", "wasm", "font"];
5
+ export type FileKind = (typeof FILE_KINDS)[number];
6
+ export declare const AssetFileSchema: z.ZodMiniObject<{
7
+ url: z.ZodMiniString<string>;
8
+ size: z.ZodMiniInt;
9
+ kind: z.ZodMiniEnum<{
10
+ code: "code";
11
+ art: "art";
12
+ audio: "audio";
13
+ wasm: "wasm";
14
+ font: "font";
15
+ }>;
16
+ scope: z.ZodMiniEnum<{
17
+ shell: "shell";
18
+ game: "game";
19
+ }>;
20
+ }, z.core.$strip>;
21
+ export type AssetFile = z.infer<typeof AssetFileSchema>;
22
+ export declare const GameAssetManifestSchema: z.ZodMiniObject<{
23
+ schemaVersion: z.ZodMiniLiteral<1>;
24
+ id: z.ZodMiniString<string>;
25
+ name: z.ZodMiniString<string>;
26
+ version: z.ZodMiniString<string>;
27
+ rulesVersion: z.ZodMiniInt;
28
+ saveVersion: z.ZodMiniInt;
29
+ release: z.ZodMiniString<string>;
30
+ page: z.ZodMiniString<string>;
31
+ documents: z.ZodMiniArray<z.ZodMiniString<string>>;
32
+ files: z.ZodMiniArray<z.ZodMiniObject<{
33
+ url: z.ZodMiniString<string>;
34
+ size: z.ZodMiniInt;
35
+ kind: z.ZodMiniEnum<{
36
+ code: "code";
37
+ art: "art";
38
+ audio: "audio";
39
+ wasm: "wasm";
40
+ font: "font";
41
+ }>;
42
+ scope: z.ZodMiniEnum<{
43
+ shell: "shell";
44
+ game: "game";
45
+ }>;
46
+ }, z.core.$strip>>;
47
+ totalBytes: z.ZodMiniInt;
48
+ }, z.core.$strip>;
49
+ export type GameAssetManifest = z.infer<typeof GameAssetManifestSchema>;
50
+ /** Decodes an asset manifest, or lists what is wrong with it. */
51
+ export declare const parseAssetManifest: (value: unknown) => Result<GameAssetManifest, readonly string[]>;
52
+ /** What pinning a manifest into the caches must do. */
53
+ export type PinPlan = {
54
+ /** The cache this build lives in: `game:<id>:<version>`. */
55
+ readonly target: string;
56
+ /** Hashed files the target cache lacks. */
57
+ readonly fetch: readonly string[];
58
+ /** Pages and data, fetched fresh every time, after the files. */
59
+ readonly documents: readonly string[];
60
+ /** Paths in the target cache the manifest no longer lists; deleted last. */
61
+ readonly remove: readonly string[];
62
+ /** The same game's caches for other builds; deleted after the switch. */
63
+ readonly dropCaches: readonly string[];
64
+ };
65
+ /**
66
+ * Plans a pin or a refresh. `existing` lists this game's pinned caches with the paths each
67
+ * holds. Hashed files never change, so one already cached is kept; pages always refresh.
68
+ */
69
+ export declare const planPin: (manifest: GameAssetManifest, assetsUrl: string, target: string, existing: readonly {
70
+ readonly cacheName: string;
71
+ readonly paths: readonly string[];
72
+ }[], recordPath: string) => PinPlan;
73
+ /** The hashed files a page's HTML refers to (`src` and `href`), to check a pin is complete. */
74
+ export declare const referencedAssets: (html: string) => string[];
75
+ /** "312 KB", for the offline control. */
76
+ export declare const formatBytes: (bytes: number) => string;
@@ -0,0 +1,80 @@
1
+ import { RECENT_KINDS } from "./names.js";
2
+ import { ASSETS_PREFIX } from "./routing.js";
3
+ import { err, isGameId, ok } from "@joystyk/contracts";
4
+ import * as z from "zod/mini";
5
+ //#region src/asset-manifest.ts
6
+ var ASSET_MANIFEST_VERSION = 1;
7
+ var FILE_KINDS = [...RECENT_KINDS, "font"];
8
+ var path = z.string().check(z.refine((value) => value.startsWith("/") && !value.startsWith("//"), "Expected a same-origin path such as /assets/x.js"));
9
+ var assetPath = z.string().check(z.refine((value) => value.startsWith("/assets/") && !value.includes(".."), `Expected a path under ${ASSETS_PREFIX}`));
10
+ var size = z.int().check(z.nonnegative());
11
+ var AssetFileSchema = z.object({
12
+ url: assetPath,
13
+ size,
14
+ kind: z.enum(FILE_KINDS),
15
+ /** `shell`: the page and launcher code every game shares. `game`: this game's own code and assets. */
16
+ scope: z.enum(["shell", "game"])
17
+ });
18
+ var GameAssetManifestSchema = z.object({
19
+ schemaVersion: z.literal(1),
20
+ id: z.string().check(z.refine(isGameId, "Expected a kebab-case game id")),
21
+ name: z.string().check(z.minLength(1)),
22
+ /** The game build, semver (`manifest.versions.build`). Pinned caches are named by it. */
23
+ version: z.string().check(z.regex(/^[0-9A-Za-z.+-]+$/)),
24
+ rulesVersion: z.int(),
25
+ saveVersion: z.int(),
26
+ /** The platform release that built these files. */
27
+ release: z.string().check(z.minLength(1)),
28
+ /** The game's page. */
29
+ page: path,
30
+ /** Server-rendered pages and catalog data a pin keeps; always fetched fresh when pinning. */
31
+ documents: z.array(path),
32
+ files: z.array(AssetFileSchema),
33
+ /** The sum of `files[].size`. */
34
+ totalBytes: size
35
+ });
36
+ /** Decodes an asset manifest, or lists what is wrong with it. */
37
+ var parseAssetManifest = (value) => {
38
+ const parsed = GameAssetManifestSchema.safeParse(value);
39
+ if (!parsed.success) return err(parsed.error.issues.map((issue) => `${issue.path.join(".") || "manifest"}: ${issue.message}`));
40
+ const total = parsed.data.files.reduce((sum, file) => sum + file.size, 0);
41
+ if (total !== parsed.data.totalBytes) return err([`totalBytes: ${String(parsed.data.totalBytes)} is not the sum of the file sizes (${String(total)})`]);
42
+ return ok(parsed.data);
43
+ };
44
+ /**
45
+ * Plans a pin or a refresh. `existing` lists this game's pinned caches with the paths each
46
+ * holds. Hashed files never change, so one already cached is kept; pages always refresh.
47
+ */
48
+ var planPin = (manifest, assetsUrl, target, existing, recordPath) => {
49
+ const held = new Set(existing.find((cache) => cache.cacheName === target)?.paths ?? []);
50
+ const files = [...new Set(manifest.files.map((file) => file.url))];
51
+ const documents = [.../* @__PURE__ */ new Set([...manifest.documents, assetsUrl])];
52
+ const keep = /* @__PURE__ */ new Set([
53
+ ...files,
54
+ ...documents,
55
+ recordPath
56
+ ]);
57
+ return {
58
+ target,
59
+ fetch: files.filter((url) => !held.has(url)),
60
+ documents,
61
+ remove: [...held].filter((url) => !keep.has(url)).sort(),
62
+ dropCaches: existing.map((cache) => cache.cacheName).filter((name) => name !== target)
63
+ };
64
+ };
65
+ /** The hashed files a page's HTML refers to (`src` and `href`), to check a pin is complete. */
66
+ var referencedAssets = (html) => {
67
+ const found = /* @__PURE__ */ new Set();
68
+ for (const match of html.matchAll(/\s(?:src|href)=["']?(\/assets\/[^"'\s>]+)/g)) if (match[1] !== void 0) found.add(match[1]);
69
+ return [...found].sort();
70
+ };
71
+ /** "312 KB", for the offline control. */
72
+ var formatBytes = (bytes) => {
73
+ if (bytes < 1024) return `${String(bytes)} bytes`;
74
+ if (bytes < 1048576) return `${String(Math.round(bytes / 1024))} KB`;
75
+ return `${(bytes / 1048576).toFixed(1)} MB`;
76
+ };
77
+ //#endregion
78
+ export { ASSET_MANIFEST_VERSION, AssetFileSchema, FILE_KINDS, GameAssetManifestSchema, formatBytes, parseAssetManifest, planPin, referencedAssets };
79
+
80
+ //# sourceMappingURL=asset-manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-manifest.js","names":[],"sources":["../src/asset-manifest.ts"],"sourcesContent":["// A game's asset manifest, `games/<id>/manifest.json` (offline-and-updates.md, Rule 1): every\n// file the game's page needs to play, with sizes and the versions that built it. The build\n// writes it; pinning reads it from the network, so it is decoded through Standard Schema\n// before use (D-18). A game is either completely available offline or not offered.\nimport { err, isGameId, ok, type Result } from '@joystyk/contracts';\nimport * as z from 'zod/mini';\nimport { RECENT_KINDS } from './names.ts';\nimport { ASSETS_PREFIX } from './routing.ts';\n\nexport const ASSET_MANIFEST_VERSION = 1;\n\nexport const FILE_KINDS = [...RECENT_KINDS, 'font'] as const;\nexport type FileKind = (typeof FILE_KINDS)[number];\n\nconst path = z\n .string()\n .check(\n z.refine(\n (value) => value.startsWith('/') && !value.startsWith('//'),\n 'Expected a same-origin path such as /assets/x.js',\n ),\n );\nconst assetPath = z\n .string()\n .check(\n z.refine(\n (value) => value.startsWith(ASSETS_PREFIX) && !value.includes('..'),\n `Expected a path under ${ASSETS_PREFIX}`,\n ),\n );\nconst size = z.int().check(z.nonnegative());\n\nexport const AssetFileSchema = z.object({\n url: assetPath,\n size,\n kind: z.enum(FILE_KINDS),\n /** `shell`: the page and launcher code every game shares. `game`: this game's own code and assets. */\n scope: z.enum(['shell', 'game']),\n});\nexport type AssetFile = z.infer<typeof AssetFileSchema>;\n\nexport const GameAssetManifestSchema = z.object({\n schemaVersion: z.literal(ASSET_MANIFEST_VERSION),\n id: z.string().check(z.refine(isGameId, 'Expected a kebab-case game id')),\n name: z.string().check(z.minLength(1)),\n /** The game build, semver (`manifest.versions.build`). Pinned caches are named by it. */\n version: z.string().check(z.regex(/^[0-9A-Za-z.+-]+$/)),\n rulesVersion: z.int(),\n saveVersion: z.int(),\n /** The platform release that built these files. */\n release: z.string().check(z.minLength(1)),\n /** The game's page. */\n page: path,\n /** Server-rendered pages and catalog data a pin keeps; always fetched fresh when pinning. */\n documents: z.array(path),\n files: z.array(AssetFileSchema),\n /** The sum of `files[].size`. */\n totalBytes: size,\n});\nexport type GameAssetManifest = z.infer<typeof GameAssetManifestSchema>;\n\n/** Decodes an asset manifest, or lists what is wrong with it. */\nexport const parseAssetManifest = (value: unknown): Result<GameAssetManifest, readonly string[]> => {\n const parsed = GameAssetManifestSchema.safeParse(value);\n if (!parsed.success)\n return err(parsed.error.issues.map((issue) => `${issue.path.join('.') || 'manifest'}: ${issue.message}`));\n const total = parsed.data.files.reduce((sum, file) => sum + file.size, 0);\n if (total !== parsed.data.totalBytes)\n return err([`totalBytes: ${String(parsed.data.totalBytes)} is not the sum of the file sizes (${String(total)})`]);\n return ok(parsed.data);\n};\n\n/** What pinning a manifest into the caches must do. */\nexport type PinPlan = {\n /** The cache this build lives in: `game:<id>:<version>`. */\n readonly target: string;\n /** Hashed files the target cache lacks. */\n readonly fetch: readonly string[];\n /** Pages and data, fetched fresh every time, after the files. */\n readonly documents: readonly string[];\n /** Paths in the target cache the manifest no longer lists; deleted last. */\n readonly remove: readonly string[];\n /** The same game's caches for other builds; deleted after the switch. */\n readonly dropCaches: readonly string[];\n};\n\n/**\n * Plans a pin or a refresh. `existing` lists this game's pinned caches with the paths each\n * holds. Hashed files never change, so one already cached is kept; pages always refresh.\n */\nexport const planPin = (\n manifest: GameAssetManifest,\n assetsUrl: string,\n target: string,\n existing: readonly { readonly cacheName: string; readonly paths: readonly string[] }[],\n recordPath: string,\n): PinPlan => {\n const held = new Set(existing.find((cache) => cache.cacheName === target)?.paths ?? []);\n const files = [...new Set(manifest.files.map((file) => file.url))];\n const documents = [...new Set([...manifest.documents, assetsUrl])];\n const keep = new Set([...files, ...documents, recordPath]);\n return {\n target,\n fetch: files.filter((url) => !held.has(url)),\n documents,\n remove: [...held].filter((url) => !keep.has(url)).sort(),\n dropCaches: existing.map((cache) => cache.cacheName).filter((name) => name !== target),\n };\n};\n\n/** The hashed files a page's HTML refers to (`src` and `href`), to check a pin is complete. */\nexport const referencedAssets = (html: string): string[] => {\n const found = new Set<string>();\n for (const match of html.matchAll(/\\s(?:src|href)=[\"']?(\\/assets\\/[^\"'\\s>]+)/g)) {\n if (match[1] !== undefined) found.add(match[1]);\n }\n return [...found].sort();\n};\n\n/** \"312 KB\", for the offline control. */\nexport const formatBytes = (bytes: number): string => {\n if (bytes < 1024) return `${String(bytes)} bytes`;\n if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n};\n"],"mappings":"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"}
@@ -0,0 +1,79 @@
1
+ import { type GameAssetManifest } from '../asset-manifest.js';
2
+ /** A short content hash for a file name or a precache revision (base64url, like Vite's). */
3
+ export declare const contentHash: (content: string | Uint8Array, length?: number) => string;
4
+ export type IconFiles = {
5
+ readonly svg: string;
6
+ readonly png192: string;
7
+ readonly png512: string;
8
+ readonly maskable512: string;
9
+ };
10
+ /** manifest.webmanifest: what an installed Joystyk looks like. */
11
+ export declare const webManifest: (icons: IconFiles) => {
12
+ id: string;
13
+ name: string;
14
+ short_name: string;
15
+ description: string;
16
+ lang: string;
17
+ dir: string;
18
+ start_url: string;
19
+ scope: string;
20
+ display: string;
21
+ background_color: string;
22
+ theme_color: string;
23
+ categories: string[];
24
+ icons: {
25
+ src: string;
26
+ sizes: string;
27
+ type: string;
28
+ purpose: string;
29
+ }[];
30
+ };
31
+ /** The offline page. Static: its script fills in the list of games this device can play. */
32
+ export declare const offlinePage: (files: {
33
+ readonly css: string;
34
+ readonly js: string;
35
+ readonly icon: string;
36
+ }) => string;
37
+ /** A game as the site describes it to the build. */
38
+ export type BuildGame = {
39
+ readonly id: string;
40
+ readonly name: string;
41
+ /** `manifest.versions.build`. */
42
+ readonly version: string;
43
+ readonly rulesVersion: number;
44
+ readonly saveVersion: number;
45
+ /** The folder holding the game's code (its entry's folder). */
46
+ readonly root: string;
47
+ /** The game manifest's `assets`: where this asset manifest is served. */
48
+ readonly assets: string;
49
+ /** The game's page, e.g. /games/nim. */
50
+ readonly page: string;
51
+ /** Catalog data the page loads on client-side navigation, e.g. /api/games/nim. */
52
+ readonly data: readonly string[];
53
+ };
54
+ /** `games/<id>/manifest.json`: the game's files plus the shell's, sizes from `sizeOf`. */
55
+ export declare const gameAssetManifest: (game: BuildGame, release: string, shell: ReadonlySet<string>, own: ReadonlySet<string>, sizeOf: (file: string) => number) => GameAssetManifest;
56
+ /** One Workbox precache entry. `revision: null` for a URL whose name already holds a hash. */
57
+ export type PrecacheEntry = {
58
+ readonly url: string;
59
+ readonly revision: string | null;
60
+ };
61
+ /** Where a precache entry's bytes come from at build time, so each can be checked. */
62
+ export type PrecacheSource = {
63
+ readonly url: string;
64
+ readonly hashed: true;
65
+ readonly file: string;
66
+ } | {
67
+ readonly url: string;
68
+ readonly hashed: false;
69
+ readonly body: Uint8Array | string;
70
+ };
71
+ /**
72
+ * The precache list. A hashed file must exist on disk (`exists`); an unhashed entry gets a
73
+ * revision from its rendered bytes. Throws listing every missing entry, because one bad entry
74
+ * fails the worker's install for every visitor.
75
+ */
76
+ export declare const precacheEntries: (sources: readonly PrecacheSource[], exists: (file: string) => boolean) => PrecacheEntry[];
77
+ export declare const INJECTION_POINT = "self.__WB_MANIFEST";
78
+ /** Puts the precache list into the bundled worker, or throws when the injection point is gone. */
79
+ export declare const injectManifest: (code: string, entries: readonly PrecacheEntry[]) => string;
@@ -0,0 +1,135 @@
1
+ import { fileKind } from "../routing.js";
2
+ import "../asset-manifest.js";
3
+ import { BRAND_BACKGROUND } from "./icons.js";
4
+ import { createHash } from "node:crypto";
5
+ //#region src/build/documents.ts
6
+ /** A short content hash for a file name or a precache revision (base64url, like Vite's). */
7
+ var contentHash = (content, length = 8) => createHash("sha256").update(content).digest("base64url").replace(/[-_]/g, "x").slice(0, length);
8
+ /** manifest.webmanifest: what an installed Joystyk looks like. */
9
+ var webManifest = (icons) => ({
10
+ id: "/",
11
+ name: "Joystyk",
12
+ short_name: "Joystyk",
13
+ description: "Free games for your browser, for every age. Solo, against the computer, or pass the device to a friend.",
14
+ lang: "en",
15
+ dir: "ltr",
16
+ start_url: "/",
17
+ scope: "/",
18
+ display: "standalone",
19
+ background_color: BRAND_BACKGROUND,
20
+ theme_color: BRAND_BACKGROUND,
21
+ categories: ["games", "entertainment"],
22
+ icons: [
23
+ {
24
+ src: icons.svg,
25
+ sizes: "any",
26
+ type: "image/svg+xml",
27
+ purpose: "any"
28
+ },
29
+ {
30
+ src: icons.png192,
31
+ sizes: "192x192",
32
+ type: "image/png",
33
+ purpose: "any"
34
+ },
35
+ {
36
+ src: icons.png512,
37
+ sizes: "512x512",
38
+ type: "image/png",
39
+ purpose: "any"
40
+ },
41
+ {
42
+ src: icons.maskable512,
43
+ sizes: "512x512",
44
+ type: "image/png",
45
+ purpose: "maskable"
46
+ }
47
+ ]
48
+ });
49
+ var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
50
+ /** The offline page. Static: its script fills in the list of games this device can play. */
51
+ var offlinePage = (files) => `<!doctype html>
52
+ <html lang="en">
53
+ <head>
54
+ <meta charset="utf-8">
55
+ <meta name="viewport" content="width=device-width, initial-scale=1">
56
+ <title>You're offline | Joystyk</title>
57
+ <meta name="robots" content="noindex">
58
+ <meta name="theme-color" content="${BRAND_BACKGROUND}">
59
+ <link rel="icon" href="${escapeAttr(files.icon)}" type="image/svg+xml">
60
+ <link rel="stylesheet" href="${escapeAttr(files.css)}">
61
+ <script defer src="${escapeAttr(files.js)}"><\/script>
62
+ </head>
63
+ <body>
64
+ <main>
65
+ <p class="kicker">Joystyk</p>
66
+ <h1>You're offline</h1>
67
+ <p id="where" class="lede">This page needs an internet connection.</p>
68
+ <section aria-labelledby="ready-heading">
69
+ <h2 id="ready-heading">Games you can play offline</h2>
70
+ <ul id="games" hidden></ul>
71
+ <p id="none">No games are saved on this device yet. Next time you're online, open a game's page and choose “Make available offline”.</p>
72
+ </section>
73
+ <p class="actions"><button type="button" id="retry">Try again</button> <a href="/">See all games</a></p>
74
+ </main>
75
+ </body>
76
+ </html>
77
+ `;
78
+ /** `games/<id>/manifest.json`: the game's files plus the shell's, sizes from `sizeOf`. */
79
+ var gameAssetManifest = (game, release, shell, own, sizeOf) => {
80
+ const describe = (file, scope) => {
81
+ const url = `/${file}`;
82
+ const kind = fileKind(url) ?? "code";
83
+ return {
84
+ url,
85
+ size: sizeOf(file),
86
+ kind,
87
+ scope
88
+ };
89
+ };
90
+ const files = [...[...shell].sort().map((file) => describe(file, "shell")), ...[...own].sort().map((file) => describe(file, "game"))];
91
+ return {
92
+ schemaVersion: 1,
93
+ id: game.id,
94
+ name: game.name,
95
+ version: game.version,
96
+ rulesVersion: game.rulesVersion,
97
+ saveVersion: game.saveVersion,
98
+ release,
99
+ page: game.page,
100
+ documents: [game.page, ...game.data],
101
+ files,
102
+ totalBytes: files.reduce((sum, file) => sum + file.size, 0)
103
+ };
104
+ };
105
+ /**
106
+ * The precache list. A hashed file must exist on disk (`exists`); an unhashed entry gets a
107
+ * revision from its rendered bytes. Throws listing every missing entry, because one bad entry
108
+ * fails the worker's install for every visitor.
109
+ */
110
+ var precacheEntries = (sources, exists) => {
111
+ const missing = sources.filter((source) => source.hashed ? !exists(source.file) : source.body.length === 0);
112
+ if (missing.length > 0) throw new Error(`[pwa] Precache entries missing from the build: ${missing.map((s) => s.url).join(", ")}. Each one would fail the service worker install for every visitor; fix the build before deploying.`);
113
+ const seen = /* @__PURE__ */ new Set();
114
+ const entries = [];
115
+ for (const source of sources) {
116
+ if (seen.has(source.url)) continue;
117
+ seen.add(source.url);
118
+ entries.push({
119
+ url: source.url,
120
+ revision: source.hashed ? null : contentHash(source.body, 16)
121
+ });
122
+ }
123
+ return entries.sort((a, b) => a.url.localeCompare(b.url));
124
+ };
125
+ var INJECTION_POINT = "self.__WB_MANIFEST";
126
+ /** Puts the precache list into the bundled worker, or throws when the injection point is gone. */
127
+ var injectManifest = (code, entries) => {
128
+ const at = code.indexOf(INJECTION_POINT);
129
+ if (at === -1 || code.indexOf("self.__WB_MANIFEST", at + 1) !== -1) throw new Error(`[pwa] Expected "${INJECTION_POINT}" exactly once in the bundled service worker; sw.ts must call precache(${INJECTION_POINT}).`);
130
+ return code.replace(INJECTION_POINT, JSON.stringify(entries));
131
+ };
132
+ //#endregion
133
+ export { INJECTION_POINT, contentHash, gameAssetManifest, injectManifest, offlinePage, precacheEntries, webManifest };
134
+
135
+ //# sourceMappingURL=documents.js.map