@ultimat3/cli 20.2.1 → 21.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 +69 -1
- package/package.json +30 -30
- package/src/app-env.ts +2 -2
- package/src/budgets.ts +45 -12
- package/src/build-errors.ts +54 -0
- package/src/cdp-browser.ts +21 -27
- package/src/cdp-connection.ts +66 -30
- package/src/cdp-e2e-page.ts +84 -113
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-launch.ts +95 -41
- package/src/cdp-offline-script.ts +73 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cmd-deploy.ts +7 -0
- package/src/cmd-dev.ts +23 -86
- package/src/dev-live-feed.ts +2 -0
- package/src/dev-render.ts +119 -20
- package/src/dev-route-table.ts +119 -0
- package/src/dev-services.ts +4 -1
- package/src/dev-sync.ts +5 -3
- package/src/e2e-app.ts +103 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-driver.ts +32 -12
- package/src/e2e-errors.ts +14 -0
- package/src/e2e-page.ts +5 -2
- package/src/e2e-preload.ts +64 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-spawn.ts +169 -0
- package/src/error-codes.ts +7 -0
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +8 -29
- package/src/index.ts +18 -4
- package/src/island-bundle.ts +33 -11
- package/src/island-realtime.ts +91 -0
- package/src/island-verdict.ts +1 -1
- package/src/live-routes.ts +82 -42
- package/src/mcp-errors.ts +3 -0
- package/src/page-sync.ts +54 -0
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/serve.ts +9 -0
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/resource-form-island.ts +30 -21
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-container.ts +18 -3
- package/src/templates/scaffold-env.ts +6 -0
- package/src/verify-e2e.ts +38 -0
- package/src/verify-run.ts +105 -50
- package/src/verify-tests.ts +21 -4
- package/src/worker-bundle.ts +192 -0
package/src/verify-run.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Run every step
|
|
17
|
+
* Run every step, never bailing early: an agent fixing three things at once needs all
|
|
18
18
|
* three findings from one run, not one per round-trip.
|
|
19
19
|
*
|
|
20
20
|
* `ctx.only` narrows the list to one step. The narrowing lives HERE rather than in `cmd-verify.ts`
|
|
@@ -26,61 +26,43 @@ export async function runVerify(
|
|
|
26
26
|
ctx: VerifyContext,
|
|
27
27
|
): Promise<CommandResult> {
|
|
28
28
|
const floor = await readVerifyFloor(ctx.root);
|
|
29
|
-
const results: StepResult[] = [];
|
|
30
29
|
const selected = ctx.only === undefined ? steps : steps.filter((step) => step.name === ctx.only);
|
|
30
|
+
const byName = new Map<string, StepResult>();
|
|
31
|
+
const began = performance.now();
|
|
32
|
+
// The static steps wait for the serial suites and then run BESIDE them — only when `live` is in
|
|
33
|
+
// the list, so a one-step run (`--only`) and a list with no serial suite keep today's order.
|
|
34
|
+
const overlapping = selected.some((step) => step.name === SERIAL_SUITES[0]);
|
|
35
|
+
const beside = overlapping ? selected.filter((step) => BESIDE_SERIAL_SUITES.has(step.name)) : [];
|
|
36
|
+
let pending: Promise<void> | undefined;
|
|
37
|
+
const join = async (): Promise<void> => {
|
|
38
|
+
await pending;
|
|
39
|
+
pending = undefined;
|
|
40
|
+
};
|
|
31
41
|
for (const step of selected) {
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
name: step.name,
|
|
42
|
-
ok: !required,
|
|
43
|
-
durationMs: 0,
|
|
44
|
-
skipped: !required,
|
|
45
|
-
findings: required ? [vanishedSuiteFinding(step.name)] : [],
|
|
46
|
-
});
|
|
47
|
-
continue;
|
|
42
|
+
if (beside.includes(step)) continue;
|
|
43
|
+
if (step.name === SERIAL_SUITES[0]) {
|
|
44
|
+
pending = Promise.all(
|
|
45
|
+
beside.map(async (other) => {
|
|
46
|
+
byName.set(other.name, await runStep(other, ctx, floor));
|
|
47
|
+
}),
|
|
48
|
+
).then(() => undefined);
|
|
49
|
+
} else if (!SERIAL_SUITES.includes(step.name)) {
|
|
50
|
+
await join();
|
|
48
51
|
}
|
|
49
|
-
|
|
50
|
-
const outcome = await step.run(ctx).catch(
|
|
51
|
-
(error: unknown): StepOutcome => ({
|
|
52
|
-
ok: false,
|
|
53
|
-
findings: [findingOf(error, step.name)],
|
|
54
|
-
}),
|
|
55
|
-
);
|
|
56
|
-
// A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
|
|
57
|
-
// over an all-skipped file, so the counts are the only channel that can tell the two apart.
|
|
58
|
-
// ONE definition of "nothing ran", read twice, because the floor decides which of the two
|
|
59
|
-
// things it means — exactly as it already does for a step whose `applies` said no.
|
|
60
|
-
const tests = outcome.tests;
|
|
61
|
-
const nothingRan = tests !== undefined && tests.ran === 0;
|
|
62
|
-
const required = floorRequires(floor, step.name);
|
|
63
|
-
// A step the floor requires whose suite executed nothing is the same vanished suite as a step
|
|
64
|
-
// with no files at all — the run just had to finish before it could be seen. Appended to the
|
|
65
|
-
// step's own findings so `data.failed`, the counts and every gate reading this table carry it.
|
|
66
|
-
const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
|
|
67
|
-
results.push({
|
|
68
|
-
name: step.name,
|
|
69
|
-
ok: outcome.ok && vanished.length === 0,
|
|
70
|
-
durationMs: Math.round(performance.now() - started),
|
|
71
|
-
// Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
|
|
72
|
-
// the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
|
|
73
|
-
// step table may never do — a reader cannot tell a lane that ran from a lane that did not.
|
|
74
|
-
skipped: nothingRan && !required,
|
|
75
|
-
findings: [...outcome.findings, ...vanished],
|
|
76
|
-
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
77
|
-
...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
|
|
78
|
-
...(tests === undefined ? {} : { tests }),
|
|
79
|
-
});
|
|
52
|
+
byName.set(step.name, await runStep(step, ctx, floor));
|
|
80
53
|
}
|
|
54
|
+
await join();
|
|
55
|
+
// Reported in the declared order, whatever order the steps finished in: the table, `--json` and
|
|
56
|
+
// every gate parsing either read the same sequence they always did.
|
|
57
|
+
const results = selected.flatMap((step) => {
|
|
58
|
+
const result = byName.get(step.name);
|
|
59
|
+
return result === undefined ? [] : [result];
|
|
60
|
+
});
|
|
81
61
|
const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
|
|
82
62
|
const skippedSteps = results.filter((step) => step.skipped === true).map((step) => step.name);
|
|
83
|
-
|
|
63
|
+
// WALL time, not the sum of step times: with steps overlapping, the sum overstates what a run
|
|
64
|
+
// costs, and the wall clock is the number a CI job waits on.
|
|
65
|
+
const totalMs = Math.round(performance.now() - began);
|
|
84
66
|
const summary = verifySummary({
|
|
85
67
|
results,
|
|
86
68
|
failed: failedSteps,
|
|
@@ -139,6 +121,79 @@ function verifySummary(input: {
|
|
|
139
121
|
return msg(clean ? 'cli.verify.fail' : 'cli.verify.failSkipped', params);
|
|
140
122
|
}
|
|
141
123
|
|
|
124
|
+
/**
|
|
125
|
+
* `x verify`'s wall time was the SUM of 20 serial steps (#14, the DX ledger): measured locally, 395s,
|
|
126
|
+
* of which `lint`, `boundaries`, `filesize`, `package-shape` and `errors` were 127s spent while
|
|
127
|
+
* nothing else ran. They read the tree and write nothing a later step reads (`lint` is biome over
|
|
128
|
+
* files; the rest are in-process scans), so they run BESIDE the serial suites — `live` and `e2e`
|
|
129
|
+
* are one worker each, Postgres- and browser-bound, and mostly waiting. `typecheck` stays first
|
|
130
|
+
* and alone: `tsc -b` writes `.tsbuildinfo` and `dist/`, and `unit` saturates every core.
|
|
131
|
+
*/
|
|
132
|
+
export const BESIDE_SERIAL_SUITES: ReadonlySet<string> = new Set([
|
|
133
|
+
'lint',
|
|
134
|
+
'boundaries',
|
|
135
|
+
'filesize',
|
|
136
|
+
'package-shape',
|
|
137
|
+
'errors',
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
/** The consecutive run of steps the static group overlaps, in the order `VERIFY_STEP_NAMES` holds. */
|
|
141
|
+
export const SERIAL_SUITES: readonly string[] = ['live', 'job', 'e2e', 'eval'];
|
|
142
|
+
|
|
143
|
+
async function runStep(
|
|
144
|
+
step: VerifyStep,
|
|
145
|
+
ctx: VerifyContext,
|
|
146
|
+
floor: Awaited<ReturnType<typeof readVerifyFloor>>,
|
|
147
|
+
): Promise<StepResult> {
|
|
148
|
+
const applies = step.applies === undefined ? true : await step.applies(ctx);
|
|
149
|
+
if (!applies) {
|
|
150
|
+
// A skip this repo already ruled out is not a skip. The step ran here before — the floor is
|
|
151
|
+
// that claim, committed — so "nothing to check" now means the suite was deleted, and the
|
|
152
|
+
// gate says so on the step's own line rather than counting one more thing not to worry
|
|
153
|
+
// about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
|
|
154
|
+
// the summary, `data.failed`, and the reference-app gate's own red list.
|
|
155
|
+
const required = floorRequires(floor, step.name);
|
|
156
|
+
return {
|
|
157
|
+
name: step.name,
|
|
158
|
+
ok: !required,
|
|
159
|
+
durationMs: 0,
|
|
160
|
+
skipped: !required,
|
|
161
|
+
findings: required ? [vanishedSuiteFinding(step.name)] : [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const started = performance.now();
|
|
165
|
+
const outcome = await step.run(ctx).catch(
|
|
166
|
+
(error: unknown): StepOutcome => ({
|
|
167
|
+
ok: false,
|
|
168
|
+
findings: [findingOf(error, step.name)],
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
// A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
|
|
172
|
+
// over an all-skipped file, so the counts are the only channel that can tell the two apart.
|
|
173
|
+
// ONE definition of "nothing ran", read twice, because the floor decides which of the two
|
|
174
|
+
// things it means — exactly as it already does for a step whose `applies` said no.
|
|
175
|
+
const tests = outcome.tests;
|
|
176
|
+
const nothingRan = tests !== undefined && tests.ran === 0;
|
|
177
|
+
const required = floorRequires(floor, step.name);
|
|
178
|
+
// A step the floor requires whose suite executed nothing is the same vanished suite as a step
|
|
179
|
+
// with no files at all — the run just had to finish before it could be seen. Appended to the
|
|
180
|
+
// step's own findings so `data.failed`, the counts and every gate reading this table carry it.
|
|
181
|
+
const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
|
|
182
|
+
return {
|
|
183
|
+
name: step.name,
|
|
184
|
+
ok: outcome.ok && vanished.length === 0,
|
|
185
|
+
durationMs: Math.round(performance.now() - started),
|
|
186
|
+
// Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
|
|
187
|
+
// the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
|
|
188
|
+
// step table may never do — a reader cannot tell a lane that ran from a lane that did not.
|
|
189
|
+
skipped: nothingRan && !required,
|
|
190
|
+
findings: [...outcome.findings, ...vanished],
|
|
191
|
+
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
192
|
+
...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
|
|
193
|
+
...(tests === undefined ? {} : { tests }),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
142
197
|
function findingOf(error: unknown, step: string): Finding {
|
|
143
198
|
// A step may throw anything, including an Error that fights being read: `instanceof` runs a
|
|
144
199
|
// Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
|
package/src/verify-tests.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { testEnvOverrides } from './test-dotenv';
|
|
|
19
19
|
import type { TestFile } from './test-select';
|
|
20
20
|
import { discoverTests } from './test-select';
|
|
21
21
|
import { defaultWorkers } from './test-workers';
|
|
22
|
+
import { withE2eApp } from './verify-e2e';
|
|
22
23
|
import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
|
|
23
24
|
import { fromExec, fromFindings } from './verify-step';
|
|
24
25
|
import { runParallel } from './verify-test-run';
|
|
@@ -162,6 +163,12 @@ const ignoreFlags = (patterns: readonly string[]): readonly string[] =>
|
|
|
162
163
|
export const typeFiltersOf = (type: Exclude<TestType, 'unit'>): readonly string[] =>
|
|
163
164
|
OWNERSHIP.filter(([owner]) => owner === type).map(([, filter]) => filter);
|
|
164
165
|
|
|
166
|
+
/**
|
|
167
|
+
* An e2e test's own budget: a first navigation waits on `x dev` compiling the route and its island,
|
|
168
|
+
* which bun's default 5 s does not cover — a timeout there reads as an app bug that is not one.
|
|
169
|
+
*/
|
|
170
|
+
export const E2E_TEST_TIMEOUT_MS = 60_000;
|
|
171
|
+
|
|
165
172
|
/** Unit is everything the typed suites do not claim, so no test can fall between two steps. */
|
|
166
173
|
export const testStepCommand = (type: TestType): readonly string[] =>
|
|
167
174
|
type === 'unit'
|
|
@@ -173,6 +180,7 @@ export const testStepCommand = (type: TestType): readonly string[] =>
|
|
|
173
180
|
: [
|
|
174
181
|
'bun',
|
|
175
182
|
'test',
|
|
183
|
+
...(type === 'e2e' ? [`--timeout=${String(E2E_TEST_TIMEOUT_MS)}`] : []),
|
|
176
184
|
...ignoreFlags([...NEVER_A_TEST, ...disownedBy(type)]),
|
|
177
185
|
...typeFiltersOf(type),
|
|
178
186
|
];
|
|
@@ -203,10 +211,19 @@ export const resetTestDiscovery = (): void => discovered.clear();
|
|
|
203
211
|
const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
|
|
204
212
|
const command = testStepCommand(type);
|
|
205
213
|
const envOverrides = testEnvOverrides(ctx.root, ctx.env ?? Bun.env);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
const exec = (e2e: { command: readonly string[]; env: typeof envOverrides }) =>
|
|
215
|
+
ctx.runner(e2e.command, {
|
|
216
|
+
cwd: ctx.root,
|
|
217
|
+
...(Object.keys(e2e.env).length === 0 ? {} : { env: e2e.env }),
|
|
218
|
+
});
|
|
219
|
+
// The e2e step drives a real browser against the app it spawns, when this machine has one.
|
|
220
|
+
const result =
|
|
221
|
+
type === 'e2e'
|
|
222
|
+
? await withE2eApp(
|
|
223
|
+
{ root: ctx.root, isApp: isApp(ctx.root), command, env: envOverrides },
|
|
224
|
+
exec,
|
|
225
|
+
)
|
|
226
|
+
: await exec({ command, env: envOverrides });
|
|
210
227
|
return {
|
|
211
228
|
...fromExec(result, {
|
|
212
229
|
code: 'X_TEST_FAILED',
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The page's framework scripts (plan 101), each built as its own classic-script browser bundle,
|
|
2
|
+
// addressed by a hash of its source graph, served `immutable` by the same route in `x dev` and the
|
|
3
|
+
// container: the ONE sync worker (`@ultimat3/realtime/sync-worker`, slice 11) and the ONE page boot
|
|
4
|
+
// (`@ultimat3/realtime/boot` — the disk restore and the outbox, once per page instead of once per
|
|
5
|
+
// island). A new deploy is a new URL, so an old tab keeps what it started with.
|
|
6
|
+
|
|
7
|
+
// why: Bun ships no path API; the entry is resolved to a file and named in the cause.
|
|
8
|
+
import { dirname, join, relative } from 'node:path';
|
|
9
|
+
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
10
|
+
import { applyCacheHeaders, json } from '@ultimat3/http';
|
|
11
|
+
import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
|
|
12
|
+
import { describeBuildError, graphHash, stripDebugId } from './island-bundle';
|
|
13
|
+
|
|
14
|
+
/** Under the dev namespace `/_x` — where the socket it opens (`/_x/sync`) already lives. */
|
|
15
|
+
export const SYNC_WORKER_BASE_PATH = '/_x/sync-worker';
|
|
16
|
+
|
|
17
|
+
/** What an app resolves: realtime's worker entry, from the APP's install, never the CLI's own. */
|
|
18
|
+
export const SYNC_WORKER_SPECIFIER = '@ultimat3/realtime/sync-worker';
|
|
19
|
+
|
|
20
|
+
/** Under the dev namespace too; one per document that carries a principal scope. */
|
|
21
|
+
export const PAGE_BOOT_BASE_PATH = '/_x/page-boot';
|
|
22
|
+
|
|
23
|
+
/** Realtime's page boot, from the APP's install. */
|
|
24
|
+
export const PAGE_BOOT_SPECIFIER = '@ultimat3/realtime/boot';
|
|
25
|
+
|
|
26
|
+
/** One framework script: the sync worker or the page boot. */
|
|
27
|
+
export interface FrameworkScript {
|
|
28
|
+
/** `<base>/<hash>.js` — what the document names. */
|
|
29
|
+
readonly url: string;
|
|
30
|
+
readonly code: string;
|
|
31
|
+
readonly bytes: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The worker, by the name every caller already uses. */
|
|
35
|
+
export type SyncWorkerChunk = FrameworkScript;
|
|
36
|
+
|
|
37
|
+
export interface BuildSyncWorkerOptions {
|
|
38
|
+
/** An absolute entry path. Absent resolves `SYNC_WORKER_SPECIFIER` from `root`. */
|
|
39
|
+
readonly entry?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `undefined` when the app cannot resolve realtime's worker: an app with no realtime has no
|
|
44
|
+
* socket to share, and the tab-side host falls back to an in-page engine by design (slice 11's
|
|
45
|
+
* transparent fallback) — absence is an answer, not a failure.
|
|
46
|
+
*/
|
|
47
|
+
export function buildSyncWorker(
|
|
48
|
+
root: string,
|
|
49
|
+
options: BuildSyncWorkerOptions = {},
|
|
50
|
+
): Promise<FrameworkScript | undefined> {
|
|
51
|
+
return buildFrameworkScript(
|
|
52
|
+
root,
|
|
53
|
+
'sync worker',
|
|
54
|
+
SYNC_WORKER_SPECIFIER,
|
|
55
|
+
SYNC_WORKER_BASE_PATH,
|
|
56
|
+
options.entry,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The page boot, or `undefined` for an app with no realtime (nothing to restore, no outbox). A
|
|
62
|
+
* document then carries no boot script, and `pageRealtime().booted` answers at once.
|
|
63
|
+
*/
|
|
64
|
+
export function buildPageBoot(
|
|
65
|
+
root: string,
|
|
66
|
+
options: BuildSyncWorkerOptions = {},
|
|
67
|
+
): Promise<FrameworkScript | undefined> {
|
|
68
|
+
return buildFrameworkScript(
|
|
69
|
+
root,
|
|
70
|
+
'page boot',
|
|
71
|
+
PAGE_BOOT_SPECIFIER,
|
|
72
|
+
PAGE_BOOT_BASE_PATH,
|
|
73
|
+
options.entry,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function buildFrameworkScript(
|
|
78
|
+
root: string,
|
|
79
|
+
what: FrameworkScriptKind,
|
|
80
|
+
specifier: string,
|
|
81
|
+
basePath: string,
|
|
82
|
+
explicit: string | undefined,
|
|
83
|
+
): Promise<FrameworkScript | undefined> {
|
|
84
|
+
const entry = explicit ?? resolveEntry(root, specifier);
|
|
85
|
+
if (entry === undefined) return undefined;
|
|
86
|
+
const label = relative(root, entry);
|
|
87
|
+
let built: Awaited<ReturnType<typeof Bun.build>>;
|
|
88
|
+
try {
|
|
89
|
+
built = await Bun.build({
|
|
90
|
+
entrypoints: [entry],
|
|
91
|
+
target: 'browser',
|
|
92
|
+
// A CLASSIC script: `new SharedWorker(url, { name })` with no `type: 'module'` runs it, and a
|
|
93
|
+
// `<script defer>` runs it before any island module that follows it in the document.
|
|
94
|
+
format: 'iife',
|
|
95
|
+
splitting: false,
|
|
96
|
+
minify: true,
|
|
97
|
+
// `island-bundle.ts`'s reasons, verbatim: a chunk is only ever built to be shipped, and the
|
|
98
|
+
// map's `sourcesContent` is the one stable identity a minified bundle has.
|
|
99
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
100
|
+
sourcemap: 'external',
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new FrameworkScriptBuildFailedError({
|
|
104
|
+
what,
|
|
105
|
+
entry: label,
|
|
106
|
+
logs: describeBuildError(error),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
|
|
110
|
+
const map = built.outputs.find((artifact) => artifact.kind === 'sourcemap');
|
|
111
|
+
if (!built.success || output === undefined || map === undefined) {
|
|
112
|
+
throw new FrameworkScriptBuildFailedError({
|
|
113
|
+
what,
|
|
114
|
+
entry: label,
|
|
115
|
+
logs: built.logs.map((log) => String(log)).join('; '),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const code = stripDebugId(await output.text());
|
|
119
|
+
return {
|
|
120
|
+
url: `${basePath}/${graphHash(specifier, await map.text())}.js`,
|
|
121
|
+
code,
|
|
122
|
+
bytes: new TextEncoder().encode(code).byteLength,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The app's realtime, from the root or — in a workspace app, where `@ultimat3/realtime` is a
|
|
128
|
+
* dependency of `apps/<app>` and not of the root (`examples/dummy`) — from the first app that has
|
|
129
|
+
* it: where its islands resolve it, which is the copy the scripts must match. Resolving from the
|
|
130
|
+
* root alone built no worker and no boot for such an app, silently.
|
|
131
|
+
*/
|
|
132
|
+
function resolveEntry(root: string, specifier: string): string | undefined {
|
|
133
|
+
for (const dir of [root, ...appDirs(root)]) {
|
|
134
|
+
try {
|
|
135
|
+
return Bun.resolveSync(specifier, dir);
|
|
136
|
+
} catch {
|
|
137
|
+
// Not installed here, or a realtime that predates the export: try the next app.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function appDirs(root: string): readonly string[] {
|
|
144
|
+
const glob = new Bun.Glob('apps/*/package.json');
|
|
145
|
+
return [...glob.scanSync({ cwd: root, onlyFiles: true })]
|
|
146
|
+
.sort()
|
|
147
|
+
.map((file) => join(root, dirname(file)));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The script a build produced, read per request so `x dev` and the container share one route. */
|
|
151
|
+
export type SyncWorkerSource = () => FrameworkScript | undefined;
|
|
152
|
+
|
|
153
|
+
export function syncWorkerRoutes(source: SyncWorkerSource): readonly Route[] {
|
|
154
|
+
return [scriptRoute(SYNC_WORKER_BASE_PATH, 'assets.sync-worker', 'sync worker', source)];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function pageBootRoutes(source: SyncWorkerSource): readonly Route[] {
|
|
158
|
+
return [scriptRoute(PAGE_BOOT_BASE_PATH, 'assets.page-boot', 'page boot', source)];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function scriptRoute(
|
|
162
|
+
basePath: string,
|
|
163
|
+
name: string,
|
|
164
|
+
what: string,
|
|
165
|
+
source: SyncWorkerSource,
|
|
166
|
+
): Route {
|
|
167
|
+
return {
|
|
168
|
+
method: 'GET',
|
|
169
|
+
path: `${basePath}/:file`,
|
|
170
|
+
meta: { name, auth: 'public', tags: ['assets'] },
|
|
171
|
+
handler: (request: UltimateRequest): Response => {
|
|
172
|
+
const script = source();
|
|
173
|
+
if (script === undefined || script.url !== request.pathname) {
|
|
174
|
+
return json(
|
|
175
|
+
{
|
|
176
|
+
ok: false,
|
|
177
|
+
error: {
|
|
178
|
+
code: 'X_ROUTE_NOT_FOUND',
|
|
179
|
+
cause: `no ${what} is built at ${request.pathname} — the document that named it was rendered against another build`,
|
|
180
|
+
fix: `reload the page — this process serves only the ${what} it built`,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{ status: 404 },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return applyCacheHeaders(
|
|
187
|
+
new Response(script.code, { headers: { 'content-type': 'text/javascript' } }),
|
|
188
|
+
{ mode: 'immutable' },
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|