@ultimat3/cli 19.1.3 → 19.3.1
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 +125 -8
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/app-openapi.ts +13 -5
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +53 -4
- package/src/budgets.ts +60 -7
- package/src/cmd-dev.ts +49 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-shot.ts +3 -1
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +28 -7
- package/src/dev-roles.ts +9 -8
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +17 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +75 -0
- package/src/doctor-offline.ts +122 -0
- package/src/duplicate-packages.ts +278 -0
- package/src/error-catalog.ts +4 -5
- package/src/error-codes.ts +6 -0
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/i18n-registration.ts +34 -5
- package/src/index.ts +3 -1
- package/src/island-bundle.ts +123 -10
- package/src/island-harness.ts +11 -4
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-errors.ts +2 -0
- package/src/mcp-host.ts +21 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +68 -16
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +17 -2
- package/src/shot-browser.ts +23 -4
- package/src/source-files.ts +3 -1
- package/src/static-report.ts +21 -1
- package/src/style-bundle.ts +124 -0
- package/src/style-csp.ts +14 -12
- package/src/style-routes.ts +56 -0
- package/src/sw-artifacts.ts +84 -12
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/resource-form-island.ts +13 -3
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +13 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +13 -7
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +33 -8
- package/src/web-binding.ts +22 -0
package/src/mcp-host.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// the gate. The description half is the framework's own `frameworkIntrospection`, so nothing here
|
|
4
4
|
// is a second catalog of routes, entities, actions, queries or jobs.
|
|
5
5
|
|
|
6
|
+
// why: Bun exposes no path API. Every use here builds a path this host then hands to `Bun.file`
|
|
7
|
+
// or prints inside a `fix:` an operator runs — the dev log, a per-role log, the committed
|
|
8
|
+
// manifest — and string concatenation would answer a different path on a Windows checkout.
|
|
6
9
|
import { join } from 'node:path';
|
|
7
10
|
import { agentActor, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
8
11
|
import type { DbClient } from '@ultimat3/db';
|
|
@@ -42,6 +45,7 @@ import { databaseTarget } from './mcp-db-target';
|
|
|
42
45
|
import { explainErrorCode } from './mcp-errors';
|
|
43
46
|
import { parseBunTest } from './mcp-test-output';
|
|
44
47
|
import { readMigrations } from './migrations';
|
|
48
|
+
import { retryMemo } from './retry-memo';
|
|
45
49
|
|
|
46
50
|
export interface DevHostInput {
|
|
47
51
|
readonly root: string;
|
|
@@ -88,7 +92,11 @@ export interface LazyServices {
|
|
|
88
92
|
*/
|
|
89
93
|
export function lazyServices(input: DevHostInput): LazyServices {
|
|
90
94
|
const services = resolveServices(input.root, input.env);
|
|
91
|
-
|
|
95
|
+
// `retryMemo`, not `??=`: a boot that REJECTED is not an answer to keep. A Postgres that refused
|
|
96
|
+
// one connection wedged every later tool call in the session with that first error, and the only
|
|
97
|
+
// way out was restarting the host — `startServices` unwinds everything it started before it
|
|
98
|
+
// rejects, so there is nothing left over for a second attempt to collide with.
|
|
99
|
+
const boot = retryMemo(() => startServices(services, input.env));
|
|
92
100
|
let closed = false;
|
|
93
101
|
return {
|
|
94
102
|
services,
|
|
@@ -101,14 +109,14 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
101
109
|
fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
|
|
102
110
|
});
|
|
103
111
|
}
|
|
104
|
-
|
|
105
|
-
return started;
|
|
112
|
+
return boot.get();
|
|
106
113
|
},
|
|
107
114
|
async close(): Promise<void> {
|
|
108
115
|
if (closed) return;
|
|
109
116
|
closed = true;
|
|
110
|
-
//
|
|
111
|
-
|
|
117
|
+
// `started()` and never `get()`: closing must not BOOT a database in order to stop one. A
|
|
118
|
+
// boot that rejected has nothing to stop, and close() must not throw on the way out.
|
|
119
|
+
await (await boot.started()?.catch(() => undefined))?.stop();
|
|
112
120
|
},
|
|
113
121
|
};
|
|
114
122
|
}
|
|
@@ -172,8 +180,13 @@ export async function readOnlyRows(
|
|
|
172
180
|
function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
|
|
173
181
|
const { root, runner } = input;
|
|
174
182
|
// Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
|
|
175
|
-
// loop — resolve the role once per process and reuse the answer, `null` included.
|
|
176
|
-
|
|
183
|
+
// loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
|
|
184
|
+
// resolution is not an answer: `??=` kept the rejection, so a statement timeout on the DDL
|
|
185
|
+
// meant every later `db.query` in the session refused with it instead of trying again.
|
|
186
|
+
//
|
|
187
|
+
// It asks `lazy.running()` for the client rather than closing over one: this runs before any
|
|
188
|
+
// boot has happened, and the boot is memoised, so it is the same connection `runQuery` uses.
|
|
189
|
+
const readOnlyRole = retryMemo(async () => ensureReadOnlyRole((await lazy.running()).db));
|
|
177
190
|
|
|
178
191
|
return {
|
|
179
192
|
database: databaseTarget(lazy.services, input.env),
|
|
@@ -182,8 +195,7 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
182
195
|
const { db } = await lazy.running();
|
|
183
196
|
// A managed Postgres may refuse CREATE ROLE; `ensureReadOnlyRole` answers null and the
|
|
184
197
|
// layer is reported absent in `guards` rather than quietly assumed present.
|
|
185
|
-
|
|
186
|
-
return readOnlyRows(db, sql, limits, await readOnlyRole);
|
|
198
|
+
return readOnlyRows(db, sql, limits, await readOnlyRole.get());
|
|
187
199
|
},
|
|
188
200
|
|
|
189
201
|
async runMigrations(branch: string, dryRun: boolean) {
|
package/src/parse.ts
CHANGED
|
@@ -88,6 +88,15 @@ export interface CommandSpec {
|
|
|
88
88
|
* that declared it and forgot the call ran outside an app with no refusal.
|
|
89
89
|
*/
|
|
90
90
|
readonly requiresApp?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The command hands everything after a bare `--` to another tool, so `ParsedArgs.passthrough`
|
|
93
|
+
* has a READER. Declared, because it did not until 2026-09 and nothing read it anywhere:
|
|
94
|
+
* `x test unit -- --coverage --bail` parsed both flags, carried them the whole way and dropped
|
|
95
|
+
* them, and every other command did the same in the same silence. A command that declares this
|
|
96
|
+
* forwards them; one that does not refuses the `--` (`X_CLI_BAD_FLAG`), which is the only way an
|
|
97
|
+
* argument that changes nothing becomes visible to the caller who typed it.
|
|
98
|
+
*/
|
|
99
|
+
readonly passthrough?: true;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export interface ParsedArgs {
|
|
@@ -170,6 +179,14 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
const spec = resolveCommand(first, specs);
|
|
182
|
+
if (passthrough.length > 0 && spec.passthrough !== true) {
|
|
183
|
+
throw new BadFlagError({
|
|
184
|
+
flag: '',
|
|
185
|
+
command: spec.name,
|
|
186
|
+
reason: `hands nothing to another tool, so ${passthrough.join(' ')} would be dropped in silence`,
|
|
187
|
+
fix: `x ${spec.name} --help`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
173
190
|
const flags = defaults(spec);
|
|
174
191
|
const positionals: string[] = [];
|
|
175
192
|
// What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// One answer to "does this path pass through a directory called X". Six modules asked it with
|
|
2
|
+
// `path.includes('node_modules')`, which is a SUBSTRING match: an app checked out under
|
|
3
|
+
// `~/dev/node_modules-experiments/myapp` answered true for every file it holds, so `loadApp`
|
|
4
|
+
// imported none of them and the app registered nothing at all.
|
|
5
|
+
|
|
6
|
+
/** Split on either separator: a rule that reads `a/b` and not `a\b` does not exist on Windows. */
|
|
7
|
+
export function pathSegments(path: string): readonly string[] {
|
|
8
|
+
return path.replaceAll('\\', '/').split('/');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Whether any whole segment of `path` is `segment` — never a substring of one. */
|
|
12
|
+
export function hasPathSegment(path: string, segment: string): boolean {
|
|
13
|
+
return pathSegments(path).includes(segment);
|
|
14
|
+
}
|
package/src/prerender.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// only which routes qualify and where the bytes land.
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { createContext, renderThrowable, runWithContext } from '@ultimat3/core';
|
|
7
|
+
import { createContext, isUltimateError, renderThrowable, runWithContext } from '@ultimat3/core';
|
|
8
8
|
import type { RouteEntry } from '@ultimat3/render';
|
|
9
9
|
import { describeRoutes, routeEntries } from '@ultimat3/render';
|
|
10
10
|
import { renderStatic } from '@ultimat3/render/server';
|
|
@@ -21,7 +21,14 @@ import { measurementActor } from './measurement-actor';
|
|
|
21
21
|
import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
|
|
22
22
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
23
23
|
import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
|
|
24
|
-
import {
|
|
24
|
+
import { styleBundle, writeStyles } from './style-bundle';
|
|
25
|
+
import type { RenderedDocument } from './sw-artifacts';
|
|
26
|
+
import {
|
|
27
|
+
SERVICE_WORKER_PATH,
|
|
28
|
+
SW_REGISTER_PATH,
|
|
29
|
+
serviceWorkerArtifacts,
|
|
30
|
+
serviceWorkerHead,
|
|
31
|
+
} from './sw-artifacts';
|
|
25
32
|
|
|
26
33
|
// Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
|
|
27
34
|
// carries it, and this file already imports that module.
|
|
@@ -78,6 +85,8 @@ export interface PrerenderReport {
|
|
|
78
85
|
readonly report: string;
|
|
79
86
|
/** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
|
|
80
87
|
readonly islands: readonly string[];
|
|
88
|
+
/** Surface stylesheets emitted, one file each — the CSS half of the same question. */
|
|
89
|
+
readonly styles: readonly string[];
|
|
81
90
|
/**
|
|
82
91
|
* What the service worker could not express, and what its precache manifest weighs too much of.
|
|
83
92
|
*
|
|
@@ -138,12 +147,23 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
138
147
|
const skipped: SkippedRoute[] = [];
|
|
139
148
|
const routes: RouteStats[] = [];
|
|
140
149
|
const unmeasured: UnmeasuredRoute[] = [];
|
|
150
|
+
// What the loop below rendered, by the path it rendered — the service worker's precache
|
|
151
|
+
// revisions. A `Map` and not a record, so a route path spelling a prototype member cannot answer
|
|
152
|
+
// with one; insertion order is never read, because `pwaRoutes` walks the route table and
|
|
153
|
+
// `buildPrecacheManifest` sorts its own entries by code unit.
|
|
154
|
+
const documents = new Map<string, RenderedDocument>();
|
|
141
155
|
|
|
142
156
|
// Before the first document: a page's `data-x-entry` is a built chunk's URL, so the chunks have
|
|
143
157
|
// to exist to be named. Written into `out` too — a static export is served with no process
|
|
144
158
|
// behind it, so the artifact carries every byte the browser will ask for.
|
|
145
159
|
const islands = await buildIslands(options.root);
|
|
146
160
|
await writeIslands(islands, options.out);
|
|
161
|
+
// And the stylesheet every document below LINKS. Derived after the island build on purpose:
|
|
162
|
+
// `islandStylesPlugin` registers an island's own `.module.scss` during `Bun.build`, so a bundle
|
|
163
|
+
// minted before it would hash CSS the documents do not carry — and the export would publish
|
|
164
|
+
// pages whose `<link>` names a file the artifact does not have.
|
|
165
|
+
const styles = styleBundle();
|
|
166
|
+
await writeStyles(styles, options.out);
|
|
147
167
|
// Same rule, one asset further: a browser asks for `/favicon.ico` on the first page it loads,
|
|
148
168
|
// and a static export has no route to answer it — so the bytes the served surfaces would have
|
|
149
169
|
// returned go into the artifact instead of leaving a 404 in every visitor's console.
|
|
@@ -165,18 +185,15 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
165
185
|
// wiring exists to close. `undefined` when the app is not installable, and then no document
|
|
166
186
|
// names it either.
|
|
167
187
|
const pwa = await loadPwaArtifacts(options.root);
|
|
168
|
-
// The
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
|
|
178
|
-
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
|
|
179
|
-
}
|
|
188
|
+
// The registration TAG now, the worker itself after the render loop — the two halves are wanted
|
|
189
|
+
// at different moments and used to be taken at the same one. Every document below has to name
|
|
190
|
+
// `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
|
|
191
|
+
// those same documents, which do not exist yet: emitted here, every route's revision was the
|
|
192
|
+
// BUILD ID and every route's byte count was 0, so a deploy of a byte-identical site re-fetched
|
|
193
|
+
// everything and the precache budget could not count one byte of HTML (`precache.ts`' own
|
|
194
|
+
// header). `serviceWorkerHead` is the one predicate behind both, so a page can never name a
|
|
195
|
+
// script the export does not carry.
|
|
196
|
+
const swHead = pwa === undefined ? undefined : serviceWorkerHead(pwa);
|
|
180
197
|
if (pwa !== undefined) {
|
|
181
198
|
await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
|
|
182
199
|
// And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
|
|
@@ -209,7 +226,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
209
226
|
runWithContext(as, () =>
|
|
210
227
|
routeDocument(entry, data, {
|
|
211
228
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
212
|
-
...(pwa === undefined ? {} : { pwaHead: pwa.head + (
|
|
229
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
|
|
213
230
|
}),
|
|
214
231
|
);
|
|
215
232
|
const document = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
|
|
@@ -240,7 +257,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
240
257
|
} catch (error) {
|
|
241
258
|
// `renderThrowable`, never `String(error)`: this is a caught unknown, and a hostile
|
|
242
259
|
// `toString` here would take the whole build down instead of one route's measurement.
|
|
243
|
-
|
|
260
|
+
// A framework error rides along with its code, cause and fix: `checkBudgets` reports
|
|
261
|
+
// `X_ISLAND_PROPS_INVALID` under its own name, because that sentence — the island, the
|
|
262
|
+
// prop, its bytes — is the finding, and `X_BUDGET_UNMEASURED` pointing at this list was
|
|
263
|
+
// a second command between the author and it.
|
|
264
|
+
unmeasured.push({
|
|
265
|
+
path: entry.path,
|
|
266
|
+
reason: renderThrowable(error),
|
|
267
|
+
...(isUltimateError(error)
|
|
268
|
+
? { code: error.code, cause: error.cause, fix: error.fix }
|
|
269
|
+
: {}),
|
|
270
|
+
});
|
|
244
271
|
}
|
|
245
272
|
continue;
|
|
246
273
|
}
|
|
@@ -274,6 +301,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
274
301
|
hash: artifact.hash,
|
|
275
302
|
bytes,
|
|
276
303
|
});
|
|
304
|
+
// `artifact.hash` is `contentHash(html)` — the same identity that becomes this page's ETag,
|
|
305
|
+
// so the precache revision and the HTTP validator can never disagree about one document.
|
|
306
|
+
// Keyed by the FILLED path, which for a non-dynamic route is the declared one; a dynamic
|
|
307
|
+
// route is not precached as a single URL anyway (`buildPrecacheManifest` skips it).
|
|
308
|
+
documents.set(artifact.path, { revision: artifact.hash, bytes });
|
|
277
309
|
// Measured from the document that was just written, so the `budgets` step compares a
|
|
278
310
|
// declared budget against bytes that exist on disk rather than against a graph's estimate.
|
|
279
311
|
const measured = await measureDocumentJs(artifact.html, options.out);
|
|
@@ -287,6 +319,25 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
287
319
|
}
|
|
288
320
|
if (heaviest !== undefined) routes.push(heaviest);
|
|
289
321
|
}
|
|
322
|
+
// The worker, LAST: every document it precaches has now been rendered, hashed and weighed. A
|
|
323
|
+
// static host runs no route table, so both files go into the artifact — a
|
|
324
|
+
// `<script src="/x-sw-register.js">` in every document is a 404 otherwise, which is the same
|
|
325
|
+
// promise `favicon.ico` and the icons above keep.
|
|
326
|
+
const serviceWorker =
|
|
327
|
+
pwa === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: serviceWorkerArtifacts({
|
|
330
|
+
pwa,
|
|
331
|
+
buildId,
|
|
332
|
+
routes: describeRoutes(),
|
|
333
|
+
islands,
|
|
334
|
+
styles,
|
|
335
|
+
documents,
|
|
336
|
+
});
|
|
337
|
+
if (serviceWorker !== undefined) {
|
|
338
|
+
await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
|
|
339
|
+
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
|
|
340
|
+
}
|
|
290
341
|
const stats = await writeBuildStats(options.root, { routes });
|
|
291
342
|
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
|
292
343
|
// not reach `prerenderSite` produces neither — and `x verify`'s `budgets` step already reds that
|
|
@@ -313,6 +364,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
313
364
|
stats,
|
|
314
365
|
report,
|
|
315
366
|
islands: islands.chunks.map((chunk) => chunk.file),
|
|
367
|
+
styles: styles.chunks.map((chunk) => chunk.url),
|
|
316
368
|
serviceWorkerWarnings: serviceWorker?.warnings ?? [],
|
|
317
369
|
};
|
|
318
370
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One in-flight attempt, shared — and a FAILED one not kept. `started ??= boot()` caches the
|
|
2
|
+
// rejection with the value, so a Postgres that refused a connection once answered every later
|
|
3
|
+
// call in the session with that same error and no retry was possible short of restarting the host.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A lazily made attempt that may be made again.
|
|
7
|
+
*
|
|
8
|
+
* `started()` is the half `??=` has no spelling for: it answers the attempt already in flight or
|
|
9
|
+
* already made, and `undefined` when nobody has asked yet — so a `close()` can stop what was
|
|
10
|
+
* booted without BOOTING one in order to stop it.
|
|
11
|
+
*/
|
|
12
|
+
export interface RetryMemo<T> {
|
|
13
|
+
get(): Promise<T>;
|
|
14
|
+
started(): Promise<T> | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `start` runs at most once per SUCCESS. `@ultimat3/db`'s `createPgliteClient` states the rule this
|
|
19
|
+
* generalises — "a failed boot must not be cached" — and the clearing handler is attached at
|
|
20
|
+
* creation for the reason that matters: it therefore runs ahead of every caller's own `await`
|
|
21
|
+
* continuation, so by the time anyone sees the rejection the slot is already empty and the next
|
|
22
|
+
* call really does start a new attempt. Cleared inside the caller's `catch` instead, there is a
|
|
23
|
+
* window in which a second call is handed the dead promise.
|
|
24
|
+
*/
|
|
25
|
+
export function retryMemo<T>(start: () => Promise<T>): RetryMemo<T> {
|
|
26
|
+
let attempt: Promise<T> | undefined;
|
|
27
|
+
return {
|
|
28
|
+
get(): Promise<T> {
|
|
29
|
+
attempt ??= start().catch((error: unknown) => {
|
|
30
|
+
attempt = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
return attempt;
|
|
34
|
+
},
|
|
35
|
+
started: (): Promise<T> | undefined => attempt,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -49,6 +49,8 @@ import { readMigrations } from './migrations';
|
|
|
49
49
|
import { startOtlpExport } from './otlp-export';
|
|
50
50
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
51
51
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
52
|
+
import { styleBundle } from './style-bundle';
|
|
53
|
+
import { styleRoutes } from './style-routes';
|
|
52
54
|
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
53
55
|
import { serviceWorkerRoutes } from './sw-routes';
|
|
54
56
|
|
|
@@ -193,7 +195,11 @@ export type StartedApp = ServedApp | MigratedApp;
|
|
|
193
195
|
* is what fails on it.
|
|
194
196
|
*/
|
|
195
197
|
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
196
|
-
const queue = await startQueue(
|
|
198
|
+
const queue = await startQueue(
|
|
199
|
+
resolveServices(options.root, options.env),
|
|
200
|
+
options.runtime,
|
|
201
|
+
options.env,
|
|
202
|
+
);
|
|
197
203
|
try {
|
|
198
204
|
const migrations = await readMigrations(options.root);
|
|
199
205
|
const report = await migrate({
|
|
@@ -329,7 +335,13 @@ async function bootRoles(boot: {
|
|
|
329
335
|
const serviceWorker =
|
|
330
336
|
pwa === undefined
|
|
331
337
|
? undefined
|
|
332
|
-
: serviceWorkerArtifacts({
|
|
338
|
+
: serviceWorkerArtifacts({
|
|
339
|
+
pwa,
|
|
340
|
+
buildId,
|
|
341
|
+
routes: describeRoutes(),
|
|
342
|
+
islands,
|
|
343
|
+
styles: styleBundle(),
|
|
344
|
+
});
|
|
333
345
|
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
334
346
|
const mcpMount = await mountAppMcp(options.root);
|
|
335
347
|
const routes: readonly Route[] = [
|
|
@@ -344,6 +356,9 @@ async function bootRoles(boot: {
|
|
|
344
356
|
}),
|
|
345
357
|
...storageRoutes({ storage: runtime.storage }),
|
|
346
358
|
...islandRoutes(() => islands),
|
|
359
|
+
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
360
|
+
// filled, so this process serves exactly the CSS it renders against.
|
|
361
|
+
...styleRoutes(() => styleBundle()),
|
|
347
362
|
...appRoutes({
|
|
348
363
|
buildId,
|
|
349
364
|
resolveIsland: (file) => islands.resolverFor(file),
|
package/src/shot-browser.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
cdpUrlFrom,
|
|
9
9
|
cdpUrlProblem,
|
|
10
10
|
executablePathFrom,
|
|
11
|
+
ShotChromeMissingError,
|
|
11
12
|
} from './browser-launcher';
|
|
12
13
|
import { BadFlagError } from './errors';
|
|
13
14
|
|
|
@@ -21,7 +22,10 @@ const CDP_FIX = 'x shot / --cdp-url wss://cdp.example.com/session/abc';
|
|
|
21
22
|
export interface ShotBrowserChoice {
|
|
22
23
|
/** Attach here. When set, nothing about a local executable was read. */
|
|
23
24
|
readonly cdpUrl?: string | undefined;
|
|
24
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Launch this. Already proved to exist on disk, and never absent alongside an absent `cdpUrl`:
|
|
27
|
+
* a run that reached this type has a browser, because the alternative is refused above.
|
|
28
|
+
*/
|
|
25
29
|
readonly executablePath?: string | undefined;
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -31,6 +35,12 @@ export interface ShotBrowserInput {
|
|
|
31
35
|
/** `--browser` as typed, before `PUPPETEER_EXECUTABLE_PATH` / `CHROME_PATH`. */
|
|
32
36
|
readonly browserFlag?: string | undefined;
|
|
33
37
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
38
|
+
/**
|
|
39
|
+
* Test seam: how a path is proved to be on disk. Injected rather than stubbed globally, so the
|
|
40
|
+
* probe below is asserted identically on a machine that has Chrome and on one that does not —
|
|
41
|
+
* an assertion whose verdict depends on the box it runs on is not an assertion.
|
|
42
|
+
*/
|
|
43
|
+
readonly exists?: (path: string) => boolean;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -47,8 +57,14 @@ export interface ShotBrowserInput {
|
|
|
47
57
|
* the flag IS read.
|
|
48
58
|
* 3. **On an attach, no executable is read at all.** Checking the filesystem for a binary this run
|
|
49
59
|
* will never execute is how a correct remote capture gets refused on a box with no Chrome.
|
|
60
|
+
* 4. **A local run with no browser anywhere is refused HERE.** `puppeteer-core` has no bundled
|
|
61
|
+
* browser and no default, so "nothing named" is not "the library finds its own" — it is a throw
|
|
62
|
+
* from inside somebody else's library, after `runShot` has already booted an embedded Postgres,
|
|
63
|
+
* saying ``An `executablePath` or `channel` must be specified``. The same fact is knowable from
|
|
64
|
+
* the environment and four filesystem probes before anything starts.
|
|
50
65
|
*/
|
|
51
66
|
export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
|
|
67
|
+
const exists = input.exists ?? browserBinaryExists;
|
|
52
68
|
if (input.cdpFlag !== undefined && input.browserFlag !== undefined) {
|
|
53
69
|
throw new BadFlagError({
|
|
54
70
|
flag: 'cdp-url',
|
|
@@ -71,8 +87,11 @@ export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
|
|
|
71
87
|
}
|
|
72
88
|
return { cdpUrl };
|
|
73
89
|
}
|
|
74
|
-
const executablePath = executablePathFrom(input.browserFlag, input.env);
|
|
75
|
-
|
|
90
|
+
const executablePath = executablePathFrom(input.browserFlag, input.env, exists);
|
|
91
|
+
// Nothing named and nothing probed: not a bad flag, because no flag was typed — a machine with no
|
|
92
|
+
// browser on it, which is a configuration to repair rather than a value to correct.
|
|
93
|
+
if (executablePath === undefined) throw new ShotChromeMissingError();
|
|
94
|
+
if (!exists(executablePath)) {
|
|
76
95
|
throw new BadFlagError({
|
|
77
96
|
flag: 'browser',
|
|
78
97
|
command: 'shot',
|
|
@@ -80,5 +99,5 @@ export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
|
|
|
80
99
|
fix: 'x shot / --browser /usr/bin/chromium',
|
|
81
100
|
});
|
|
82
101
|
}
|
|
83
|
-
return
|
|
102
|
+
return { executablePath };
|
|
84
103
|
}
|
package/src/source-files.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// and an app. One list for every step that walks source, because two steps scanning different sets
|
|
3
3
|
// means a finding one of them can never see.
|
|
4
4
|
|
|
5
|
+
import { hasPathSegment } from './path-segments';
|
|
6
|
+
|
|
5
7
|
export const SOURCE_GLOBS = [
|
|
6
8
|
'packages/*/src/**/*.{ts,tsx}',
|
|
7
9
|
// Three packages carry an `e2e` directory beside `src`. It is shipped source by every rule that
|
|
@@ -21,7 +23,7 @@ export const SOURCE_GLOBS = [
|
|
|
21
23
|
* root. `dist/` is build output: the sources that produced it are already in the set.
|
|
22
24
|
*/
|
|
23
25
|
export const isVendored = (path: string): boolean =>
|
|
24
|
-
path
|
|
26
|
+
hasPathSegment(path, 'node_modules') || hasPathSegment(path, 'dist');
|
|
25
27
|
|
|
26
28
|
/** Emitted declarations, not authored source — a rule about authored code cannot apply to them. */
|
|
27
29
|
export const isGenerated = (path: string): boolean => path.endsWith('.d.ts');
|
package/src/static-report.ts
CHANGED
|
@@ -60,6 +60,17 @@ export type UnmeasuredRoute = {
|
|
|
60
60
|
/** The DECLARED path, as `X_BUDGET_UNMEASURED`'s `at:` spells it, so the two rows join. */
|
|
61
61
|
readonly path: string;
|
|
62
62
|
readonly reason: string;
|
|
63
|
+
/**
|
|
64
|
+
* The throwable's own `code`, `cause` and `fix` when the render failed with an `UltimateError`
|
|
65
|
+
* — absent for a bare `TypeError`. Carried so the `budgets` step can report a failure that IS
|
|
66
|
+
* an instruction under its own code rather than under `X_BUDGET_UNMEASURED`: an island handed
|
|
67
|
+
* props over `ISLAND_PROPS_MAX_BYTES` throws `X_ISLAND_PROPS_INVALID` naming the prop and its
|
|
68
|
+
* bytes, and "run x build, its unmeasured list says why" sent the reader to a second command to
|
|
69
|
+
* read the sentence this build had already composed.
|
|
70
|
+
*/
|
|
71
|
+
readonly code?: string;
|
|
72
|
+
readonly cause?: string;
|
|
73
|
+
readonly fix?: string;
|
|
63
74
|
};
|
|
64
75
|
|
|
65
76
|
/** One HTML file in the artifact, and the declared route that produced it. */
|
|
@@ -171,8 +182,17 @@ const isSkipped = (value: unknown): value is SkippedRoute =>
|
|
|
171
182
|
typeof value['why'] === 'string' &&
|
|
172
183
|
inDomain(SKIP_REASONS, value['reason']);
|
|
173
184
|
|
|
185
|
+
const optionalString = (value: unknown): boolean =>
|
|
186
|
+
value === undefined || typeof value === 'string';
|
|
187
|
+
|
|
188
|
+
/** The three coded fields are optional TOGETHER on the way in; a present one must be a string. */
|
|
174
189
|
const isUnmeasured = (value: unknown): value is UnmeasuredRoute =>
|
|
175
|
-
isRecord(value) &&
|
|
190
|
+
isRecord(value) &&
|
|
191
|
+
typeof value['path'] === 'string' &&
|
|
192
|
+
typeof value['reason'] === 'string' &&
|
|
193
|
+
optionalString(value['code']) &&
|
|
194
|
+
optionalString(value['cause']) &&
|
|
195
|
+
optionalString(value['fix']);
|
|
176
196
|
|
|
177
197
|
const isEmitted = (value: unknown): value is EmittedPage =>
|
|
178
198
|
isRecord(value) &&
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The surface stylesheet table: the CSS a document on `site/` or `app/` carries, content-hashed
|
|
2
|
+
// and addressed as a FILE rather than inlined into every response. The island table's shape one
|
|
3
|
+
// asset over (`island-bundle.ts`), because the two answer the same question — "what does this
|
|
4
|
+
// document make the browser fetch, and can it keep it?" — and one mechanism is the whole point.
|
|
5
|
+
//
|
|
6
|
+
// Why it stopped being an inline `<style>`: measured against ai-maxxing on 2026-09-06, every
|
|
7
|
+
// `app/` document carried one 156,738-byte block — 1,336 rules from 406 module sources, byte
|
|
8
|
+
// identical across `/`, `/fleet`, `/fleet/[host]` and the session page — inside a response the
|
|
9
|
+
// pipeline sends `Cache-Control: private, no-store`. 92% of the dashboard document and 89% of the
|
|
10
|
+
// session document, re-sent and re-parsed on every navigation, cacheable by nothing.
|
|
11
|
+
|
|
12
|
+
// Bun ships no path API; `join` is the filesystem side of writing a chunk into a static export.
|
|
13
|
+
// why: Bun exposes no path API — nothing native joins a directory to a URL-shaped path.
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import type { Surface } from '@ultimat3/render';
|
|
16
|
+
import { SURFACES } from '@ultimat3/render';
|
|
17
|
+
import { contentHash, stylesFor, stylesheetsRevision } from '@ultimat3/render/server';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The surfaces a stylesheet is minted for. `api/` is dropped for `documentSurfaces`' reason — it
|
|
21
|
+
* emits no document, so a file no `<link>` can ever name would be bytes in the export and an entry
|
|
22
|
+
* in the precache manifest with no reader. `shared/` stays: `x shot --island` renders an island
|
|
23
|
+
* that lives there, and `surfaceOf` answers `shared` for it.
|
|
24
|
+
*/
|
|
25
|
+
const STYLED_SURFACES: readonly Surface[] = SURFACES.filter((surface) => surface !== 'api');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where a surface stylesheet is served from, in `x dev`, in the container and in a static export.
|
|
29
|
+
* Sits beside `ISLAND_BASE_PATH`, `ICON_BASE_PATH` and `MEDIA_BASE_PATH`, and outside the dev-only
|
|
30
|
+
* `/_x` namespace for their reason: the URL is baked into documents a static export publishes, so
|
|
31
|
+
* a dev-only path would be a page that renders in `x dev` and 404s on a CDN.
|
|
32
|
+
*/
|
|
33
|
+
export const STYLE_BASE_PATH = '/styles';
|
|
34
|
+
|
|
35
|
+
export interface StyleChunk {
|
|
36
|
+
/**
|
|
37
|
+
* Every surface whose documents link it, sorted. Usually one — `site/` and `app/` carry
|
|
38
|
+
* different modules — but an app whose only CSS is its global layer produces one byte string for
|
|
39
|
+
* all three, and shipping it three times would put three copies in the static export and three
|
|
40
|
+
* entries in the precache manifest, which has a budget.
|
|
41
|
+
*/
|
|
42
|
+
readonly surfaces: readonly Surface[];
|
|
43
|
+
/** Immutable, content-addressed URL. What `<link rel="stylesheet">` carries. */
|
|
44
|
+
readonly url: string;
|
|
45
|
+
readonly css: string;
|
|
46
|
+
readonly bytes: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface StyleBundle {
|
|
50
|
+
readonly chunks: readonly StyleChunk[];
|
|
51
|
+
/** The `href` a document on this surface links, or `undefined` when the surface has no CSS. */
|
|
52
|
+
hrefFor(surface: Surface | null): string | undefined;
|
|
53
|
+
/** The chunk a URL names — for serving it, and for writing it into a static export. */
|
|
54
|
+
chunkAt(url: string): StyleChunk | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `stylesFor(null)` and `stylesFor('shared')` select the same sheets by construction — a `null`
|
|
59
|
+
* surface matches only the package sheets, which `'shared'` already carries — so the one chunk
|
|
60
|
+
* answers both. `island-harness.ts` is the caller that can hold a `null`.
|
|
61
|
+
*/
|
|
62
|
+
const surfaceKey = (surface: Surface | null): Surface => surface ?? 'shared';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derived, never cached across a change: `stylesheetsRevision()` moves when a sheet's rules move,
|
|
66
|
+
* and island CSS registers on every `buildIslands` — which `x dev` re-runs on every watcher tick.
|
|
67
|
+
* Memoised on that revision rather than recomputed per request, because the derivation is a filter
|
|
68
|
+
* over every registered sheet plus a hash of the ~150 kB it joins.
|
|
69
|
+
*/
|
|
70
|
+
let memo: { readonly revision: number; readonly bundle: StyleBundle } | undefined;
|
|
71
|
+
|
|
72
|
+
export function styleBundle(): StyleBundle {
|
|
73
|
+
const revision = stylesheetsRevision();
|
|
74
|
+
if (memo !== undefined && memo.revision === revision) return memo.bundle;
|
|
75
|
+
const bundle = styleBundleOf(
|
|
76
|
+
STYLED_SURFACES.map((surface) => ({ surface, css: stylesFor(surface) })).filter(
|
|
77
|
+
(sheet) => sheet.css.length > 0,
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
memo = { revision, bundle };
|
|
81
|
+
return bundle;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Test seam, and the shape `islandBundle` has: a table built from what the caller supplies.
|
|
86
|
+
*
|
|
87
|
+
* The URL is the content hash and nothing else — no surface in the name — because a surface is not
|
|
88
|
+
* a property of the BYTES. Two surfaces with identical CSS are one file, one precache entry and
|
|
89
|
+
* one download, which is what the name would otherwise prevent. The same `contentHash` that stamps
|
|
90
|
+
* an ETag, a precache revision and an island chunk: one identity for a byte string, not a fourth.
|
|
91
|
+
*/
|
|
92
|
+
export function styleBundleOf(
|
|
93
|
+
sheets: readonly { readonly surface: Surface; readonly css: string }[],
|
|
94
|
+
): StyleBundle {
|
|
95
|
+
const byCss = new Map<string, Surface[]>();
|
|
96
|
+
for (const sheet of sheets) {
|
|
97
|
+
const held = byCss.get(sheet.css);
|
|
98
|
+
if (held === undefined) byCss.set(sheet.css, [sheet.surface]);
|
|
99
|
+
else held.push(sheet.surface);
|
|
100
|
+
}
|
|
101
|
+
const chunks: readonly StyleChunk[] = [...byCss].map(([css, surfaces]) => ({
|
|
102
|
+
surfaces: [...surfaces].sort(),
|
|
103
|
+
url: `${STYLE_BASE_PATH}/${contentHash(css)}.css`,
|
|
104
|
+
css,
|
|
105
|
+
bytes: new TextEncoder().encode(css).byteLength,
|
|
106
|
+
}));
|
|
107
|
+
const bySurface = new Map(
|
|
108
|
+
chunks.flatMap((chunk) => chunk.surfaces.map((surface) => [surface, chunk] as const)),
|
|
109
|
+
);
|
|
110
|
+
const byUrl = new Map(chunks.map((chunk) => [chunk.url, chunk]));
|
|
111
|
+
return {
|
|
112
|
+
chunks,
|
|
113
|
+
hrefFor: (surface: Surface | null): string | undefined =>
|
|
114
|
+
bySurface.get(surfaceKey(surface))?.url,
|
|
115
|
+
chunkAt: (url: string): StyleChunk | undefined => byUrl.get(url),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Write every surface stylesheet under a static export, at the URL the documents already carry. */
|
|
120
|
+
export async function writeStyles(bundle: StyleBundle, out: string): Promise<void> {
|
|
121
|
+
for (const chunk of bundle.chunks) {
|
|
122
|
+
await Bun.write(join(out, chunk.url.slice(1)), chunk.css);
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/style-csp.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
// Every inline `<style>` body a served process can put in a document, as the `style-src`
|
|
2
|
-
// that admit it.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Every inline `<style>` body a served process can still put in a document, as the `style-src`
|
|
2
|
+
// sources that admit it. The caller names them, because the caller is what knows which documents
|
|
3
|
+
// it mounted.
|
|
4
|
+
//
|
|
5
|
+
// The app's OWN CSS is no longer among them, `As of 2026-09-06`: a surface stylesheet is served as
|
|
6
|
+
// a content-hashed file (`style-bundle.ts`) and a same-origin `<link>` is admitted by the `'self'`
|
|
7
|
+
// already in `@ultimat3/http`'s `style-src`. This function used to hash `stylesFor(surface)` for
|
|
8
|
+
// all four surfaces — 157 kB of CSS hashed at boot to admit a block that is not emitted any more,
|
|
9
|
+
// which is a rule describing a document nobody serves. So a production boot (`serve.ts`, which
|
|
10
|
+
// passes no extras) now extends `style-src` with nothing at all.
|
|
5
11
|
|
|
6
12
|
import { cspHashSource } from '@ultimat3/http';
|
|
7
|
-
import { SURFACES } from '@ultimat3/render';
|
|
8
|
-
import { stylesFor } from '@ultimat3/render/server';
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
|
-
* Call
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
14
|
-
* knows which of them it mounted.
|
|
15
|
+
* Call with the inline bodies THIS process emits. `x dev` passes the `/_x` shell's stylesheet and
|
|
16
|
+
* the screenshot harness's frame style — documents this package renders itself, which no app
|
|
17
|
+
* surface can see.
|
|
15
18
|
*/
|
|
16
|
-
export function inlineStyleSources(
|
|
17
|
-
const bodies = [...SURFACES.map((surface) => stylesFor(surface)), ...extra];
|
|
19
|
+
export function inlineStyleSources(bodies: readonly string[] = []): readonly string[] {
|
|
18
20
|
return [...new Set(bodies.filter((body) => body.length > 0).map(cspHashSource))].sort();
|
|
19
21
|
}
|