@ultimat3/cli 18.0.0 → 19.0.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/src/messages.ts CHANGED
@@ -11,6 +11,10 @@ const CATALOG = {
11
11
  'cli.commands.heading': 'commands',
12
12
  'cli.build.done': 'built {target}',
13
13
  'cli.build.failed': '{target} build failed',
14
+ // A build-output line, so it belongs here rather than inline beside the emitter: `x build` is a
15
+ // human surface and every other word it prints comes from this catalog.
16
+ 'cli.build.pushUnwired':
17
+ 'pwa.push is true and no VAPID key is configured, so the emitted sw.js carries no push handler',
14
18
  // `describeCron`'s vocabulary. `@ultimat3/time` is tier 1 and reaches no i18n runtime, so the
15
19
  // caller supplies the words — and the caller here is a rendered `x tasks show` line, which is
16
20
  // exactly what this catalog holds. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/
package/src/prerender.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { join } from 'node:path';
7
7
  import { createContext, renderThrowable, runWithContext } from '@ultimat3/core';
8
8
  import type { RouteEntry } from '@ultimat3/render';
9
- import { routeEntries } from '@ultimat3/render';
9
+ import { describeRoutes, routeEntries } from '@ultimat3/render';
10
10
  import { renderStatic } from '@ultimat3/render/server';
11
11
  import { loadApp } from './app-load';
12
12
  import { appManifest } from './app-manifest';
@@ -20,6 +20,7 @@ import { buildIslands, writeIslands } from './island-bundle';
20
20
  import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
21
21
  import type { SkippedRoute, UnmeasuredRoute } from './static-report';
22
22
  import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
23
+ import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, serviceWorkerArtifacts } from './sw-artifacts';
23
24
 
24
25
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
25
26
  // carries it, and this file already imports that module.
@@ -76,6 +77,15 @@ export interface PrerenderReport {
76
77
  readonly report: string;
77
78
  /** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
78
79
  readonly islands: readonly string[];
80
+ /**
81
+ * What the service worker could not express, and what its precache manifest weighs too much of.
82
+ *
83
+ * `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
84
+ * `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
85
+ * that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
86
+ * has to reach the build's own report. Empty for an app with no service worker.
87
+ */
88
+ readonly serviceWorkerWarnings: readonly string[];
79
89
  }
80
90
 
81
91
  /**
@@ -154,6 +164,18 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
154
164
  // wiring exists to close. `undefined` when the app is not installable, and then no document
155
165
  // names it either.
156
166
  const pwa = await loadPwaArtifacts(options.root);
167
+ // The worker and its registration script, written as FILES. A static host runs no route table,
168
+ // so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the
169
+ // artifact — the same promise `favicon.ico` and the icons above keep, for the asset that decides
170
+ // whether the export works offline at all.
171
+ const serviceWorker =
172
+ pwa === undefined
173
+ ? undefined
174
+ : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands });
175
+ if (serviceWorker !== undefined) {
176
+ await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
177
+ await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
178
+ }
157
179
  if (pwa !== undefined) {
158
180
  await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
159
181
  // And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
@@ -176,7 +198,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
176
198
  runWithContext(ctx, () =>
177
199
  routeDocument(entry, data, {
178
200
  resolveIsland: (file: string) => islands.resolverFor(file),
179
- ...(pwa === undefined ? {} : { pwaHead: pwa.head }),
201
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
180
202
  }),
181
203
  );
182
204
 
@@ -267,6 +289,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
267
289
  // stdout — so the one command the finding tells an author to run printed no `unmeasured` key
268
290
  // and no reason. Written into the report is what makes the instruction true.
269
291
  unmeasured,
292
+ serviceWorkerWarnings: serviceWorker?.warnings ?? [],
270
293
  });
271
294
  return {
272
295
  out: options.out,
@@ -277,5 +300,6 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
277
300
  stats,
278
301
  report,
279
302
  islands: islands.chunks.map((chunk) => chunk.file),
303
+ serviceWorkerWarnings: serviceWorker?.warnings ?? [],
280
304
  };
281
305
  }
@@ -22,7 +22,7 @@ import { existsSync } from 'node:fs';
22
22
  // why: Bun exposes no path-join primitive, and `APP_CONFIG_FILE` is app-root-relative — the same
23
23
  // necessity `favicon.ts` and `dev-assets.ts` each record for their own root-relative constant.
24
24
  import { join } from 'node:path';
25
- import type { PwaColors } from '@ultimat3/core';
25
+ import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
26
26
  import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
27
27
  import { applyCacheHeaders } from '@ultimat3/http';
28
28
  import {
@@ -56,6 +56,15 @@ export interface PwaArtifacts {
56
56
  * no manifest is an iOS icon for an app iOS will not add.
57
57
  */
58
58
  readonly head: string;
59
+ /**
60
+ * The three `pwa` keys the SERVICE WORKER needs, carried here because this is the one module
61
+ * that reads an app's config file — `sw-artifacts.ts` needs the route table and the island
62
+ * bundle as well, and a second `await import` of `app.config.ts` would be a second answer to
63
+ * "what did this app declare".
64
+ */
65
+ readonly offline: PwaOfflineConfig;
66
+ readonly backgroundSync: boolean;
67
+ readonly push: boolean;
59
68
  }
60
69
 
61
70
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -93,6 +102,9 @@ function colorsOf(value: unknown): PwaColors | undefined {
93
102
  interface InstallableApp {
94
103
  readonly name: string;
95
104
  readonly colors: PwaColors;
105
+ readonly offline: PwaOfflineConfig;
106
+ readonly backgroundSync: boolean;
107
+ readonly push: boolean;
96
108
  }
97
109
 
98
110
  async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
@@ -109,9 +121,36 @@ async function loadInstallable(root: string): Promise<InstallableApp | undefined
109
121
  const name = text(pwa['name']);
110
122
  const colors = colorsOf(pwa['colors']);
111
123
  if (name === undefined || colors === undefined) return undefined;
112
- return { name, colors };
124
+ return { name, colors, offline: offlineOf(pwa['offline']), ...flags(pwa) };
125
+ }
126
+
127
+ /**
128
+ * The offline block, read structurally for `colorsOf`'s reason: `defineConfig` refuses
129
+ * `enabled: true` without an absolute `offline.fallback`, but a HAND-WRITTEN config object never
130
+ * passed through it. A missing or relative fallback answers `null`, and `serviceWorkerArtifacts`
131
+ * then emits no worker at all — never a path the framework invented, which offline would be a
132
+ * cached 404 answering every navigation.
133
+ */
134
+ function offlineOf(value: unknown): PwaOfflineConfig {
135
+ const block = isRecord(value) ? value : {};
136
+ const fallback = text(block['fallback']);
137
+ const patterns = block['neverCache'];
138
+ return {
139
+ fallback: fallback?.startsWith('/') === true ? fallback : null,
140
+ image: text(block['image']) ?? null,
141
+ font: text(block['font']) ?? null,
142
+ neverCache: Array.isArray(patterns)
143
+ ? patterns.filter((entry): entry is string => typeof entry === 'string')
144
+ : [],
145
+ };
113
146
  }
114
147
 
148
+ /** `=== true` for `enabled`'s reason: a hand-written `backgroundSync: 'yes'` wires nothing. */
149
+ const flags = (pwa: Record<string, unknown>): { backgroundSync: boolean; push: boolean } => ({
150
+ backgroundSync: pwa['backgroundSync'] === true,
151
+ push: pwa['push'] === true,
152
+ });
153
+
115
154
  /**
116
155
  * Resolved ONCE at boot, like `loadSignInPath` and `loadCacheTiers` and unlike `faviconBytes`:
117
156
  * `await import` caches the module, so re-reading per request would answer the same object at a
@@ -139,6 +178,9 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
139
178
  icons: icons?.manifestIcons ?? [],
140
179
  });
141
180
  return {
181
+ offline: app.offline,
182
+ backgroundSync: app.backgroundSync,
183
+ push: app.push,
142
184
  body: serializeWebManifest(result.manifest),
143
185
  head:
144
186
  `<link rel="manifest" href="${escapeAttribute(WEB_MANIFEST_PATH)}">` +
package/src/serve.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  migrate,
21
21
  } from '@ultimat3/db';
22
22
  import type { Route } from '@ultimat3/http';
23
+ import { describeRoutes } from '@ultimat3/render';
23
24
  import { createIsrController } from '@ultimat3/render/server';
24
25
  import { apiRoutes } from './api-routes';
25
26
  import { loadSignInPath } from './app-auth';
@@ -46,6 +47,8 @@ import { readMigrations } from './migrations';
46
47
  import { startOtlpExport } from './otlp-export';
47
48
  import { loadPwaArtifacts } from './pwa-artifacts';
48
49
  import type { RuntimeOverrides } from './runtime-overrides';
50
+ import { serviceWorkerArtifacts } from './sw-artifacts';
51
+ import { serviceWorkerRoutes } from './sw-routes';
49
52
 
50
53
  export const DEFAULT_PORT = 3000;
51
54
 
@@ -303,8 +306,16 @@ async function bootRoles(boot: {
303
306
  // on a laptop and absent in the image is exactly the dev/prod difference this file exists to
304
307
  // prevent, and it is the one an operator cannot see without installing the app.
305
308
  const pwa = await loadPwaArtifacts(options.root);
309
+ // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
310
+ // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
311
+ // route added here cannot be missing from the precache manifest.
312
+ const serviceWorker =
313
+ pwa === undefined
314
+ ? undefined
315
+ : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands });
306
316
  const routes: readonly Route[] = [
307
317
  ...apiRoutes(),
318
+ ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
308
319
  ...assetRoutes({
309
320
  root: options.root,
310
321
  storage: runtime.storage,
@@ -316,7 +327,7 @@ async function bootRoles(boot: {
316
327
  ...appRoutes({
317
328
  buildId,
318
329
  resolveIsland: (file) => islands.resolverFor(file),
319
- ...(pwa === undefined ? {} : { pwaHead: pwa.head }),
330
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
320
331
  // Only when a store was supplied. `createIsrController` defaults to a per-process memory
321
332
  // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
322
333
  // the fleet while the other eleven keep serving the page it just invalidated.
@@ -13,6 +13,7 @@ import { RENDER_MODES } from '@ultimat3/core';
13
13
  import type { Surface } from '@ultimat3/render';
14
14
  import { SURFACE_SPECS, SURFACES, surfaceAllows } from '@ultimat3/render';
15
15
  import type { JsonValue } from './output';
16
+ import { SERVICE_WORKER_PATH } from './sw-artifacts';
16
17
 
17
18
  /** Beside `.x/build-stats.json`, and written by the same call — see `readStaticReport` below. */
18
19
  export const STATIC_REPORT_FILE = join('.x', 'static-report.json');
@@ -84,6 +85,15 @@ export type StaticReport = {
84
85
  * the reader.
85
86
  */
86
87
  readonly unmeasured: readonly UnmeasuredRoute[];
88
+ /**
89
+ * The service worker's own findings: a capability declared with nothing to wire it to, and a
90
+ * precache manifest over its byte ceiling. `PrecacheManifest.warnings` had no reader anywhere in
91
+ * the tree (#390), so the ceiling was — in `wiki/Troubleshooting.md`'s own words — "a designed
92
+ * thing that is not one". Written here for `unmeasured`'s reason: `cmd-build.ts` discards a
93
+ * successful subprocess's stdout, so a warning that lives only on the in-process report reaches
94
+ * nobody. Empty for an app with no service worker.
95
+ */
96
+ readonly serviceWorkerWarnings: readonly string[];
87
97
  };
88
98
 
89
99
  /**
@@ -177,7 +187,7 @@ const isEmitted = (value: unknown): value is EmittedPage =>
177
187
  */
178
188
  export function parseStaticReport(value: unknown): StaticReport | undefined {
179
189
  if (!isRecord(value)) return undefined;
180
- const { target, out, buildId, emitted, skipped, unmeasured } = value;
190
+ const { target, out, buildId, emitted, skipped, unmeasured, serviceWorkerWarnings } = value;
181
191
  if (target !== 'static' || typeof out !== 'string' || typeof buildId !== 'string') {
182
192
  return undefined;
183
193
  }
@@ -190,7 +200,25 @@ export function parseStaticReport(value: unknown): StaticReport | undefined {
190
200
  if (unmeasured !== undefined && (!Array.isArray(unmeasured) || !unmeasured.every(isUnmeasured))) {
191
201
  return undefined;
192
202
  }
193
- return { target, out, buildId, emitted, skipped, unmeasured: unmeasured ?? [] };
203
+ // Optional on the way in for `unmeasured`'s reason, and a non-string entry drops the whole
204
+ // report for a malformed skip row's reason: a warning list with a hole in it is a build that
205
+ // says less than it measured, which is how the worker's findings went unread in the first place.
206
+ if (
207
+ serviceWorkerWarnings !== undefined &&
208
+ (!Array.isArray(serviceWorkerWarnings) ||
209
+ !serviceWorkerWarnings.every((entry) => typeof entry === 'string'))
210
+ ) {
211
+ return undefined;
212
+ }
213
+ return {
214
+ target,
215
+ out,
216
+ buildId,
217
+ emitted,
218
+ skipped,
219
+ unmeasured: unmeasured ?? [],
220
+ serviceWorkerWarnings: (serviceWorkerWarnings as readonly string[] | undefined) ?? [],
221
+ };
194
222
  }
195
223
 
196
224
  export async function writeStaticReport(root: string, report: StaticReport): Promise<string> {
@@ -232,7 +260,12 @@ export function staticReportData(report: StaticReport | undefined): Record<strin
232
260
  // which the inventory is about — `data` already carries `artifact` and the build's own id.
233
261
  return report === undefined
234
262
  ? {}
235
- : { emitted: report.emitted, skipped: report.skipped, unmeasured: report.unmeasured };
263
+ : {
264
+ emitted: report.emitted,
265
+ skipped: report.skipped,
266
+ unmeasured: report.unmeasured,
267
+ serviceWorkerWarnings: report.serviceWorkerWarnings,
268
+ };
236
269
  }
237
270
 
238
271
  /**
@@ -247,6 +280,16 @@ export function renderStaticReport(report: StaticReport): readonly string[] {
247
280
  // whose budget could not be weighed is invisible in `emitted` and, when it also rendered, in
248
281
  // `skipped` too — and it is the row `X_BUDGET_UNMEASURED` sends its reader here to read.
249
282
  ...report.unmeasured.map((route) => ['unmeasured', route.path, route.reason]),
283
+ // The service worker's own findings, in the same three columns — a precache manifest over its
284
+ // byte ceiling AND a capability declared with nothing to wire it to, which is why neither the
285
+ // field nor this label says `precache`. `sw.js` is the one artifact that keeps serving after a
286
+ // deploy is over, so both are build-time facts that have to be visible in the build's own
287
+ // output — `PrecacheManifest` computed the first and nothing read it (#390).
288
+ ...report.serviceWorkerWarnings.map((warning) => [
289
+ 'service-worker',
290
+ SERVICE_WORKER_PATH,
291
+ warning,
292
+ ]),
250
293
  ];
251
294
  const widths = [0, 1].map((index) =>
252
295
  Math.max(...rows.map((row) => (row[index] ?? '').length), 0),
@@ -0,0 +1,162 @@
1
+ // `sw.js` and its registration script, built from the route table and the island bundle — the one
2
+ // caller of `@ultimat3/pwa`'s `generateServiceWorker`, which had none outside its own package
3
+ // until #390. Beside `pwa-artifacts.ts` and not inside it: that file needs a root and a config
4
+ // file, this one needs a booted app and a finished build.
5
+
6
+ import type { PrecacheAsset, PrecacheManifest, PwaRoute } from '@ultimat3/pwa';
7
+ import { generateServiceWorker } from '@ultimat3/pwa';
8
+ import type { RouteDescriptor } from '@ultimat3/render';
9
+ import type { IslandBundle } from './island-bundle';
10
+ import { msg } from './messages';
11
+ import type { PwaArtifacts } from './pwa-artifacts';
12
+
13
+ /** Root scope, so `/sw.js` and nothing under a directory — `assertScope` refuses the rest. */
14
+ export const SERVICE_WORKER_PATH = '/sw.js';
15
+ export const SW_SCOPE = '/';
16
+
17
+ /**
18
+ * The registration is an EXTERNAL script, never inline, and that is a CSP fact rather than a
19
+ * preference: `startWeb` computes a `script-src` sha256 for each inline script it serves, so an
20
+ * unhashed one is blocked in the container while passing report-only under `x dev` — which is
21
+ * exactly how the hydration runtime shipped broken once already.
22
+ */
23
+ export const SW_REGISTER_PATH = '/x-sw-register.js';
24
+
25
+ export interface ServiceWorkerArtifacts {
26
+ /** `sw.js`, deterministic for identical input. */
27
+ readonly source: string;
28
+ /** `x-sw-register.js`, the four lines that install it. */
29
+ readonly register: string;
30
+ /** The `<script src>` tag, appended to `PwaArtifacts.head` by every surface that serves it. */
31
+ readonly head: string;
32
+ readonly precache: PrecacheManifest;
33
+ /** Precache budget findings — reported by `x build`, so the ceiling is not a designed thing. */
34
+ readonly warnings: readonly string[];
35
+ }
36
+
37
+ export interface ServiceWorkerInput {
38
+ /**
39
+ * What `loadPwaArtifacts` read out of `app.config.ts`. The whole object rather than three loose
40
+ * fields, because a second reader of that file is a second answer to what the app declared.
41
+ */
42
+ readonly pwa: PwaArtifacts;
43
+ readonly buildId: string;
44
+ readonly routes: readonly RouteDescriptor[];
45
+ readonly islands: IslandBundle;
46
+ }
47
+
48
+ /**
49
+ * The route table, as the service worker sees it. `api/` is dropped: an API response is a JSON
50
+ * document whose freshness is the app's business, and precaching one serves a stale answer to a
51
+ * client that had a network. Only the four fields `PwaRoute` reads cross — a descriptor carries
52
+ * budgets and policy flags that a browser has no use for.
53
+ */
54
+ const pwaRoutes = (routes: readonly RouteDescriptor[]): readonly PwaRoute[] =>
55
+ // `flatMap` rather than `filter().map()`: the filter's predicate does not narrow `surface` for
56
+ // the map that follows it, and `PwaRoute` declares the two navigable surfaces only. A cast would
57
+ // hide the day a fifth surface arrives.
58
+ routes.flatMap((route): readonly PwaRoute[] => {
59
+ // `shared/` is dropped with `api/`, and for a stronger reason: it is not a URL at all — the
60
+ // surface exists so two routes can import one module, and a browser can never navigate to it.
61
+ if (route.surface !== 'site' && route.surface !== 'app') return [];
62
+ return [
63
+ {
64
+ path: route.path,
65
+ surface: route.surface,
66
+ mode: route.mode,
67
+ offline: route.offline,
68
+ dynamic: route.dynamic,
69
+ },
70
+ ];
71
+ });
72
+
73
+ /**
74
+ * Every island chunk, precached. They are content-addressed and served `immutable`, so the
75
+ * revision IS the URL's hash and a byte-identical chunk across deploys is never re-downloaded.
76
+ *
77
+ * Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
78
+ * decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
79
+ */
80
+ const islandAssets = (islands: IslandBundle): readonly PrecacheAsset[] =>
81
+ [...islands.chunks]
82
+ .map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
83
+ .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
84
+
85
+ /**
86
+ * Four lines, and every one of them earns it. `load` because registration competes with the page's
87
+ * own first paint for the same network. `scope: '/'` stated rather than inferred, so a change to
88
+ * where the file is served is a build-time refusal (`assertScope`) instead of a worker that
89
+ * silently controls a subdirectory. The `catch` because a registration that throws in a browser
90
+ * with service workers disabled — an incognito profile, an enterprise policy — must not take an
91
+ * otherwise working page down with it.
92
+ */
93
+ const registerSource = (): string =>
94
+ `if ('serviceWorker' in navigator) {
95
+ addEventListener('load', function () {
96
+ navigator.serviceWorker
97
+ .register(${JSON.stringify(SERVICE_WORKER_PATH)}, { scope: ${JSON.stringify(SW_SCOPE)} })
98
+ .catch(function (error) { console.warn('service worker registration failed', error); });
99
+ });
100
+ }
101
+ `;
102
+
103
+ /**
104
+ * Build the worker, or answer `undefined` for an app that declared no PWA.
105
+ *
106
+ * A bad `sw.js` is sticky in a way a manifest is not: a manifest a browser dislikes is ignored, a
107
+ * worker that installs and caches wrong keeps serving wrong bytes until the user clears site data.
108
+ * That is why this landed only once a real browser could be driven —
109
+ * `packages/cli/e2e/service-worker.e2e.test.ts` installs the emitted worker in Chrome, takes the
110
+ * network away and asserts the fallback renders.
111
+ *
112
+ * `pwa.enabled` is the one switch, and it is already spent: `loadPwaArtifacts` answers `undefined`
113
+ * for an app that declared no PWA, so a caller only reaches this with an installable one.
114
+ * `defineConfig` refuses `enabled: true` without an absolute `offline.fallback`, and a
115
+ * hand-written config that lacks one reads as `null` here — so `requireOfflineFallback` inside
116
+ * `generateServiceWorker` can never be the thing that fails, and the app gets no worker rather
117
+ * than a worker caching a path nobody declared.
118
+ */
119
+ export function serviceWorkerArtifacts(
120
+ input: ServiceWorkerInput,
121
+ ): ServiceWorkerArtifacts | undefined {
122
+ const pwa = input.pwa;
123
+ if (pwa.offline.fallback === null) return undefined;
124
+ const output = generateServiceWorker(
125
+ pwaRoutes(input.routes),
126
+ {
127
+ scope: SW_SCOPE,
128
+ swPath: SERVICE_WORKER_PATH,
129
+ offline: {
130
+ fallback: pwa.offline.fallback,
131
+ ...(pwa.offline.image === null ? {} : { image: pwa.offline.image }),
132
+ ...(pwa.offline.font === null ? {} : { font: pwa.offline.font }),
133
+ neverCache: pwa.offline.neverCache,
134
+ },
135
+ capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
136
+ assets: islandAssets(input.islands),
137
+ },
138
+ input.buildId,
139
+ );
140
+ return {
141
+ source: output.source,
142
+ register: registerSource(),
143
+ head: `<script src="${SW_REGISTER_PATH}" defer></script>`,
144
+ precache: output.precache,
145
+ // `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
146
+ // verbatim — so it is read once, not twice. The push line is this module's own, and it is the
147
+ // one thing the generator cannot say: `generateServiceWorker` emits a push handler only when a
148
+ // VAPID key comes with the capability, and drops it in SILENCE otherwise. `pwa.push: true` in
149
+ // an `app.config.ts` therefore wires nothing and reports nothing, which is `jobs.driver`'s
150
+ // shape one package over.
151
+ warnings: [...output.warnings, ...pushWarning(pwa)],
152
+ };
153
+ }
154
+
155
+ /**
156
+ * `pwa.push: true` with nothing to sign a subscription with. There is no `pwa.vapid` config key
157
+ * yet, so today this fires for EVERY app that sets the flag — deliberately: a switch that silently
158
+ * does nothing is the defect this framework keeps re-shipping, and a warning naming the missing
159
+ * half is the smallest honest answer until the key exists.
160
+ */
161
+ const pushWarning = (pwa: PwaArtifacts): readonly string[] =>
162
+ pwa.push ? [msg('cli.build.pushUnwired')] : [];
@@ -0,0 +1,53 @@
1
+ // The two GET routes that serve what `sw-artifacts.ts` built, and the cache policy each one needs.
2
+ // Split from the emitter because mounting is HTTP and generation is a build: `prerender.ts` writes
3
+ // both artifacts as files and mounts nothing, and it must not have to import a route table to do it.
4
+
5
+ import type { CacheHint, Route } from '@ultimat3/http';
6
+ import { applyCacheHeaders } from '@ultimat3/http';
7
+ import type { ServiceWorkerArtifacts } from './sw-artifacts';
8
+ import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, SW_SCOPE } from './sw-artifacts';
9
+
10
+ /**
11
+ * `no-store`, and it is the one asset here that must be. A cached `sw.js` is a worker that cannot
12
+ * be replaced: the browser re-fetches it to decide whether an update exists, and an intermediary
13
+ * answering the old bytes pins every client to the deploy that shipped them. Browsers cap SW
14
+ * script caching at 24h on their own; this removes the question.
15
+ */
16
+ const SW_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
17
+
18
+ /** Content-addressed in neither path, so the register script gets the favicon's hour. */
19
+ const REGISTER_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
20
+
21
+ /** `/sw.js` and `/x-sw-register.js`, mounted by `x dev` and by the container alike. */
22
+ export const serviceWorkerRoutes = (artifacts: ServiceWorkerArtifacts): readonly Route[] => [
23
+ {
24
+ method: 'GET',
25
+ path: SERVICE_WORKER_PATH,
26
+ meta: { name: 'pwa.serviceWorker', auth: 'public' },
27
+ handler: () =>
28
+ applyCacheHeaders(
29
+ new Response(artifacts.source, {
30
+ headers: {
31
+ 'content-type': 'text/javascript; charset=utf-8',
32
+ // Without it the browser refuses to let a worker served from `/` control `/`, which
33
+ // is the failure `assertScope` cannot see: the scope a REGISTRATION asks for has to be
34
+ // allowed by the script's own response, not only by its path.
35
+ 'service-worker-allowed': SW_SCOPE,
36
+ },
37
+ }),
38
+ SW_CACHE,
39
+ ),
40
+ },
41
+ {
42
+ method: 'GET',
43
+ path: SW_REGISTER_PATH,
44
+ meta: { name: 'pwa.serviceWorkerRegister', auth: 'public' },
45
+ handler: () =>
46
+ applyCacheHeaders(
47
+ new Response(artifacts.register, {
48
+ headers: { 'content-type': 'text/javascript; charset=utf-8' },
49
+ }),
50
+ REGISTER_CACHE,
51
+ ),
52
+ },
53
+ ];
@@ -190,16 +190,63 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
190
190
  });
191
191
  `;
192
192
 
193
+ const offlineTest =
194
+ (): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
195
+ // JavaScript. Every one of those is a config field here, and every one of them rots the moment
196
+ // someone adds an import or a policy — at which point the page the service worker precaches is a
197
+ // page that cannot render when it is finally needed.
198
+ import { metaContextFor, routeDataFor } from '@ultimat3/render';
199
+ import { expect, unitTest } from '@ultimat3/testing';
200
+ import { config } from './page';
201
+
202
+ const ctx = { params: {}, url: 'https://example.test/offline' };
203
+
204
+ unitTest('the offline fallback is static, precached, and ships no JavaScript', async () => {
205
+ expect(config.render).toBe('static');
206
+ // 'precache', or the document that answers a lost network is itself fetched over the network.
207
+ expect(config.offline).toBe('precache');
208
+ expect(config.hydrate).toBe('never');
209
+ expect(config.budget.js).toBe('0kb');
210
+ // A cached error page has nothing to index, and an indexed one outranks the page it stood in for
211
+ // on the day the crawler happened to be offline.
212
+ const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
213
+ expect(meta.robots?.index).toBe(false);
214
+ });
215
+ `;
216
+
193
217
  const offlineFallback = (
194
218
  app: NameSet,
195
- ): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
196
- // tunnel shows the product's own shell instead of the browser's error page.
219
+ ): string => `// The offline fallback, and it is a ROUTE — \`pwa.offline.fallback\` in app.config.ts names this
220
+ // path, the generated sw.js precaches it, and every app/ route with offline: 'runtime' falls back
221
+ // here. So a train tunnel shows the product's own shell instead of the browser's error page.
222
+ //
223
+ // site/ and render: 'static', deliberately: the document that answers a lost network has to render
224
+ // with no network, no session and no database, which is what site/ guarantees and app/ (ssr |
225
+ // stream) cannot. \`offline: 'precache'\` for the same reason one level down — a fallback fetched
226
+ // over the network when the network is gone is not a fallback.
197
227
 
198
228
  // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
199
- import { useT } from '@${app.kebab}/i18n';
200
- import styles from './offline.module.scss';
229
+ ${sortedImports([
230
+ `import { useT } from '@${app.kebab}/i18n';`,
231
+ `import { defineRoute } from '@ultimat3/render';`,
232
+ ])}
233
+ import styles from './page.module.scss';
234
+
235
+ export const config = defineRoute({
236
+ render: 'static',
237
+ offline: 'precache',
238
+ hydrate: 'never',
239
+ budget: { js: '0kb' },
240
+ meta: ({ t }) => ({
241
+ title: t('app.offline.title'),
242
+ description: t('app.offline.description'),
243
+ // A cached error page has nothing to index, and an indexed one outranks the page it stood in
244
+ // for on the day the crawler happened to be offline.
245
+ robots: { index: false },
246
+ }),
247
+ });
201
248
 
202
- export function OfflineFallback() {
249
+ export function OfflinePage() {
203
250
  const t = useT();
204
251
 
205
252
  return (
@@ -390,8 +437,12 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
390
437
  // policy and `shared/roles.ts` declares the grants, and until this file existed nothing
391
438
  // answered "who is this?" — so every one of those routes refused every request.
392
439
  ...authFiles(app),
393
- { path: 'apps/web/app/offline.tsx', contents: offlineFallback(app) },
394
- { path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
440
+ // `site/offline/page.tsx`, not `app/offline.tsx`: the directory is the URL and `<name>.tsx` is
441
+ // not a route file, so the old path shipped a component nothing rendered and left `/offline` a
442
+ // URL the generated service worker could not fall back to.
443
+ { path: 'apps/web/site/offline/page.tsx', contents: offlineFallback(app) },
444
+ { path: 'apps/web/site/offline/page.module.scss', contents: offlineStyle() },
445
+ { path: 'apps/web/site/offline/page.test.ts', contents: offlineTest() },
395
446
  // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
396
447
  ...apiFiles(example),
397
448
  { path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
@@ -187,7 +187,10 @@ export const config = defineConfig({
187
187
  // else in an app.
188
188
  pwa: {
189
189
  enabled: true,
190
- offline: 'runtime',
190
+ // The document an offline navigation gets when the cache has no answer, and the path
191
+ // \`apps/web/site/offline/page.tsx\` serves. Required once \`enabled\` is true: an installable
192
+ // app that shows the browser's error page offline is the failure the block exists to prevent.
193
+ offline: { fallback: '/offline' },
191
194
  name: '${titleCase(app.raw)}',
192
195
  colors: {
193
196
  light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },