@thunderclap-engine/host-pwa 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mandalorian007 / ThunderClap Gaming
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # @thunderclap-engine/host-pwa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The installable web shell for a ThunderClap game: a vite plugin that turns a build into an
4
+ installable, offline-capable app (manifest, icons, service worker), and a `Host` that surfaces the
5
+ install prompt, the update prompt and the offline state through `host.pwa`. No dependencies, no
6
+ workbox: the worker is written here. The engine itself stays network-free; the worker and the
7
+ manifest are this package's and the game's.
8
+
9
+ ## Layout
10
+
11
+ | Path | What it is |
12
+ | --- | --- |
13
+ | `src/index.ts` | Exports: `createPwaHost`, `inStandalone`, `pwaPlugin` and the types |
14
+ | `src/host.ts` | `createPwaHost(opts)`: `createWebHost()` plus `host.pwa`; `inStandalone()` |
15
+ | `src/vite.ts` | `pwaPlugin(opts)`: the build-only vite plugin (`@thunderclap-engine/host-pwa/vite`, so a vite config never loads the host) |
16
+ | `src/manifest.ts` | The manifest, the icon file names, the tags injected into the page, the precache list and the version stamp |
17
+ | `src/worker.ts` | The `sw.js` body with the per-build placeholders, and `workerSource(cfg)` that fills them |
18
+ | `src/sw-logic.ts` | The worker's decisions as pure functions (`routeFor`, `trimPlan`, `staleCacheNames`, `isNewVersion`) |
19
+ | `harness/` | A noun-free page built with the plugin, publishing `window.__pwa` |
20
+ | `e2e/pwa.spec.ts` | The smoke on the built preview (Chromium, port 4551) |
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ // vite.config.ts
26
+ import { pwaPlugin } from '@thunderclap-engine/host-pwa/vite';
27
+
28
+ export default defineConfig({
29
+ plugins: [
30
+ pwaPlugin({
31
+ manifest: { name: 'Pairs', theme_color: '#0b0d14', background_color: '#0b0d14', svg: ICON_SVG },
32
+ runtimeCache: ['levels/', 'audio/'], // stale-while-revalidate, capped
33
+ maxRuntimeEntries: 60,
34
+ }),
35
+ ],
36
+ });
37
+ ```
38
+
39
+ ```ts
40
+ // main.ts
41
+ import { createPwaHost } from '@thunderclap-engine/host-pwa';
42
+
43
+ const host = createPwaHost(); // a web Host (kind 'web') with host.pwa
44
+ const ctx = await createGame({ host, ... });
45
+
46
+ host.pwa.onInstallable(() => showInstallButton(() => host.pwa.install())); // 'accepted' | 'dismissed' | 'unavailable'
47
+ host.pwa.onUpdateReady(() => toast('Update ready', () => host.pwa.applyUpdate())); // skip-waiting, then reload
48
+ host.pwa.onOffline((off) => hint(off ? 'Offline: playing from the cache' : ''));
49
+ host.pwa.standalone; // launched from the home screen or the dock
50
+ await host.pwa.cacheStatus(); // { precached, runtime, bytes }
51
+ await host.pwa.clearCache(); // drops every tc-pwa-* cache; saves are untouched
52
+ ```
53
+
54
+ `pwaPlugin` runs on `vite build` only; `vite dev` is unchanged. At `generateBundle` it reads the
55
+ bundle's hashed file names, stamps the version (fnv1a of the sorted precache list, or
56
+ `opts.version`), and emits `manifest.webmanifest`, `icon.svg`, `sw.js` and, when `OffscreenCanvas`
57
+ exists in the build process, `icon-192.png` and `icon-512.png` (Node has none, so the package's own
58
+ tests and harness take the SVG-only path; a game can list ready-made PNGs in `manifest.icons`
59
+ instead). Its post `transformIndexHtml` hook then injects `<link rel="manifest">`,
60
+ `<meta name="theme-color">` and a registration snippet that also sets `window.__tcPwa = { sw, version }`,
61
+ which is how `createPwaHost()` finds the worker (`opts.swUrl` overrides it; `register: false` leaves
62
+ registration to the page). `inStandalone()` reads the `display-mode` media query.
63
+
64
+ `createPwaHost` is guarded: in a browser without `serviceWorker`, `caches` or
65
+ `beforeinstallprompt`, `install()` resolves `'unavailable'`, `applyUpdate()` and `clearCache()` do
66
+ nothing, `cacheStatus()` reports zeros, and the host is otherwise the plain web host.
67
+
68
+ ## Routing
69
+
70
+ The worker (`sw.js`, scope = where the game is served) decides per `GET` on its own origin, in order:
71
+
72
+ | Request | Route | Strategy |
73
+ | --- | --- | --- |
74
+ | A navigation, the scope root, or `index.html` | `index` | Network-first (`cache: 'no-cache'`); the cached copy on failure |
75
+ | A path in the precache set (the build's hashed assets) | `precache` | Cache-first; a miss is fetched and stored |
76
+ | A path starting with one of `runtimeCache` | `runtime` | Stale-while-revalidate into `tc-pwa-runtime`, the oldest dropped past `maxRuntimeEntries` |
77
+ | Anything else (other origins, non-GET, unknown paths) | `network` | Untouched |
78
+
79
+ Cache names: `tc-pwa-precache-<version>` and `tc-pwa-runtime`. On activate the worker deletes the
80
+ precache caches of other versions and claims the open pages. Lookups ignore `Vary` (the caches
81
+ are keyed by URL; a server answering `Vary: Origin` would otherwise make module scripts miss) and
82
+ the query string on precached assets. A page posts `{ type: 'tc-pwa:skip-waiting' }` to make a
83
+ waiting worker take over; `applyUpdate()` does that and reloads on `controllerchange`.
84
+
85
+ ### What is cached, and what never is
86
+
87
+ - **Cached**: the bundle's emitted assets (hashed JS, CSS, images, fonts, anything vite emits,
88
+ minus `.map` files), `index.html`, and the `runtimeCache` prefixes a game opts into.
89
+ - **Never cached**: the game's saves (they live in the host's `storage`, `localStorage` on the web;
90
+ `clearCache()` does not touch them), anything the game fetches outside the prefixes, other
91
+ origins, `POST`s, the worker and manifest themselves.
92
+
93
+ An update is a new version stamp: the new worker installs its own precache beside the old one,
94
+ reaches `installed`, and `host.pwa.updateReady` flips (`onUpdateReady` fires); nothing changes
95
+ until `applyUpdate()` or the next full navigation. `index.html` is network-first, so an online
96
+ load always gets the new page, which registers the new worker.
97
+
98
+ ## Commands
99
+
100
+ From the repo root: `pnpm pwa:dev` (harness dev server on 5198), `pnpm pwa:build`,
101
+ `pnpm check:host-pwa` (part of `pnpm check`). In this folder: `pnpm test` (Vitest: the routing,
102
+ the plugin's hooks, the host with a fake worker container under happy-dom), `pnpm test:e2e`
103
+ (builds the harness, serves it with `vite preview` on 4551 and runs `e2e/pwa.spec.ts`: manifest,
104
+ activation, an offline reload, `offline` following the context, the precache count, the runtime
105
+ cache filling and trimming).
package/dist/host.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `createPwaHost()`: the web host (`createWebHost`, `@thunderclap-engine/engine/host`) with a `pwa`
3
+ * facet: install prompt, update prompt, offline state, standalone detection and the caches the
4
+ * plugin's worker fills. `Host` itself gains nothing: `kind` stays `'web'` (the engine's type is
5
+ * not this package's), and `pwa.standalone` says the rest.
6
+ *
7
+ * Everything is guarded. In a tab without service workers, without `caches`, or on a build that
8
+ * never ran the plugin (no `window.__tcPwa`, no `swUrl`), the host boots and every method
9
+ * resolves `'unavailable'` or does nothing.
10
+ */
11
+ import { type Host, type WebHostOptions } from '@thunderclap-engine/engine/host';
12
+ export type InstallOutcome = 'accepted' | 'dismissed' | 'unavailable';
13
+ export interface CacheStatus {
14
+ /** Entries in the build's precache (hashed assets and `index.html`). */
15
+ precached: number;
16
+ /** Entries in the runtime cache (the `runtimeCache` prefixes). */
17
+ runtime: number;
18
+ /** Bytes across both, summed from each cached body. */
19
+ bytes: number;
20
+ }
21
+ export interface PwaHost {
22
+ /** True once the browser offered an install prompt (`beforeinstallprompt`) that is still open. */
23
+ readonly installable: boolean;
24
+ /** Shows the deferred prompt. `unavailable` when there is none (already installed, or unsupported). */
25
+ install(): Promise<InstallOutcome>;
26
+ /** Calls `fn(installable)` on every change. Returns unsubscribe. */
27
+ onInstallable(fn: (installable: boolean) => void): () => void;
28
+ /** True when a new worker is installed and waiting behind the one that controls the page. */
29
+ readonly updateReady: boolean;
30
+ /**
31
+ * Tells the waiting worker to take over and reloads the page once it controls it. Resolves
32
+ * without reloading when no update is waiting.
33
+ */
34
+ applyUpdate(): Promise<void>;
35
+ /** Calls `fn()` when an update becomes ready. Returns unsubscribe. */
36
+ onUpdateReady(fn: () => void): () => void;
37
+ /** `!navigator.onLine`: kept current from the `online` and `offline` events. */
38
+ readonly offline: boolean;
39
+ /** Calls `fn(offline)` on every change. Returns unsubscribe. */
40
+ onOffline(fn: (offline: boolean) => void): () => void;
41
+ /** True when the page runs as an installed app (display-mode standalone, or iOS `navigator.standalone`). */
42
+ readonly standalone: boolean;
43
+ /** Counts and bytes of this package's caches. Zeros without `caches`. */
44
+ cacheStatus(): Promise<CacheStatus>;
45
+ /** Deletes this package's caches (never saves: those are `localStorage` / IndexedDB). */
46
+ clearCache(): Promise<void>;
47
+ }
48
+ export interface PwaHostOptions extends WebHostOptions {
49
+ /**
50
+ * The worker to register. Default: `window.__tcPwa.sw`, which the plugin's snippet sets in the
51
+ * built page. Without either the host registers nothing.
52
+ */
53
+ swUrl?: string;
54
+ /** Set false to leave registration to the page (the plugin's snippet already did it). Default true. */
55
+ register?: boolean;
56
+ }
57
+ /** True when the page runs as an installed app. */
58
+ export declare function inStandalone(win?: Window): boolean;
59
+ export declare function createPwaHost(opts?: PwaHostOptions): Host & {
60
+ pwa: PwaHost;
61
+ };
package/dist/host.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `createPwaHost()`: the web host (`createWebHost`, `@thunderclap-engine/engine/host`) with a `pwa`
3
+ * facet: install prompt, update prompt, offline state, standalone detection and the caches the
4
+ * plugin's worker fills. `Host` itself gains nothing: `kind` stays `'web'` (the engine's type is
5
+ * not this package's), and `pwa.standalone` says the rest.
6
+ *
7
+ * Everything is guarded. In a tab without service workers, without `caches`, or on a build that
8
+ * never ran the plugin (no `window.__tcPwa`, no `swUrl`), the host boots and every method
9
+ * resolves `'unavailable'` or does nothing.
10
+ */
11
+ import { createWebHost } from '@thunderclap-engine/engine/host';
12
+ import { PAGE_GLOBAL } from './manifest.js';
13
+ import { CACHE_PREFIX, PRECACHE_PREFIX, RUNTIME_CACHE } from './sw-logic.js';
14
+ import { SKIP_WAITING } from './worker.js';
15
+ /** True when the page runs as an installed app. */
16
+ export function inStandalone(win = window) {
17
+ try {
18
+ if (win.matchMedia?.('(display-mode: standalone)').matches)
19
+ return true;
20
+ if (win.matchMedia?.('(display-mode: fullscreen)').matches)
21
+ return true;
22
+ return win.navigator.standalone === true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function listeners() {
29
+ const set = new Set();
30
+ return {
31
+ add(fn) {
32
+ set.add(fn);
33
+ return () => {
34
+ set.delete(fn);
35
+ };
36
+ },
37
+ emit(v) {
38
+ for (const fn of set)
39
+ fn(v);
40
+ },
41
+ };
42
+ }
43
+ export function createPwaHost(opts = {}) {
44
+ const win = opts.win ?? window;
45
+ const base = createWebHost({ win });
46
+ const nav = win.navigator;
47
+ // Install prompt.
48
+ let deferred = null;
49
+ const installable = listeners();
50
+ win.addEventListener('beforeinstallprompt', (e) => {
51
+ e.preventDefault();
52
+ deferred = e;
53
+ installable.emit(true);
54
+ });
55
+ win.addEventListener('appinstalled', () => {
56
+ deferred = null;
57
+ installable.emit(false);
58
+ });
59
+ // Online state.
60
+ let offline = nav.onLine === false;
61
+ const offlineL = listeners();
62
+ const setOffline = (v) => {
63
+ if (v === offline)
64
+ return;
65
+ offline = v;
66
+ offlineL.emit(v);
67
+ };
68
+ win.addEventListener('online', () => setOffline(false));
69
+ win.addEventListener('offline', () => setOffline(true));
70
+ // Service worker: registration and the waiting worker.
71
+ const sw = 'serviceWorker' in nav ? nav.serviceWorker : null;
72
+ const page = win[PAGE_GLOBAL];
73
+ const swUrl = opts.swUrl ?? page?.sw;
74
+ let registration = null;
75
+ let updateReady = false;
76
+ const updateL = listeners();
77
+ const markReady = () => {
78
+ if (updateReady)
79
+ return;
80
+ updateReady = true;
81
+ updateL.emit();
82
+ };
83
+ const watchInstalling = (reg) => {
84
+ const w = reg.installing;
85
+ if (!w)
86
+ return;
87
+ w.addEventListener('statechange', () => {
88
+ // A worker that installed while another controls the page is an update waiting.
89
+ if (w.state === 'installed' && sw?.controller)
90
+ markReady();
91
+ });
92
+ };
93
+ const adopt = (reg) => {
94
+ registration = reg;
95
+ if (reg.waiting && sw?.controller)
96
+ markReady();
97
+ watchInstalling(reg);
98
+ reg.addEventListener('updatefound', () => watchInstalling(reg));
99
+ };
100
+ if (sw && swUrl) {
101
+ const ready = opts.register === false
102
+ ? sw.getRegistration(swUrl)
103
+ : sw.register(swUrl).then((r) => r, () => undefined);
104
+ ready.then((reg) => reg && adopt(reg)).catch(() => { });
105
+ }
106
+ const openCaches = async () => {
107
+ const store = win.caches;
108
+ if (!store)
109
+ return [];
110
+ try {
111
+ const names = (await store.keys()).filter((n) => n.startsWith(CACHE_PREFIX));
112
+ return Promise.all(names.map(async (name) => ({ name, cache: await store.open(name) })));
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ };
118
+ const pwa = {
119
+ get installable() {
120
+ return deferred !== null;
121
+ },
122
+ async install() {
123
+ const e = deferred;
124
+ if (!e)
125
+ return 'unavailable';
126
+ deferred = null;
127
+ installable.emit(false);
128
+ try {
129
+ await e.prompt();
130
+ const { outcome } = await e.userChoice;
131
+ return outcome;
132
+ }
133
+ catch {
134
+ return 'unavailable';
135
+ }
136
+ },
137
+ onInstallable: (fn) => installable.add(fn),
138
+ get updateReady() {
139
+ return updateReady;
140
+ },
141
+ async applyUpdate() {
142
+ const waiting = registration?.waiting;
143
+ if (!waiting || !sw)
144
+ return;
145
+ await new Promise((resolve) => {
146
+ const done = () => {
147
+ sw.removeEventListener('controllerchange', done);
148
+ resolve();
149
+ };
150
+ sw.addEventListener('controllerchange', done);
151
+ waiting.postMessage({ type: SKIP_WAITING });
152
+ });
153
+ win.location.reload();
154
+ },
155
+ onUpdateReady: (fn) => updateL.add(() => fn()),
156
+ get offline() {
157
+ return offline;
158
+ },
159
+ onOffline: (fn) => offlineL.add(fn),
160
+ get standalone() {
161
+ return inStandalone(win);
162
+ },
163
+ async cacheStatus() {
164
+ const status = { precached: 0, runtime: 0, bytes: 0 };
165
+ for (const { name, cache } of await openCaches()) {
166
+ const keys = await cache.keys();
167
+ if (name.startsWith(PRECACHE_PREFIX))
168
+ status.precached += keys.length;
169
+ else if (name === RUNTIME_CACHE)
170
+ status.runtime += keys.length;
171
+ for (const k of keys) {
172
+ const res = await cache.match(k);
173
+ if (res)
174
+ status.bytes += (await res.arrayBuffer()).byteLength;
175
+ }
176
+ }
177
+ return status;
178
+ },
179
+ async clearCache() {
180
+ const store = win.caches;
181
+ if (!store)
182
+ return;
183
+ for (const { name } of await openCaches())
184
+ await store.delete(name);
185
+ },
186
+ };
187
+ return Object.assign(base, { pwa });
188
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@thunderclap-engine/host-pwa`: what a game imports. The page side (`createPwaHost`, `inStandalone`)
3
+ * and the build side (`pwaPlugin`, also at `@thunderclap-engine/host-pwa/vite`, which a `vite.config.ts`
4
+ * should prefer so Node never loads the host).
5
+ */
6
+ export { type CacheStatus, createPwaHost, type InstallOutcome, inStandalone, type PwaHost, type PwaHostOptions, } from './host.js';
7
+ export type { ManifestIcon, ManifestOptions, WebManifest } from './manifest.js';
8
+ export type { Route, RouteRequest } from './sw-logic.js';
9
+ export { type PwaPluginOptions, pwaPlugin } from './vite.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@thunderclap-engine/host-pwa`: what a game imports. The page side (`createPwaHost`, `inStandalone`)
3
+ * and the build side (`pwaPlugin`, also at `@thunderclap-engine/host-pwa/vite`, which a `vite.config.ts`
4
+ * should prefer so Node never loads the host).
5
+ */
6
+ export { createPwaHost, inStandalone, } from './host.js';
7
+ export { pwaPlugin } from './vite.js';
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Build-time pieces of the plugin as pure functions: the web manifest, the `index.html`
3
+ * injection, the precache list from a bundle and the version stamp. vite.ts wires them into
4
+ * Vite's hooks; vite.test.ts exercises them on fakes.
5
+ */
6
+ export interface ManifestIcon {
7
+ /** Path relative to the build root, e.g. `icons/icon-192.png`. */
8
+ src: string;
9
+ sizes: string;
10
+ type?: string;
11
+ purpose?: string;
12
+ }
13
+ export interface ManifestOptions {
14
+ name: string;
15
+ short_name?: string;
16
+ description?: string;
17
+ /** Default `#000000`. */
18
+ theme_color?: string;
19
+ /** Default `#000000`. */
20
+ background_color?: string;
21
+ /** Default `standalone`. */
22
+ display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
23
+ /** Default `landscape` (a game); `any` lets the page follow the device. */
24
+ orientation?: 'any' | 'landscape' | 'portrait' | 'natural';
25
+ /** Default `.` (the build root). */
26
+ start_url?: string;
27
+ /** Default `.`. */
28
+ scope?: string;
29
+ /** Default `games`. */
30
+ categories?: string[];
31
+ /**
32
+ * One SVG document. The plugin emits it as `icon.svg` (`sizes: any`) and, where
33
+ * `OffscreenCanvas` exists at build time, rasterises it to `icon-192.png` and `icon-512.png`.
34
+ * Node has no `OffscreenCanvas`, so under `vite build` the SVG goes alone unless `icons` names
35
+ * PNGs the game ships in `public/`.
36
+ */
37
+ svg?: string;
38
+ /** Icons the game provides itself (paths relative to the build root). */
39
+ icons?: ManifestIcon[];
40
+ }
41
+ export interface WebManifest {
42
+ name: string;
43
+ short_name: string;
44
+ description?: string;
45
+ start_url: string;
46
+ scope: string;
47
+ display: string;
48
+ orientation: string;
49
+ theme_color: string;
50
+ background_color: string;
51
+ categories: string[];
52
+ icons: ManifestIcon[];
53
+ }
54
+ /** Rasterised icons the plugin emits next to the manifest. */
55
+ export declare const ICON_SIZES: readonly [192, 512];
56
+ export declare const SVG_ICON_FILE = "icon.svg";
57
+ export declare const pngIconFile: (size: number) => string;
58
+ /**
59
+ * The manifest as written. `svg` adds the SVG icon (and the PNG names when `rasterised`); the
60
+ * game's own `icons` follow.
61
+ */
62
+ export declare function buildManifest(opts: ManifestOptions, rasterised: boolean): WebManifest;
63
+ /** What the page learns from the build: read by `createPwaHost` when it is given no `swUrl`. */
64
+ export declare const PAGE_GLOBAL = "__tcPwa";
65
+ /**
66
+ * The tags injected into `index.html`: the manifest link, a theme colour, and a snippet that
67
+ * publishes `window.__tcPwa = { sw, version }` and registers the worker, so a page that never
68
+ * calls `createPwaHost` still works offline. `base` is Vite's `base` (`/`, `./`, `/game/`).
69
+ */
70
+ export declare function injectTags(html: string, base: string, cfg: HtmlInjection): string;
71
+ export interface HtmlInjection {
72
+ manifestFile: string;
73
+ swFile: string;
74
+ version: string;
75
+ themeColor: string;
76
+ }
77
+ /** The subset of a Rollup/Rolldown output bundle the precache list reads. */
78
+ export interface BundleLike {
79
+ [fileName: string]: {
80
+ type: 'chunk' | 'asset';
81
+ fileName: string;
82
+ };
83
+ }
84
+ /**
85
+ * Scope-relative paths to precache: every emitted chunk and asset except the worker and the
86
+ * manifest, plus `index.html`. Sorted so the list (and the version stamped from it) is stable.
87
+ */
88
+ export declare function precacheList(bundle: BundleLike, exclude: Iterable<string>): string[];
89
+ /** FNV-1a over a string, as 8 hex digits. */
90
+ export declare function fnv1a(text: string): string;
91
+ /**
92
+ * The build's version stamp: a hash of the precache list. Every chunk and asset carries a content
93
+ * hash in its name, so any changed code or asset changes the list and the stamp, while an
94
+ * identical rebuild keeps it (no spurious update). `index.html` is not in the stamp: it is served
95
+ * network-first and its cached copy is refreshed on every online load, so a page-only change
96
+ * needs no new worker.
97
+ */
98
+ export declare function versionStamp(precache: string[]): string;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Build-time pieces of the plugin as pure functions: the web manifest, the `index.html`
3
+ * injection, the precache list from a bundle and the version stamp. vite.ts wires them into
4
+ * Vite's hooks; vite.test.ts exercises them on fakes.
5
+ */
6
+ /** Rasterised icons the plugin emits next to the manifest. */
7
+ export const ICON_SIZES = [192, 512];
8
+ export const SVG_ICON_FILE = 'icon.svg';
9
+ export const pngIconFile = (size) => `icon-${size}.png`;
10
+ /**
11
+ * The manifest as written. `svg` adds the SVG icon (and the PNG names when `rasterised`); the
12
+ * game's own `icons` follow.
13
+ */
14
+ export function buildManifest(opts, rasterised) {
15
+ const icons = [];
16
+ if (opts.svg) {
17
+ icons.push({ src: SVG_ICON_FILE, sizes: 'any', type: 'image/svg+xml', purpose: 'any' });
18
+ if (rasterised) {
19
+ for (const s of ICON_SIZES) {
20
+ icons.push({ src: pngIconFile(s), sizes: `${s}x${s}`, type: 'image/png', purpose: 'any' });
21
+ }
22
+ }
23
+ }
24
+ icons.push(...(opts.icons ?? []));
25
+ const m = {
26
+ name: opts.name,
27
+ short_name: opts.short_name ?? opts.name,
28
+ start_url: opts.start_url ?? '.',
29
+ scope: opts.scope ?? '.',
30
+ display: opts.display ?? 'standalone',
31
+ orientation: opts.orientation ?? 'landscape',
32
+ theme_color: opts.theme_color ?? '#000000',
33
+ background_color: opts.background_color ?? '#000000',
34
+ categories: opts.categories ?? ['games'],
35
+ icons,
36
+ };
37
+ if (opts.description)
38
+ m.description = opts.description;
39
+ return m;
40
+ }
41
+ /** What the page learns from the build: read by `createPwaHost` when it is given no `swUrl`. */
42
+ export const PAGE_GLOBAL = '__tcPwa';
43
+ /**
44
+ * The tags injected into `index.html`: the manifest link, a theme colour, and a snippet that
45
+ * publishes `window.__tcPwa = { sw, version }` and registers the worker, so a page that never
46
+ * calls `createPwaHost` still works offline. `base` is Vite's `base` (`/`, `./`, `/game/`).
47
+ */
48
+ export function injectTags(html, base, cfg) {
49
+ const prefix = base.endsWith('/') ? base : `${base}/`;
50
+ const sw = JSON.stringify(`${prefix}${cfg.swFile}`);
51
+ const version = JSON.stringify(cfg.version);
52
+ const tags = [
53
+ `<link rel="manifest" href="${prefix}${cfg.manifestFile}" />`,
54
+ `<meta name="theme-color" content="${cfg.themeColor}" />`,
55
+ `<script>window.${PAGE_GLOBAL}={sw:${sw},version:${version}};if('serviceWorker' in navigator)navigator.serviceWorker.register(${sw}).catch(function(){});</script>`,
56
+ ].join('\n ');
57
+ if (html.includes('</head>'))
58
+ return html.replace(/\s*<\/head>/, `\n ${tags}\n </head>`);
59
+ return `${tags}\n${html}`;
60
+ }
61
+ /**
62
+ * Scope-relative paths to precache: every emitted chunk and asset except the worker and the
63
+ * manifest, plus `index.html`. Sorted so the list (and the version stamped from it) is stable.
64
+ */
65
+ export function precacheList(bundle, exclude) {
66
+ const skip = new Set(exclude);
67
+ const out = Object.values(bundle)
68
+ .map((o) => o.fileName)
69
+ .filter((f) => !skip.has(f) && !f.endsWith('.map') && !f.endsWith('.html'));
70
+ out.push('index.html');
71
+ return [...new Set(out)].sort();
72
+ }
73
+ /** FNV-1a over a string, as 8 hex digits. */
74
+ export function fnv1a(text) {
75
+ let h = 0x811c9dc5;
76
+ for (let i = 0; i < text.length; i++) {
77
+ h ^= text.charCodeAt(i);
78
+ h = Math.imul(h, 0x01000193) >>> 0;
79
+ }
80
+ return h.toString(16).padStart(8, '0');
81
+ }
82
+ /**
83
+ * The build's version stamp: a hash of the precache list. Every chunk and asset carries a content
84
+ * hash in its name, so any changed code or asset changes the list and the stamp, while an
85
+ * identical rebuild keeps it (no spurious update). `index.html` is not in the stamp: it is served
86
+ * network-first and its cached copy is refreshed on every online load, so a page-only change
87
+ * needs no new worker.
88
+ */
89
+ export function versionStamp(precache) {
90
+ return fnv1a(precache.join('\n'));
91
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The service worker's decisions as pure functions. They run in two places: in Vitest, as
3
+ * ordinary imports, and inside the emitted `sw.js`, where worker.ts pastes their
4
+ * `Function.prototype.toString()` ahead of the worker body. That is why every function here is
5
+ * self-contained: no imports, no closures over module state, no TS-only syntax that leaves a
6
+ * runtime trace (enums, parameter properties). Types are erased and leave plain JavaScript.
7
+ */
8
+ /** Cache names. The precache cache carries the build version; the runtime cache is shared. */
9
+ export declare const CACHE_PREFIX = "tc-pwa-";
10
+ export declare const PRECACHE_PREFIX = "tc-pwa-precache-";
11
+ export declare const RUNTIME_CACHE = "tc-pwa-runtime";
12
+ /** What the worker does with a fetch. */
13
+ export type Route = 'precache' | 'index' | 'runtime' | 'network';
14
+ /** The parts of a `Request` the routing reads, so a test can pass a plain object. */
15
+ export interface RouteRequest {
16
+ url: string;
17
+ method: string;
18
+ /** `'navigate'` for a page load. */
19
+ mode?: string;
20
+ }
21
+ /**
22
+ * Which cache strategy a request takes.
23
+ *
24
+ * - `precache`: a GET for a path in the precache set (the build's hashed assets): cache-first.
25
+ * - `index`: a navigation, or a GET for the scope root or `index.html`: network-first with the
26
+ * cached copy as the fallback.
27
+ * - `runtime`: a GET whose path starts with one of `runtimePrefixes`: stale-while-revalidate
28
+ * into the runtime cache.
29
+ * - `network`: everything else (other origins, non-GET, unknown paths): straight through.
30
+ *
31
+ * `scope` is the worker's scope URL (`self.registration.scope`), so paths are matched relative
32
+ * to where the game is served, not to the origin root.
33
+ */
34
+ export declare function routeFor(request: RouteRequest, precache: Set<string>, runtimePrefixes: string[], scope: string): Route;
35
+ /**
36
+ * Which runtime-cache entries to evict so that at most `max` remain, oldest first. `keys` is the
37
+ * cache's key list in insertion order (what `cache.keys()` returns).
38
+ */
39
+ export declare function trimPlan(keys: string[], max: number): string[];
40
+ /**
41
+ * Which caches an activating worker of `version` deletes: every cache of this package whose
42
+ * name is a precache of another version. The runtime cache and caches of other software stay.
43
+ */
44
+ export declare function staleCacheNames(names: string[], version: string): string[];
45
+ /** True when `incoming` is a build other than `current`; empty stamps never count as new. */
46
+ export declare function isNewVersion(current: string, incoming: string): boolean;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The service worker's decisions as pure functions. They run in two places: in Vitest, as
3
+ * ordinary imports, and inside the emitted `sw.js`, where worker.ts pastes their
4
+ * `Function.prototype.toString()` ahead of the worker body. That is why every function here is
5
+ * self-contained: no imports, no closures over module state, no TS-only syntax that leaves a
6
+ * runtime trace (enums, parameter properties). Types are erased and leave plain JavaScript.
7
+ */
8
+ /** Cache names. The precache cache carries the build version; the runtime cache is shared. */
9
+ export const CACHE_PREFIX = 'tc-pwa-';
10
+ export const PRECACHE_PREFIX = 'tc-pwa-precache-';
11
+ export const RUNTIME_CACHE = 'tc-pwa-runtime';
12
+ /**
13
+ * Which cache strategy a request takes.
14
+ *
15
+ * - `precache`: a GET for a path in the precache set (the build's hashed assets): cache-first.
16
+ * - `index`: a navigation, or a GET for the scope root or `index.html`: network-first with the
17
+ * cached copy as the fallback.
18
+ * - `runtime`: a GET whose path starts with one of `runtimePrefixes`: stale-while-revalidate
19
+ * into the runtime cache.
20
+ * - `network`: everything else (other origins, non-GET, unknown paths): straight through.
21
+ *
22
+ * `scope` is the worker's scope URL (`self.registration.scope`), so paths are matched relative
23
+ * to where the game is served, not to the origin root.
24
+ */
25
+ export function routeFor(request, precache, runtimePrefixes, scope) {
26
+ if (request.method !== 'GET')
27
+ return 'network';
28
+ let url;
29
+ let scopeUrl;
30
+ try {
31
+ url = new URL(request.url);
32
+ scopeUrl = new URL(scope);
33
+ }
34
+ catch {
35
+ return 'network';
36
+ }
37
+ if (url.origin !== scopeUrl.origin)
38
+ return 'network';
39
+ const scopePath = scopeUrl.pathname.endsWith('/') ? scopeUrl.pathname : `${scopeUrl.pathname}/`;
40
+ if (!url.pathname.startsWith(scopePath))
41
+ return 'network';
42
+ const rel = url.pathname.slice(scopePath.length);
43
+ if (request.mode === 'navigate' || rel === '' || rel === 'index.html')
44
+ return 'index';
45
+ if (precache.has(rel))
46
+ return 'precache';
47
+ for (const p of runtimePrefixes) {
48
+ if (rel.startsWith(p))
49
+ return 'runtime';
50
+ }
51
+ return 'network';
52
+ }
53
+ /**
54
+ * Which runtime-cache entries to evict so that at most `max` remain, oldest first. `keys` is the
55
+ * cache's key list in insertion order (what `cache.keys()` returns).
56
+ */
57
+ export function trimPlan(keys, max) {
58
+ if (max < 0 || keys.length <= max)
59
+ return [];
60
+ return keys.slice(0, keys.length - max);
61
+ }
62
+ /**
63
+ * Which caches an activating worker of `version` deletes: every cache of this package whose
64
+ * name is a precache of another version. The runtime cache and caches of other software stay.
65
+ */
66
+ export function staleCacheNames(names, version) {
67
+ const keep = `tc-pwa-precache-${version}`;
68
+ return names.filter((n) => n.startsWith('tc-pwa-precache-') && n !== keep);
69
+ }
70
+ /** True when `incoming` is a build other than `current`; empty stamps never count as new. */
71
+ export function isNewVersion(current, incoming) {
72
+ return incoming !== '' && incoming !== current;
73
+ }
package/dist/vite.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `pwaPlugin(opts)`: the Vite plugin that turns a game's build into an installable app. It
3
+ * imports nothing from the engine or the browser side of this package, so a `vite.config.ts` can
4
+ * load it from `@thunderclap-engine/host-pwa/vite` in Node without pulling the host in.
5
+ *
6
+ * Build only (`apply: 'build'`). Hooks, in the order Vite runs them:
7
+ *
8
+ * 1. `buildStart`: rasterises the SVG icon where the environment can (see `rasterise`).
9
+ * 2. `generateBundle`: reads the emitted chunks and assets into the precache list, stamps the
10
+ * version from it, and emits `manifest.webmanifest`, the icon(s) and `sw.js` (worker.ts).
11
+ * 3. `transformIndexHtml` (post: Vite's own html plugin runs the post hooks in its
12
+ * `generateBundle`, which comes after this plugin's): injects the manifest link, the theme
13
+ * colour and the registration snippet with that version (manifest.ts `injectTags`).
14
+ *
15
+ * Imports carry `.ts` extensions because a `vite.config.ts` may load this file through Vite's
16
+ * native config loader, which requires them.
17
+ */
18
+ import type { Plugin } from 'vite';
19
+ import { type ManifestOptions } from './manifest.js';
20
+ export interface PwaPluginOptions {
21
+ manifest: ManifestOptions;
22
+ /**
23
+ * Scope-relative path prefixes (`data/`, `levels/`) served stale-while-revalidate from the
24
+ * runtime cache: things the game fetches at play time that are not in the bundle. Default none.
25
+ */
26
+ runtimeCache?: string[];
27
+ /** Most entries the runtime cache keeps; the oldest are evicted first. Default 60. */
28
+ maxRuntimeEntries?: number;
29
+ /**
30
+ * The version stamp. Default: a hash of the precache list and the page, so an identical
31
+ * rebuild is not an update and any changed asset is.
32
+ */
33
+ version?: string;
34
+ /** Default `sw.js`. */
35
+ swFile?: string;
36
+ /** Default `manifest.webmanifest`. */
37
+ manifestFile?: string;
38
+ }
39
+ export declare function pwaPlugin(opts: PwaPluginOptions): Plugin;
package/dist/vite.js ADDED
@@ -0,0 +1,93 @@
1
+ import { buildManifest, ICON_SIZES, injectTags, pngIconFile, precacheList, SVG_ICON_FILE, versionStamp, } from './manifest.js';
2
+ import { workerSource } from './worker.js';
3
+ /**
4
+ * Rasterises an SVG to PNG bytes at `size`, or returns null where the build environment has no
5
+ * `OffscreenCanvas` (Node). Guarded so the plugin never fails a build over an icon.
6
+ */
7
+ async function rasterise(svg, size) {
8
+ const g = globalThis;
9
+ if (!g.OffscreenCanvas || !g.createImageBitmap)
10
+ return null;
11
+ try {
12
+ const bitmap = await g.createImageBitmap(new Blob([svg], { type: 'image/svg+xml' }));
13
+ const canvas = new g.OffscreenCanvas(size, size);
14
+ canvas.getContext('2d').drawImage(bitmap, 0, 0, size, size);
15
+ const blob = await canvas.convertToBlob({ type: 'image/png' });
16
+ return new Uint8Array(await blob.arrayBuffer());
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function pwaPlugin(opts) {
23
+ const swFile = opts.swFile ?? 'sw.js';
24
+ const manifestFile = opts.manifestFile ?? 'manifest.webmanifest';
25
+ const runtimePrefixes = opts.runtimeCache ?? [];
26
+ const maxRuntimeEntries = opts.maxRuntimeEntries ?? 60;
27
+ let base = '/';
28
+ let precache = [];
29
+ let version = opts.version ?? '';
30
+ let rasterised = false;
31
+ const svg = opts.manifest.svg;
32
+ let pngs = [];
33
+ const iconFiles = () => [
34
+ ...(svg ? [SVG_ICON_FILE] : []),
35
+ ...pngs.map((p) => pngIconFile(p.size)),
36
+ ];
37
+ return {
38
+ name: 'thunderclap:pwa',
39
+ apply: 'build',
40
+ configResolved(config) {
41
+ base = config.base;
42
+ },
43
+ async buildStart() {
44
+ pngs = [];
45
+ if (!svg)
46
+ return;
47
+ for (const size of ICON_SIZES) {
48
+ const bytes = await rasterise(svg, size);
49
+ if (bytes)
50
+ pngs.push({ size, bytes });
51
+ }
52
+ rasterised = pngs.length === ICON_SIZES.length;
53
+ if (!rasterised)
54
+ pngs = [];
55
+ },
56
+ transformIndexHtml: {
57
+ order: 'post',
58
+ handler(html) {
59
+ return injectTags(html, base, {
60
+ manifestFile,
61
+ swFile,
62
+ version,
63
+ themeColor: opts.manifest.theme_color ?? '#000000',
64
+ });
65
+ },
66
+ },
67
+ generateBundle(_options, bundle) {
68
+ precache = precacheList(bundle, [
69
+ swFile,
70
+ manifestFile,
71
+ ...iconFiles(),
72
+ ]);
73
+ if (!opts.version)
74
+ version = versionStamp(precache);
75
+ const manifest = buildManifest(opts.manifest, rasterised);
76
+ this.emitFile({
77
+ type: 'asset',
78
+ fileName: manifestFile,
79
+ source: `${JSON.stringify(manifest, null, 2)}\n`,
80
+ });
81
+ if (svg)
82
+ this.emitFile({ type: 'asset', fileName: SVG_ICON_FILE, source: svg });
83
+ for (const p of pngs) {
84
+ this.emitFile({ type: 'asset', fileName: pngIconFile(p.size), source: p.bytes });
85
+ }
86
+ this.emitFile({
87
+ type: 'asset',
88
+ fileName: swFile,
89
+ source: workerSource({ version, precache, runtimePrefixes, maxRuntimeEntries }),
90
+ });
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,14 @@
1
+ export interface WorkerConfig {
2
+ /** The build's version stamp. */
3
+ version: string;
4
+ /** Scope-relative paths to precache (hashed assets, plus `index.html`). */
5
+ precache: string[];
6
+ /** Scope-relative path prefixes served stale-while-revalidate from the runtime cache. */
7
+ runtimePrefixes: string[];
8
+ /** Most entries the runtime cache keeps; the oldest go first. */
9
+ maxRuntimeEntries: number;
10
+ }
11
+ /** The message a page posts to make a waiting worker take over. */
12
+ export declare const SKIP_WAITING = "tc-pwa:skip-waiting";
13
+ /** The complete `sw.js` for one build. */
14
+ export declare function workerSource(cfg: WorkerConfig): string;
package/dist/worker.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The service worker source. It is assembled, not compiled: the pure functions of sw-logic.ts are
3
+ * pasted in through `Function.prototype.toString()` (one source of truth, unit-tested as
4
+ * imports), followed by a body with four placeholders the plugin fills per build. The package
5
+ * ships TypeScript source through its exports map, like host-electron, so a worker kept as a
6
+ * separate TS file would need its own build step or a second copy of the routing; this needs
7
+ * neither. Every function pasted here must stay self-contained (see sw-logic.ts).
8
+ *
9
+ * Every cache lookup passes `ignoreVary`: the caches are keyed by URL, and a server that answers
10
+ * with `Vary: Origin` (vite preview, most CDNs) would otherwise make a module-script request
11
+ * (mode `cors`) miss the copy the worker stored from its own plain fetch.
12
+ */
13
+ import { routeFor, staleCacheNames, trimPlan } from './sw-logic.js';
14
+ /** The message a page posts to make a waiting worker take over. */
15
+ export const SKIP_WAITING = 'tc-pwa:skip-waiting';
16
+ const BODY = `
17
+ const VERSION = __VERSION__;
18
+ const PRECACHE = new Set(__PRECACHE__);
19
+ const RUNTIME_PREFIXES = __RUNTIME_PREFIXES__;
20
+ const MAX_RUNTIME = __MAX_RUNTIME__;
21
+ const PRECACHE_NAME = 'tc-pwa-precache-' + VERSION;
22
+ const RUNTIME_NAME = 'tc-pwa-runtime';
23
+ const scope = self.registration.scope;
24
+
25
+ self.addEventListener('install', (event) => {
26
+ event.waitUntil(
27
+ caches.open(PRECACHE_NAME).then((cache) =>
28
+ Promise.all(
29
+ [...PRECACHE].map((rel) =>
30
+ fetch(new Request(scope + rel, { cache: 'reload' })).then((res) => {
31
+ if (res.ok) return cache.put(scope + rel, res);
32
+ }),
33
+ ),
34
+ ),
35
+ ),
36
+ );
37
+ });
38
+
39
+ self.addEventListener('activate', (event) => {
40
+ event.waitUntil(
41
+ caches
42
+ .keys()
43
+ .then((names) => Promise.all(staleCacheNames(names, VERSION).map((n) => caches.delete(n))))
44
+ .then(() => self.clients.claim()),
45
+ );
46
+ });
47
+
48
+ self.addEventListener('message', (event) => {
49
+ if (event.data && event.data.type === ${JSON.stringify(SKIP_WAITING)}) self.skipWaiting();
50
+ });
51
+
52
+ async function trimRuntime(cache) {
53
+ const keys = await cache.keys();
54
+ const drop = trimPlan(keys.map((k) => k.url), MAX_RUNTIME);
55
+ await Promise.all(drop.map((url) => cache.delete(url)));
56
+ }
57
+
58
+ async function fromPrecache(request) {
59
+ const cache = await caches.open(PRECACHE_NAME);
60
+ const hit = await cache.match(request, { ignoreSearch: true, ignoreVary: true });
61
+ if (hit) return hit;
62
+ const res = await fetch(request);
63
+ if (res.ok) cache.put(request, res.clone());
64
+ return res;
65
+ }
66
+
67
+ async function indexNetworkFirst() {
68
+ const key = scope + 'index.html';
69
+ const cache = await caches.open(PRECACHE_NAME);
70
+ try {
71
+ const res = await fetch(new Request(key, { cache: 'no-cache' }));
72
+ if (res.ok) {
73
+ cache.put(key, res.clone());
74
+ return res;
75
+ }
76
+ const hit = await cache.match(key, { ignoreVary: true });
77
+ return hit || res;
78
+ } catch (err) {
79
+ const hit = await cache.match(key, { ignoreVary: true });
80
+ if (hit) return hit;
81
+ throw err;
82
+ }
83
+ }
84
+
85
+ async function staleWhileRevalidate(request) {
86
+ const cache = await caches.open(RUNTIME_NAME);
87
+ const hit = await cache.match(request, { ignoreVary: true });
88
+ const refresh = fetch(request)
89
+ .then(async (res) => {
90
+ if (res.ok) {
91
+ await cache.put(request, res.clone());
92
+ await trimRuntime(cache);
93
+ }
94
+ return res;
95
+ })
96
+ .catch((err) => {
97
+ if (hit) return hit;
98
+ throw err;
99
+ });
100
+ return hit || refresh;
101
+ }
102
+
103
+ self.addEventListener('fetch', (event) => {
104
+ const route = routeFor(event.request, PRECACHE, RUNTIME_PREFIXES, scope);
105
+ if (route === 'precache') event.respondWith(fromPrecache(event.request));
106
+ else if (route === 'index') event.respondWith(indexNetworkFirst());
107
+ else if (route === 'runtime') event.respondWith(staleWhileRevalidate(event.request));
108
+ });
109
+ `;
110
+ /** The complete `sw.js` for one build. */
111
+ export function workerSource(cfg) {
112
+ const fns = [routeFor, trimPlan, staleCacheNames].map((f) => f.toString());
113
+ const body = BODY.replace('__VERSION__', JSON.stringify(cfg.version))
114
+ .replace('__PRECACHE__', JSON.stringify(cfg.precache))
115
+ .replace('__RUNTIME_PREFIXES__', JSON.stringify(cfg.runtimePrefixes))
116
+ .replace('__MAX_RUNTIME__', String(Math.max(0, Math.floor(cfg.maxRuntimeEntries))));
117
+ return `/* @thunderclap-engine/host-pwa service worker, build ${cfg.version} */\n'use strict';\n${fns.join('\n')}\n${body}`;
118
+ }
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "@thunderclap-engine/host-pwa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "ThunderClap's installable web shell: a Vite plugin that emits a game's web manifest and service worker at build time, and createPwaHost(), the web Host plus install, update, offline and cache state. It knows nothing about any game and has no runtime dependencies.",
6
+ "license": "MIT",
7
+ "author": "ThunderClap",
8
+ "homepage": "https://github.com/Mandalorian007/thunderclap-engine/tree/main/packages/host-pwa#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/Mandalorian007/thunderclap-engine/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Mandalorian007/thunderclap-engine.git",
15
+ "directory": "packages/host-pwa"
16
+ },
17
+ "keywords": [
18
+ "thunderclap",
19
+ "pwa",
20
+ "service-worker",
21
+ "vite-plugin",
22
+ "webgpu",
23
+ "game-engine"
24
+ ],
25
+ "sideEffects": false,
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ },
31
+ "./vite": {
32
+ "types": "./dist/vite.d.ts",
33
+ "import": "./dist/vite.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "peerDependencies": {
47
+ "@thunderclap-engine/engine": "^1.0.0",
48
+ "vite": ">=7.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "vite": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^2.6.4",
57
+ "@playwright/test": "^1.64.1",
58
+ "@types/node": "^26.6.6",
59
+ "@webgpu/types": "^0.1.74",
60
+ "happy-dom": "^21.0.4",
61
+ "typescript": "^7.0.2",
62
+ "vite": "^8.3.5",
63
+ "vitest": "^5.0.5",
64
+ "@thunderclap-engine/engine": "1.1.0"
65
+ },
66
+ "scripts": {
67
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
68
+ "lint": "biome check .",
69
+ "test": "vitest run",
70
+ "check": "pnpm typecheck && pnpm lint && pnpm test",
71
+ "build": "vite build harness",
72
+ "dev": "vite harness --host 127.0.0.1 --port 5198",
73
+ "preview": "vite preview harness --host 127.0.0.1 --port 4551 --strictPort",
74
+ "test:e2e": "vite build harness && playwright test"
75
+ },
76
+ "types": "./dist/index.d.ts",
77
+ "main": "./dist/index.js"
6
78
  }