@ultimat3/cli 17.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.
Files changed (45) hide show
  1. package/CLAUDE.md +194 -17
  2. package/package.json +30 -30
  3. package/src/app-root.ts +22 -1
  4. package/src/cdp-browser.ts +100 -0
  5. package/src/cdp-connection.ts +211 -0
  6. package/src/cdp-e2e-page.ts +209 -0
  7. package/src/cdp-errors.ts +56 -0
  8. package/src/cdp-launch.ts +130 -0
  9. package/src/cmd-dev.ts +28 -2
  10. package/src/cmd-doctor.ts +1 -1
  11. package/src/cmd-test.ts +20 -9
  12. package/src/compile-externals.ts +11 -4
  13. package/src/db-accept-created.ts +207 -0
  14. package/src/db-generate.ts +18 -1
  15. package/src/db-subscribes.ts +81 -0
  16. package/src/db-ungeneratable.ts +14 -2
  17. package/src/dev-assets.ts +19 -54
  18. package/src/dev-notify-retention.ts +69 -0
  19. package/src/dev-purge.ts +47 -2
  20. package/src/dev-render.ts +20 -4
  21. package/src/dev-replicator.ts +19 -1
  22. package/src/dev-runtime.ts +12 -3
  23. package/src/dev-services.ts +8 -0
  24. package/src/e2e-driver.ts +35 -17
  25. package/src/e2e-page.ts +15 -3
  26. package/src/error-codes.ts +20 -0
  27. package/src/icon-assets.ts +74 -0
  28. package/src/index.ts +46 -4
  29. package/src/island-harness-script.ts +8 -1
  30. package/src/island-shot.ts +37 -4
  31. package/src/island-verdict.ts +16 -4
  32. package/src/mcp-errors.ts +15 -0
  33. package/src/messages.ts +5 -1
  34. package/src/prerender.ts +41 -1
  35. package/src/pwa-artifacts.ts +230 -0
  36. package/src/serve.ts +24 -1
  37. package/src/static-report.ts +46 -3
  38. package/src/sw-artifacts.ts +162 -0
  39. package/src/sw-routes.ts +53 -0
  40. package/src/templates/naming.ts +11 -0
  41. package/src/templates/scaffold-app.ts +58 -7
  42. package/src/templates/scaffold-repo.ts +17 -1
  43. package/src/test-shards.ts +150 -116
  44. package/src/ts-scan.ts +6 -1
  45. package/src/verify-test-run.ts +31 -46
@@ -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
+ ];
@@ -79,6 +79,17 @@ export const plural = (input: string): string => {
79
79
 
80
80
  export const titleKey = (input: string): string => `app.${kebab(input)}.title`;
81
81
 
82
+ /**
83
+ * A human title from a slug: `ledger-demo` -> `Ledger Demo`. Its one caller is the scaffolded
84
+ * `pwa.name`, which is what a browser shows a person in the install prompt — `app.name` is a slug
85
+ * by `NAME_RE` and `pascal` would offer to install `LedgerDemo`. Not a `NameSet` member: every
86
+ * other field there names a code identifier, and this one is prose.
87
+ */
88
+ export const titleCase = (input: string): string =>
89
+ words(input)
90
+ .map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
91
+ .join(' ');
92
+
82
93
  export interface NameSet {
83
94
  readonly raw: string;
84
95
  readonly kebab: string;
@@ -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() },
@@ -8,6 +8,7 @@ import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
8
8
  import { VERIFY_FLOOR_FILE } from '../verify-floor';
9
9
  import type { VerifyStepName } from '../verify-step';
10
10
  import type { GeneratedFile, NameSet } from './naming';
11
+ import { titleCase } from './naming';
11
12
  import { dbPackageFiles } from './scaffold-db-package';
12
13
  import { docsFiles } from './scaffold-docs';
13
14
  import { domainPackageFiles } from './scaffold-domain-package';
@@ -180,7 +181,22 @@ export const config = defineConfig({
180
181
  jobs: { queues: ['${app.kebab}-default'], concurrency: 4 },
181
182
  // In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
182
183
  realtime: { enabled: true, transport: 'memory' },
183
- pwa: { enabled: true, offline: 'runtime' },
184
+ // \`name\` and \`colors\` are what an install prompt shows and what a browser paints the splash
185
+ // with before any stylesheet has loaded — the four values the framework cannot derive, so
186
+ // \`defineConfig\` refuses \`pwa.enabled: true\` without them. Raw hex is legal here and nowhere
187
+ // else in an app.
188
+ pwa: {
189
+ enabled: true,
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' },
194
+ name: '${titleCase(app.raw)}',
195
+ colors: {
196
+ light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
197
+ dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' },
198
+ },
199
+ },
184
200
  ai: { mcp: { expose: true, path: '/mcp' } },
185
201
  });
186
202
  `;