@powerhousedao/builder-tools 6.2.2-dev.4 → 6.2.2-dev.40

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.
@@ -0,0 +1,352 @@
1
+ /// <reference lib="webworker" />
2
+ //
3
+ // Connect's service worker (injectManifest strategy).
4
+ //
5
+ // This file is NOT bundled into @powerhousedao/builder-tools' dist entry — it
6
+ // is copied verbatim into `dist/service-worker/` and compiled by
7
+ // vite-plugin-pwa's own build pass at the CONSUMER's build time (see
8
+ // connectPwaPlugins in ../vite-plugins/pwa.ts). It is deliberately excluded
9
+ // from the package's `tsc` project (webworker globals + the `virtual:` import
10
+ // don't belong to the Node build graph).
11
+ //
12
+ // It reproduces what Workbox `generateSW` used to bake from BASE_WORKBOX — the
13
+ // precache, the runtime-caching rules, the navigation fallback and the
14
+ // prompt/SKIP_WAITING lifecycle — PLUS the one thing generateSW cannot do:
15
+ // serve a DYNAMIC web-app manifest, merging the build-embedded base with the
16
+ // fragments dynamically-installed packages mirror into IndexedDB.
17
+ //
18
+ // Route registration order matters — Workbox is first-match-wins.
19
+
20
+ import { CacheableResponsePlugin } from "workbox-cacheable-response";
21
+ import { clientsClaim } from "workbox-core";
22
+ import { ExpirationPlugin } from "workbox-expiration";
23
+ import {
24
+ cleanupOutdatedCaches,
25
+ createHandlerBoundToURL,
26
+ precacheAndRoute,
27
+ } from "workbox-precaching";
28
+ import { registerRoute, Route } from "workbox-routing";
29
+ import {
30
+ CacheFirst,
31
+ CacheOnly,
32
+ NetworkFirst,
33
+ NetworkOnly,
34
+ StaleWhileRevalidate,
35
+ } from "workbox-strategies";
36
+ // Barrel import (not a subpath): shared's bundler only emits dist/connect/
37
+ // index.js, so subpaths like ./connect/pwa-manifest have no dist target. The
38
+ // barrel is `sideEffects: false`, so rolldown tree-shakes the unused siblings
39
+ // (env-config/json-adapter) — including their dynamic node:fs import — out of
40
+ // the worker bundle. The IDB schema constants are shared with the SPA writer
41
+ // (apps/connect/src/utils/pwa-idb.ts) so the two can't desync.
42
+ import {
43
+ mergeManifest,
44
+ type PHConnectPwaUrlPattern,
45
+ PWA_IDB_KEY,
46
+ PWA_IDB_NAME,
47
+ PWA_IDB_STORE,
48
+ PWA_IDB_VERSION,
49
+ toRegExpOrString,
50
+ } from "@powerhousedao/shared/connect";
51
+ import {
52
+ EMBEDDED_BASE_MANIFEST,
53
+ EXTRA_RUNTIME_CACHING,
54
+ NAVIGATE_FALLBACK_DENYLIST_EXTRA,
55
+ } from "virtual:ph-sw-config";
56
+
57
+ declare const self: ServiceWorkerGlobalScope &
58
+ typeof globalThis & {
59
+ // The injectManifest injection point (replaced with the precache list).
60
+ __WB_MANIFEST: Array<{ url: string; revision: string | null }>;
61
+ };
62
+
63
+ // ── lifecycle ──────────────────────────────────────────────────────────────
64
+ // skipWaiting is NOT called on install: the SPA surfaces a refresh prompt and
65
+ // posts SKIP_WAITING when the user accepts (identical contract to the old
66
+ // generateSW `registerType: "prompt"` — registerServiceWorker.ts is unchanged).
67
+ self.addEventListener("message", (event) => {
68
+ if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
69
+ });
70
+ clientsClaim();
71
+ cleanupOutdatedCaches();
72
+
73
+ // A runtime-installed package may override cosmetic manifest scalars (name,
74
+ // theme, display, …) — full parity with a build-time package — but NOT these
75
+ // navigation-critical ones: re-pointing start_url or re-scoping the installed
76
+ // PWA could break or hijack navigation, so they stay whatever the build baked.
77
+ const RUNTIME_PROTECTED_SCALARS = ["start_url", "scope"] as const;
78
+
79
+ // ── dynamic web-app manifest ─────────────────────────────────────────────────
80
+ // Registered before precache so it always wins for manifest.webmanifest (which
81
+ // is also excluded from the precache glob). Every request reads IndexedDB
82
+ // fresh, so a package install/removal is reflected on the next manifest fetch
83
+ // with no service-worker restart. Fragment-wins (with start_url/scope
84
+ // protected), so a dynamically-installed package extends and overrides the
85
+ // manifest exactly like a build-time contribution.
86
+ type DynamicFragmentRecord = {
87
+ fragment?: {
88
+ manifest?: unknown;
89
+ runtimeCaching?: unknown;
90
+ navigateFallbackDenylist?: unknown;
91
+ };
92
+ };
93
+
94
+ async function readDynamicFragmentRecord(): Promise<
95
+ DynamicFragmentRecord | undefined
96
+ > {
97
+ return new Promise((resolve) => {
98
+ let request: IDBOpenDBRequest;
99
+ try {
100
+ request = indexedDB.open(PWA_IDB_NAME, PWA_IDB_VERSION);
101
+ } catch {
102
+ resolve(undefined);
103
+ return;
104
+ }
105
+ // If the DB doesn't exist yet (no package has synced), ABORT the upgrade so
106
+ // we don't leave a store-less v1 DB behind — that would later block the
107
+ // page's own open (same version → no upgrade → missing store → write
108
+ // fails). The page owns creating the DB/store.
109
+ request.onupgradeneeded = () => {
110
+ try {
111
+ request.transaction?.abort();
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ };
116
+ request.onerror = () => resolve(undefined);
117
+ request.onblocked = () => resolve(undefined);
118
+ request.onsuccess = () => {
119
+ const db = request.result;
120
+ if (!db.objectStoreNames.contains(PWA_IDB_STORE)) {
121
+ db.close();
122
+ resolve(undefined);
123
+ return;
124
+ }
125
+ try {
126
+ const tx = db.transaction(PWA_IDB_STORE, "readonly");
127
+ const getReq = tx.objectStore(PWA_IDB_STORE).get(PWA_IDB_KEY);
128
+ getReq.onsuccess = () => {
129
+ resolve(getReq.result);
130
+ db.close();
131
+ };
132
+ getReq.onerror = () => {
133
+ resolve(undefined);
134
+ db.close();
135
+ };
136
+ } catch {
137
+ resolve(undefined);
138
+ db.close();
139
+ }
140
+ };
141
+ });
142
+ }
143
+
144
+ registerRoute(
145
+ ({ url }) => url.pathname.endsWith("manifest.webmanifest"),
146
+ async () => {
147
+ let manifest: unknown = EMBEDDED_BASE_MANIFEST;
148
+ try {
149
+ const record = await readDynamicFragmentRecord();
150
+ const fragmentManifest = record?.fragment?.manifest;
151
+ if (fragmentManifest) {
152
+ manifest = mergeManifest(
153
+ EMBEDDED_BASE_MANIFEST as Record<string, unknown>,
154
+ // The record was validated by the page before it was written.
155
+ fragmentManifest as never,
156
+ {
157
+ scalarPolicy: "fragment-wins",
158
+ protectedScalars: RUNTIME_PROTECTED_SCALARS,
159
+ },
160
+ );
161
+ }
162
+ } catch {
163
+ // Fall back to the embedded base — a broken fragment must never take the
164
+ // manifest down.
165
+ }
166
+ return new Response(JSON.stringify(manifest), {
167
+ headers: {
168
+ "Content-Type": "application/manifest+json",
169
+ "Cache-Control": "no-cache",
170
+ },
171
+ });
172
+ },
173
+ );
174
+
175
+ // ── precache ─────────────────────────────────────────────────────────────────
176
+ // Precache the app shell + PGlite wasm/data. manifest.webmanifest is excluded
177
+ // from the glob (see connectPwaPlugins) so the dynamic route above is the sole
178
+ // producer at that URL. The __WB_MANIFEST property below is the injectManifest
179
+ // injection point — workbox-build replaces it with the precache list at build
180
+ // time (the token is written once, on the code line, never in prose).
181
+ precacheAndRoute(self.__WB_MANIFEST);
182
+
183
+ // ── runtime caching (ported 1:1 from the old BASE_WORKBOX, same order) ───────
184
+ // Inter font stays on Google's CDN; cache it after the first online load.
185
+ registerRoute(
186
+ ({ url }) => url.origin === "https://fonts.googleapis.com",
187
+ new StaleWhileRevalidate({ cacheName: "google-fonts-stylesheets" }),
188
+ );
189
+ registerRoute(
190
+ ({ url }) => url.origin === "https://fonts.gstatic.com",
191
+ new CacheFirst({
192
+ cacheName: "google-fonts-webfonts",
193
+ plugins: [
194
+ new ExpirationPlugin({
195
+ maxEntries: 30,
196
+ maxAgeSeconds: 60 * 60 * 24 * 365,
197
+ }),
198
+ // statuses [0, 200]: 0 permits opaque cross-origin font responses.
199
+ new CacheableResponsePlugin({ statuses: [0, 200] }),
200
+ ],
201
+ }),
202
+ );
203
+ // Document-model editors/packages loaded at runtime from the registry CDN. The
204
+ // registry ORIGIN is a runtime value, so match the stable "/-/cdn/" path. Two
205
+ // rules (order matters): unversioned ENTRY points first (SWR — a newer editor
206
+ // refreshes online, cached copy serves offline), then a catch-all CacheFirst
207
+ // for the content-hashed (immutable) assets. statuses [0, 200] on both — the
208
+ // editor JS is a CORS import (200) but its stylesheet/assets are no-cors
209
+ // (opaque/0); [200] alone silently dropped those and broke styles offline.
210
+ registerRoute(
211
+ ({ url }) =>
212
+ url.pathname.includes("/-/cdn/") &&
213
+ (url.pathname.endsWith("/browser/index.js") ||
214
+ url.pathname.endsWith("/style.css") ||
215
+ url.pathname.endsWith("/package.json")),
216
+ new StaleWhileRevalidate({
217
+ cacheName: "ph-package-cdn-entry",
218
+ plugins: [
219
+ new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 30 }),
220
+ new CacheableResponsePlugin({ statuses: [0, 200] }),
221
+ ],
222
+ }),
223
+ );
224
+ registerRoute(
225
+ ({ url }) => url.pathname.includes("/-/cdn/"),
226
+ new CacheFirst({
227
+ cacheName: "ph-package-cdn",
228
+ plugins: [
229
+ new ExpirationPlugin({
230
+ maxEntries: 200,
231
+ maxAgeSeconds: 60 * 60 * 24 * 30,
232
+ }),
233
+ new CacheableResponsePlugin({ statuses: [0, 200] }),
234
+ ],
235
+ }),
236
+ );
237
+ // Runtime config: NetworkFirst so a fresh value wins online, last-known serves
238
+ // offline (it is precache-ignored). The timeout stops a flaky network stalling
239
+ // boot until the browser's own fetch timeout.
240
+ registerRoute(
241
+ ({ url }) => url.pathname.endsWith("/powerhouse.config.json"),
242
+ new NetworkFirst({ cacheName: "ph-runtime-config", networkTimeoutSeconds: 5 }),
243
+ );
244
+
245
+ // ── extra runtime caching ────────────────────────────────────────────────────
246
+ // Appended AFTER the built-ins — Workbox is first-match-wins, so a contribution
247
+ // cannot shadow a built-in rule for the same URL (intentional). Two sources:
248
+ // build-time package/project rules (EXTRA_RUNTIME_CACHING, registered now) and
249
+ // runtime-installed package rules (read from IndexedDB at startup below).
250
+ // All five Workbox strategies a contributed rule may name, so a rule's caching
251
+ // semantics are honored faithfully (e.g. NetworkOnly never caches — remapping
252
+ // it to NetworkFirst would silently start caching an endpoint a package meant
253
+ // to keep uncached).
254
+ const STRATEGIES = {
255
+ CacheFirst,
256
+ CacheOnly,
257
+ NetworkFirst,
258
+ NetworkOnly,
259
+ StaleWhileRevalidate,
260
+ } as const;
261
+
262
+ type RuntimeCachingRule = (typeof EXTRA_RUNTIME_CACHING)[number];
263
+
264
+ function registerRuntimeCachingRule(rule: RuntimeCachingRule) {
265
+ const Strategy = STRATEGIES[rule.handler] ?? StaleWhileRevalidate;
266
+ const options = rule.options ?? {};
267
+ const plugins = [];
268
+ if (options.expiration) {
269
+ plugins.push(new ExpirationPlugin(options.expiration));
270
+ }
271
+ if (options.cacheableResponse) {
272
+ plugins.push(new CacheableResponsePlugin(options.cacheableResponse));
273
+ }
274
+ registerRoute(
275
+ toRegExpOrString(rule.urlPattern),
276
+ new Strategy({
277
+ ...(options.cacheName ? { cacheName: options.cacheName } : {}),
278
+ ...(typeof options.networkTimeoutSeconds === "number"
279
+ ? { networkTimeoutSeconds: options.networkTimeoutSeconds }
280
+ : {}),
281
+ plugins,
282
+ }),
283
+ rule.method ?? "GET",
284
+ );
285
+ }
286
+
287
+ for (const rule of EXTRA_RUNTIME_CACHING) registerRuntimeCachingRule(rule);
288
+
289
+ // ── navigation fallback ──────────────────────────────────────────────────────
290
+ // SPA fallback to index.html (resolved against the SW's own location, so it
291
+ // tracks the deploy base). The `denylist` is MUTABLE and read live on every
292
+ // request: it is seeded synchronously with the built-ins plus the BUILD-TIME
293
+ // contributed patterns (NAVIGATE_FALLBACK_DENYLIST_EXTRA), and the async read
294
+ // below pushes any RUNTIME-installed package's patterns into it. Registering
295
+ // the route synchronously keeps offline SPA navigation working the instant a
296
+ // cold SW starts; the runtime patterns just join in once they've loaded.
297
+ function escapeForRegExp(value: string): string {
298
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
299
+ }
300
+ // Workbox denylists match against `pathname + search`; a plain string is
301
+ // matched as a literal substring (escaped), mirroring the built-ins.
302
+ function toDenylistRegExp(pattern: PHConnectPwaUrlPattern): RegExp {
303
+ const value = toRegExpOrString(pattern);
304
+ return typeof value === "string" ? new RegExp(escapeForRegExp(value)) : value;
305
+ }
306
+ const denylist: RegExp[] = [
307
+ /\/powerhouse\.config\.json$/,
308
+ /^\/health$/,
309
+ /\/__/,
310
+ ...NAVIGATE_FALLBACK_DENYLIST_EXTRA.map(toDenylistRegExp),
311
+ ];
312
+ // A plain Route (not NavigationRoute, which snapshots its denylist at
313
+ // construction) so the match reads the mutable `denylist` live — same
314
+ // navigation-request + denylist semantics, minus the frozen list.
315
+ registerRoute(
316
+ new Route(
317
+ ({ request, url }) =>
318
+ request.mode === "navigate" &&
319
+ !denylist.some((re) => re.test(url.pathname + url.search)),
320
+ createHandlerBoundToURL("index.html"),
321
+ ),
322
+ );
323
+
324
+ // ── dynamic contributions from runtime-installed packages ────────────────────
325
+ // One IndexedDB read at SW startup applies BOTH the runtime-caching rules and
326
+ // the navigate-fallback denylist patterns a runtime-installed package
327
+ // contributes (the SPA mirrors the merged fragment there). Best-effort: they
328
+ // register shortly after startup, so a package installed in THIS session takes
329
+ // full effect on the SW's next activation — the same model for both. The
330
+ // manifest route above is the live path. A malformed entry never aborts the rest.
331
+ void readDynamicFragmentRecord().then((record) => {
332
+ const rules = record?.fragment?.runtimeCaching;
333
+ if (Array.isArray(rules)) {
334
+ for (const rule of rules) {
335
+ try {
336
+ registerRuntimeCachingRule(rule as RuntimeCachingRule);
337
+ } catch {
338
+ // skip a malformed rule
339
+ }
340
+ }
341
+ }
342
+ const patterns = record?.fragment?.navigateFallbackDenylist;
343
+ if (Array.isArray(patterns)) {
344
+ for (const pattern of patterns) {
345
+ try {
346
+ denylist.push(toDenylistRegExp(pattern as PHConnectPwaUrlPattern));
347
+ } catch {
348
+ // skip a malformed pattern
349
+ }
350
+ }
351
+ }
352
+ });
@@ -0,0 +1,22 @@
1
+ // Ambient types for the build-time-generated virtual module the service worker
2
+ // imports. The concrete values are supplied by `phSwConfigPlugin` (see
3
+ // ../vite-plugins/pwa.ts), which resolves `virtual:ph-sw-config` during
4
+ // vite-plugin-pwa's injectManifest build pass.
5
+ declare module "virtual:ph-sw-config" {
6
+ import type {
7
+ PHConnectPwaRuntimeCaching,
8
+ PHConnectPwaUrlPattern,
9
+ } from "@powerhousedao/shared/connect";
10
+
11
+ /** The build-effective web-app manifest (Connect base + build-time package
12
+ * fragments + project config), the base the runtime manifest route merges
13
+ * dynamically-installed package fragments onto. */
14
+ export const EMBEDDED_BASE_MANIFEST: Record<string, unknown>;
15
+
16
+ /** Serialisable runtime-caching rules contributed at build time, registered
17
+ * after the built-in rules. */
18
+ export const EXTRA_RUNTIME_CACHING: PHConnectPwaRuntimeCaching[];
19
+
20
+ /** Extra SPA navigate-fallback denylist patterns. */
21
+ export const NAVIGATE_FALLBACK_DENYLIST_EXTRA: PHConnectPwaUrlPattern[];
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powerhousedao/builder-tools",
3
- "version": "6.2.2-dev.4",
3
+ "version": "6.2.2-dev.40",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -29,9 +29,15 @@
29
29
  "vite": "8.0.10",
30
30
  "vite-plugin-html": "3.2.2",
31
31
  "vite-plugin-pwa": "1.3.0",
32
- "@powerhousedao/config": "6.2.2-dev.4",
33
- "document-model": "6.2.2-dev.4",
34
- "@powerhousedao/shared": "6.2.2-dev.4"
32
+ "workbox-cacheable-response": "7.4.1",
33
+ "workbox-core": "7.4.1",
34
+ "workbox-expiration": "7.4.1",
35
+ "workbox-precaching": "7.4.1",
36
+ "workbox-routing": "7.4.1",
37
+ "workbox-strategies": "7.4.1",
38
+ "@powerhousedao/shared": "6.2.2-dev.40",
39
+ "@powerhousedao/config": "6.2.2-dev.40",
40
+ "document-model": "6.2.2-dev.40"
35
41
  },
36
42
  "devDependencies": {
37
43
  "@sentry/vite-plugin": "^4.3.0",