@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/CLAUDE.md +104 -10
- package/package.json +29 -29
- package/src/app-root.ts +10 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev.ts +13 -1
- package/src/compile-externals.ts +11 -4
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +11 -0
- package/src/index.ts +27 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +4 -0
- package/src/prerender.ts +26 -2
- package/src/pwa-artifacts.ts +44 -2
- package/src/serve.ts +12 -1
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +4 -1
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
|
}
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -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.
|
package/src/static-report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
: {
|
|
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')] : [];
|
package/src/sw-routes.ts
ADDED
|
@@ -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
|
|
196
|
-
//
|
|
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
|
-
|
|
200
|
-
import
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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
|
|
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' },
|