@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/dev-queue.ts
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
// The database and the job queue, started together and released together. Split from
|
|
2
2
|
// `dev-runtime.ts` because `x jobs` needs exactly this pair and nothing else — and because a
|
|
3
|
-
// process that installs
|
|
4
|
-
// takes
|
|
3
|
+
// process that installs ambient accessors (`db()`, `jobDriver()`, the jobs facade, the event bus)
|
|
4
|
+
// must have one place that takes them all back, or the next command in the same process inherits
|
|
5
|
+
// a driver over a closed socket.
|
|
5
6
|
|
|
6
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
postgresIdempotencyStore,
|
|
9
|
+
resetIdempotency,
|
|
10
|
+
SQL_IDEMPOTENCY_TABLE,
|
|
11
|
+
setIdempotencyStore,
|
|
12
|
+
} from '@ultimat3/action';
|
|
13
|
+
import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
|
|
7
14
|
import {
|
|
8
15
|
createPgliteClient,
|
|
9
16
|
createPostgresClient,
|
|
17
|
+
currentTx,
|
|
10
18
|
pgliteDataDir,
|
|
11
19
|
raw,
|
|
12
20
|
setDbClient,
|
|
13
21
|
} from '@ultimat3/db';
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
22
|
+
import type { Tx } from '@ultimat3/entity';
|
|
23
|
+
import type { EventBus, JobDriver, OutboxStore, PgExecutor } from '@ultimat3/jobs';
|
|
24
|
+
import {
|
|
25
|
+
createJobsFacade,
|
|
26
|
+
createPgDriver,
|
|
27
|
+
createPgEventBus,
|
|
28
|
+
createPgOutboxStore,
|
|
29
|
+
resetJobDriver,
|
|
30
|
+
resetJobsFacade,
|
|
31
|
+
SQL_JOBS_TABLE,
|
|
32
|
+
setEventBus,
|
|
33
|
+
setJobDriver,
|
|
34
|
+
setJobsFacade,
|
|
35
|
+
} from '@ultimat3/jobs';
|
|
16
36
|
import type { DevServices } from './dev-services';
|
|
37
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
17
38
|
|
|
18
39
|
/** Both embedded and external clients boot lazily and close explicitly. */
|
|
19
40
|
export type DevDbClient = PgliteClient | PostgresClient;
|
|
@@ -21,6 +42,13 @@ export type DevDbClient = PgliteClient | PostgresClient;
|
|
|
21
42
|
export interface RunningQueue {
|
|
22
43
|
readonly db: DevDbClient;
|
|
23
44
|
readonly jobs: JobDriver;
|
|
45
|
+
/**
|
|
46
|
+
* The `x_outbox` store this boot installed behind `handle.enqueue()`. Returned because the
|
|
47
|
+
* relay that drains it is a ROLE's decision, not the queue's — `dev-roles.ts` starts one.
|
|
48
|
+
*/
|
|
49
|
+
readonly outbox: OutboxStore;
|
|
50
|
+
/** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
|
|
51
|
+
readonly events: EventBus;
|
|
24
52
|
stop(): Promise<void>;
|
|
25
53
|
}
|
|
26
54
|
|
|
@@ -43,38 +71,94 @@ function startDb(services: DevServices): DevDbClient {
|
|
|
43
71
|
* The fragment is assembled by hand rather than through `sql`` ` because the driver hands over
|
|
44
72
|
* `$1..$n` text it wrote itself plus already-bound values — there is no interpolation to guard.
|
|
45
73
|
*/
|
|
46
|
-
function
|
|
74
|
+
export function pgExecutorFor(client: DbClient): PgExecutor {
|
|
47
75
|
return {
|
|
48
76
|
query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
|
|
49
77
|
client.query<R>({ text, values } satisfies SqlFragment),
|
|
50
78
|
};
|
|
51
79
|
}
|
|
52
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Every table this process's framework packages own, applied before anything reads one.
|
|
83
|
+
*
|
|
84
|
+
* PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL is
|
|
85
|
+
* applied statement by statement. Safe to split on `;`: both constants are fixed, with no
|
|
86
|
+
* semicolon inside a literal, and each package's own SQL test is where that stays true.
|
|
87
|
+
*
|
|
88
|
+
* `SQL_IDEMPOTENCY_TABLE` is here and not in `@ultimat3/action` because a package that holds no
|
|
89
|
+
* database dependency cannot apply its own schema — the same reason `SQL_JOBS_TABLE` is applied
|
|
90
|
+
* here. Without it `postgresIdempotencyStore` is a store whose first reservation fails on a
|
|
91
|
+
* missing relation, which is how a retried `POST /api/payments/charge` charges a card twice.
|
|
92
|
+
*/
|
|
93
|
+
async function applySchema(client: DevDbClient): Promise<void> {
|
|
94
|
+
for (const ddl of [SQL_JOBS_TABLE, SQL_IDEMPOTENCY_TABLE]) {
|
|
95
|
+
for (const statement of ddl.split(';')) {
|
|
96
|
+
if (statement.trim().length > 0) await client.execute(raw(statement));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
53
101
|
/**
|
|
54
102
|
* The dev queue is the real Postgres queue on the embedded Postgres — claiming, leases and the
|
|
55
103
|
* one-live-job-per-key index all behave here exactly as in production. A memory queue in dev
|
|
56
104
|
* would hide every bug this driver exists to make impossible.
|
|
57
105
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
106
|
+
* Three ambient installs, not one, and they go in together because they are one decision:
|
|
107
|
+
*
|
|
108
|
+
* - `setJobDriver` is what `jobDriver()` answers and what a worker claims from.
|
|
109
|
+
* - `setJobsFacade` is what `handle.enqueue()` routes through, so an enqueue inside a request's
|
|
110
|
+
* transaction STAGES a row that commits or vanishes with the business rows. Without it the
|
|
111
|
+
* fallback facade publishes straight to the driver, and a job for a transaction that rolled
|
|
112
|
+
* back still runs. `currentTx()` resolves the executor because a `DbTx` IS a client on the
|
|
113
|
+
* transaction's own connection, while the `Tx` token `@ultimat3/entity` hands over is not that
|
|
114
|
+
* object — so the token is a key and the ALS is the lookup.
|
|
115
|
+
* - `setEventBus` makes `step.waitForEvent` durable. The memory bus this replaced forgot every
|
|
116
|
+
* pending correlation on restart, which is a job that waits forever.
|
|
117
|
+
* - `setIdempotencyStore` makes `idempotent: true` mean it across replicas. The memory default is
|
|
118
|
+
* one process' worth of keys, so a client retrying `POST /api/payments/charge` after a timeout
|
|
119
|
+
* lands on a replica that has never seen the key and charges the card a second time.
|
|
120
|
+
*
|
|
121
|
+
* The idempotency store is installed HERE and not from the app, even though
|
|
122
|
+
* `@ultimat3/action` documents `postgresIdempotencyStore({ executor: Bun.sql })`: `Bun.sql` has no
|
|
123
|
+
* `.query(text, values)` — it is a tagged template whose positional form is `unsafe` — so that
|
|
124
|
+
* line does not satisfy `PgExecutor` at all, and a second executor would open a second pool
|
|
125
|
+
* against a URL this boot already resolved. Boot owns the connection, so boot supplies it.
|
|
126
|
+
* `startServices` runs before `loadApp`, so the store is in place before `registerAction`
|
|
127
|
+
* evaluates a `scope: 'shared'` declaration against it.
|
|
61
128
|
*/
|
|
62
|
-
async function startJobs(client: DevDbClient): Promise<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
const driver = createPgDriver({ executor: executorFor(client) });
|
|
129
|
+
async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Promise<RunningQueue> {
|
|
130
|
+
await applySchema(client);
|
|
131
|
+
const executor = pgExecutorFor(client);
|
|
132
|
+
const driver = overrides?.jobs ?? createPgDriver({ executor });
|
|
67
133
|
setJobDriver(driver);
|
|
68
|
-
|
|
134
|
+
const outbox = createPgOutboxStore({
|
|
135
|
+
executor,
|
|
136
|
+
// The open transaction is a client on its own connection; the `Tx` token is not that object.
|
|
137
|
+
txExecutor: () => pgExecutorFor(currentTx() ?? client),
|
|
138
|
+
});
|
|
139
|
+
setJobsFacade(
|
|
140
|
+
createJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
|
|
141
|
+
);
|
|
142
|
+
const events = createPgEventBus({ executor });
|
|
143
|
+
setEventBus(events);
|
|
144
|
+
setIdempotencyStore(postgresIdempotencyStore({ executor }));
|
|
145
|
+
return { db: client, jobs: driver, outbox, events, stop: () => releaseQueue(client, driver) };
|
|
69
146
|
}
|
|
70
147
|
|
|
71
148
|
/**
|
|
72
|
-
* Release
|
|
149
|
+
* Release every ambient accessor, then the resources behind them, in that order: a driver reset
|
|
73
150
|
* after its database is closed leaves a window where `jobDriver()` answers over a dead socket.
|
|
74
151
|
* A stale driver is worse than none — the next command sees one installed and skips queue
|
|
75
152
|
* startup entirely, so every query it makes fails on a connection this process already dropped.
|
|
153
|
+
*
|
|
154
|
+
* The facade goes with the driver for the same reason: an enqueue routed through a store bound to
|
|
155
|
+
* a closed client is a staged row nothing will ever publish.
|
|
76
156
|
*/
|
|
77
157
|
async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promise<void> {
|
|
158
|
+
// The idempotency store goes back to the memory default for the same reason the facade does: it
|
|
159
|
+
// holds this client, and the next command in this process would reserve keys over a closed one.
|
|
160
|
+
resetIdempotency();
|
|
161
|
+
resetJobsFacade();
|
|
78
162
|
resetJobDriver();
|
|
79
163
|
setDbClient(undefined);
|
|
80
164
|
await jobs?.close?.();
|
|
@@ -87,14 +171,16 @@ async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promi
|
|
|
87
171
|
* would pay for services it cannot even use. `startServices` builds on this so there is one boot
|
|
88
172
|
* path for "which database" and "which queue", not two.
|
|
89
173
|
*/
|
|
90
|
-
export async function startQueue(
|
|
174
|
+
export async function startQueue(
|
|
175
|
+
services: DevServices,
|
|
176
|
+
overrides?: RuntimeOverrides,
|
|
177
|
+
): Promise<RunningQueue> {
|
|
91
178
|
const db = startDb(services);
|
|
92
179
|
try {
|
|
93
180
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
94
181
|
// fails at boot rather than on some later query.
|
|
95
182
|
await db.ping();
|
|
96
|
-
|
|
97
|
-
return { db, jobs, stop: () => releaseQueue(db, jobs) };
|
|
183
|
+
return await startJobs(db, overrides);
|
|
98
184
|
} catch (error) {
|
|
99
185
|
// `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
|
|
100
186
|
// the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
|
package/src/dev-render.ts
CHANGED
|
@@ -2,29 +2,55 @@
|
|
|
2
2
|
// `@ultimat3/render`'s own function for that mode — the CLI picks the mode and supplies the
|
|
3
3
|
// document, it never decides what a mode means or what headers it earns.
|
|
4
4
|
//
|
|
5
|
-
// The document is head +
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The document is head + the route's own component, rendered by `@ultimat3/render`'s server JSX
|
|
6
|
+
// writer, with the surface's compiled CSS inlined. Inlined rather than linked because a `site/`
|
|
7
|
+
// page is a 0kb-JS artifact a CDN serves as one file: a stylesheet link would add a round trip to
|
|
8
|
+
// the render path the mode exists to make cheap, and a static export would need a second file.
|
|
8
9
|
|
|
9
10
|
import type { Ctx } from '@ultimat3/core';
|
|
10
11
|
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
11
12
|
import { asCtx, html, stream } from '@ultimat3/http';
|
|
12
|
-
import
|
|
13
|
+
import { currentLocale } from '@ultimat3/i18n';
|
|
14
|
+
import type {
|
|
15
|
+
IslandCollector,
|
|
16
|
+
IsrController,
|
|
17
|
+
RenderResult,
|
|
18
|
+
RouteData,
|
|
19
|
+
RouteEntry,
|
|
20
|
+
} from '@ultimat3/render';
|
|
13
21
|
import {
|
|
14
22
|
contentHash,
|
|
23
|
+
createIslandCollector,
|
|
15
24
|
createIsrController,
|
|
16
25
|
headFromMeta,
|
|
26
|
+
hydrateRuntime,
|
|
27
|
+
metaContextFor,
|
|
28
|
+
renderComponent,
|
|
17
29
|
renderHead,
|
|
18
30
|
renderSpa,
|
|
19
31
|
renderSsr,
|
|
32
|
+
routeDataFor,
|
|
20
33
|
routeEntries,
|
|
21
34
|
SPA_ROOT_ID,
|
|
22
35
|
seoRenderers,
|
|
23
36
|
staticHeaders,
|
|
24
37
|
streamResult,
|
|
38
|
+
stylesFor,
|
|
25
39
|
} from '@ultimat3/render';
|
|
26
40
|
|
|
27
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Specifier → built chunk URL, bound to the route file the specifier is written relative to.
|
|
43
|
+
* Supplied by whoever built the islands (`x dev`, the container, the static build); absent means
|
|
44
|
+
* no island was built, and a page that renders one then fails by name rather than emitting a
|
|
45
|
+
* `data-x-entry` nothing can import.
|
|
46
|
+
*/
|
|
47
|
+
export type IslandResolver = (routeFile: string) => (src: string) => string;
|
|
48
|
+
|
|
49
|
+
export interface DocumentOptions {
|
|
50
|
+
readonly resolveIsland?: IslandResolver;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DevRenderOptions extends DocumentOptions {
|
|
28
54
|
readonly buildId: string;
|
|
29
55
|
/** Injected so a test can drive the ISR store without a timer. */
|
|
30
56
|
readonly isr?: IsrController;
|
|
@@ -36,67 +62,173 @@ export interface DevRouteData extends Record<string, unknown> {
|
|
|
36
62
|
readonly params: RouteParams;
|
|
37
63
|
}
|
|
38
64
|
|
|
39
|
-
|
|
65
|
+
/**
|
|
66
|
+
* `<html lang>` is the request's own locale, never a constant: the `locale` stage negotiated it
|
|
67
|
+
* one stage before this handler and published it on the context, so a hardcoded `'en'` shipped
|
|
68
|
+
* every document mislabelled — wrong for a screen reader, wrong for `hreflang`, wrong for a CDN
|
|
69
|
+
* keying on `content-language`. Outside a request (`x build`'s prerender) it is the app's own
|
|
70
|
+
* configured fallback, which is the only defensible answer there.
|
|
71
|
+
*/
|
|
72
|
+
const lang = (): string => currentLocale();
|
|
40
73
|
|
|
41
|
-
const headFor = async (entry: RouteEntry, data:
|
|
74
|
+
const headFor = async (entry: RouteEntry, ctx: DevRouteData, data: RouteData): Promise<string> =>
|
|
42
75
|
renderHead(
|
|
43
|
-
headFromMeta(
|
|
76
|
+
headFromMeta(
|
|
77
|
+
await entry.config.meta(metaContextFor(ctx, data)),
|
|
78
|
+
seoRenderers({ path: new URL(ctx.url).pathname }),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/** `<style>` for the surface's own stylesheets, or nothing at all when the surface imports none. */
|
|
83
|
+
const styleTag = (entry: RouteEntry): string => {
|
|
84
|
+
const css = stylesFor(entry.surface);
|
|
85
|
+
return css.length === 0 ? '' : `<style>${css}</style>`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The route's rendered body, inside the hydration root. A module that exports no component (an
|
|
90
|
+
* `api/` route, or a `spa` whose data is all client-side) renders an empty root, which is the
|
|
91
|
+
* shell those modes are defined to serve — not a fallback for a component that failed.
|
|
92
|
+
*/
|
|
93
|
+
export async function routeBody(
|
|
94
|
+
entry: RouteEntry,
|
|
95
|
+
ctx: DevRouteData,
|
|
96
|
+
data: RouteData,
|
|
97
|
+
islands: IslandCollector,
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
if (entry.component === undefined) return `<div id="${SPA_ROOT_ID}"></div>`;
|
|
100
|
+
const url = new URL(ctx.url);
|
|
101
|
+
const html = await renderComponent(
|
|
102
|
+
entry.component,
|
|
103
|
+
// `data` is the route's own `load` result and is what `meta` was just given — the same object,
|
|
104
|
+
// never a second resolution. `query` is supplied because a page that reads `props.query.x`
|
|
105
|
+
// otherwise dereferences undefined and takes the whole render down.
|
|
106
|
+
{
|
|
107
|
+
data,
|
|
108
|
+
params: ctx.params,
|
|
109
|
+
url: ctx.url,
|
|
110
|
+
query: Object.fromEntries(url.searchParams) as Readonly<Record<string, string>>,
|
|
111
|
+
},
|
|
112
|
+
entry.file,
|
|
113
|
+
{ islands },
|
|
44
114
|
);
|
|
115
|
+
return `<div id="${SPA_ROOT_ID}">${html}</div>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One collector per RENDER, never module-global: two requests render different params, and a
|
|
120
|
+
* shared collector would bill one page for the other's islands. `hydrate` comes off the route, so
|
|
121
|
+
* an island never declares its own timing, and `resolve` is the build's — identity when nothing
|
|
122
|
+
* built any, which fails at the first island by name rather than emitting an unusable entry.
|
|
123
|
+
*/
|
|
124
|
+
const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
|
|
125
|
+
createIslandCollector({
|
|
126
|
+
file: entry.file,
|
|
127
|
+
hydrate: entry.config.hydrate,
|
|
128
|
+
...(options.resolveIsland === undefined ? {} : { resolve: options.resolveIsland(entry.file) }),
|
|
129
|
+
});
|
|
45
130
|
|
|
46
131
|
/**
|
|
47
|
-
* Head +
|
|
132
|
+
* Head + body for one route render. Exported because the build's prerenderer must emit the same
|
|
48
133
|
* document `x dev` serves — two document builders is how a page that works in dev ships broken.
|
|
49
134
|
*/
|
|
50
|
-
export async function routeDocument(
|
|
51
|
-
|
|
135
|
+
export async function routeDocument(
|
|
136
|
+
entry: RouteEntry,
|
|
137
|
+
ctx: DevRouteData,
|
|
138
|
+
options: DocumentOptions = {},
|
|
139
|
+
): Promise<string> {
|
|
140
|
+
return documentFrom(entry, ctx, await routeDataFor(entry.config, ctx), options);
|
|
52
141
|
}
|
|
53
142
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
143
|
+
/**
|
|
144
|
+
* The document from data ALREADY resolved. Split from `routeDocument` so one request resolves
|
|
145
|
+
* `load` exactly once: `stream` renders head and body separately, and resolving in each would let
|
|
146
|
+
* a `<title>` describe content the body does not contain.
|
|
147
|
+
*
|
|
148
|
+
* The hydration runtime is appended after the body and only after it: what strategies a page needs
|
|
149
|
+
* is a fact about the islands the walk just recorded, so emitting it earlier would either guess or
|
|
150
|
+
* ship the whole runtime to a page with no island — the 0kb baseline, spent on nothing.
|
|
151
|
+
*/
|
|
152
|
+
async function documentFrom(
|
|
153
|
+
entry: RouteEntry,
|
|
154
|
+
ctx: DevRouteData,
|
|
155
|
+
data: RouteData,
|
|
156
|
+
options: DocumentOptions,
|
|
157
|
+
): Promise<string> {
|
|
158
|
+
const islands = collectorFor(entry, options);
|
|
159
|
+
const [head, body] = await Promise.all([
|
|
160
|
+
headFor(entry, ctx, data),
|
|
161
|
+
routeBody(entry, ctx, data, islands),
|
|
162
|
+
]);
|
|
163
|
+
return (
|
|
164
|
+
`<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
|
|
165
|
+
`<body>${body}${hydrateRuntime(islands.directives)}</body></html>`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
57
168
|
|
|
58
169
|
async function resultFor(
|
|
59
170
|
entry: RouteEntry,
|
|
60
|
-
|
|
171
|
+
request: DevRouteData,
|
|
61
172
|
options: DevRenderOptions,
|
|
62
173
|
isr: IsrController,
|
|
63
174
|
ctx: Ctx,
|
|
64
175
|
): Promise<RenderResult> {
|
|
65
|
-
const url = new URL(
|
|
176
|
+
const url = new URL(request.url);
|
|
177
|
+
// ONCE per request, before the mode is chosen. Every branch below reads this same object, so a
|
|
178
|
+
// route's `load` runs exactly once however its mode splits head from body.
|
|
179
|
+
const data = await routeDataFor(entry.config, request);
|
|
66
180
|
switch (entry.config.render) {
|
|
67
181
|
case 'static': {
|
|
68
182
|
// Not `renderStatic`: that enumerates every prerendered path for the build. A request
|
|
69
183
|
// names exactly one, and it earns the same content-hashed headers.
|
|
70
|
-
const body = await
|
|
184
|
+
const body = await documentFrom(entry, request, data, options);
|
|
71
185
|
return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
72
186
|
}
|
|
73
187
|
case 'isr': {
|
|
74
|
-
const served = await isr.serve(url.pathname, () =>
|
|
188
|
+
const served = await isr.serve(url.pathname, () =>
|
|
189
|
+
documentFrom(entry, request, data, options),
|
|
190
|
+
);
|
|
75
191
|
return served.result;
|
|
76
192
|
}
|
|
77
193
|
case 'spa':
|
|
194
|
+
// The shell renders no body by definition, but it still carries the surface's CSS: the
|
|
195
|
+
// client paints into `#x-root` and a flash of unstyled shell is the mode's own regression.
|
|
78
196
|
return renderSpa({
|
|
79
197
|
entry,
|
|
80
198
|
buildId: options.buildId,
|
|
81
|
-
head: await headFor(entry, data),
|
|
199
|
+
head: (await headFor(entry, request, data)) + styleTag(entry),
|
|
82
200
|
chunks: [],
|
|
83
|
-
lang:
|
|
201
|
+
lang: lang(),
|
|
84
202
|
});
|
|
85
203
|
case 'stream': {
|
|
86
|
-
|
|
204
|
+
// The shell IS the component: nothing can yet mark a subtree as a hole. Solid's `Suspense`
|
|
205
|
+
// is not the missing piece and never will be here — it calls `getContextId()`, which throws
|
|
206
|
+
// outside a Solid renderer, and this package's JSX factory is inert by design. A hole marker
|
|
207
|
+
// has to be the framework's own. Until it exists the first flush carries the whole body —
|
|
208
|
+
// correct output, no streaming benefit.
|
|
209
|
+
const islands = collectorFor(entry, options);
|
|
210
|
+
const [head, shell] = await Promise.all([
|
|
211
|
+
headFor(entry, request, data),
|
|
212
|
+
routeBody(entry, request, data, islands),
|
|
213
|
+
]);
|
|
87
214
|
return streamResult(
|
|
88
215
|
{
|
|
89
|
-
head: `<!doctype html><html lang="${
|
|
90
|
-
shell
|
|
216
|
+
head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
|
|
217
|
+
// The runtime rides the first flush, with the shell it boots. A later chunk would leave
|
|
218
|
+
// the window between flush one and the close with inert islands and no listeners on
|
|
219
|
+
// them — which is exactly the first-click-lost failure `interaction` replay exists for.
|
|
220
|
+
shell: `${shell}${hydrateRuntime(islands.directives)}`,
|
|
91
221
|
holes: [],
|
|
92
222
|
},
|
|
93
223
|
{ buildId: options.buildId },
|
|
94
224
|
);
|
|
95
225
|
}
|
|
96
226
|
default:
|
|
97
|
-
return renderSsr(
|
|
98
|
-
|
|
99
|
-
|
|
227
|
+
return renderSsr(
|
|
228
|
+
{ entry, params: request.params, url, ctx },
|
|
229
|
+
() => documentFrom(entry, request, data, options),
|
|
230
|
+
{ buildId: options.buildId },
|
|
231
|
+
);
|
|
100
232
|
}
|
|
101
233
|
}
|
|
102
234
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The one `RunningServices` every `dev-roles` test file boots roles against, and the one reset
|
|
2
|
+
// between them. Shared rather than copied, for the reason `policy-fixture.ts` gives: three files
|
|
3
|
+
// start the same roles, and a second copy of the runtime drifts while each file keeps passing.
|
|
4
|
+
//
|
|
5
|
+
// Its own module rather than a `.test.ts` neighbours import, because `tsconfig.json` excludes
|
|
6
|
+
// `*.test.ts` — a fixture written there is one `tsc` never reads.
|
|
7
|
+
|
|
8
|
+
import { noopPurgeDriver } from '@ultimat3/cache';
|
|
9
|
+
import { resetLifecycle } from '@ultimat3/core';
|
|
10
|
+
import {
|
|
11
|
+
createMemoryDriver,
|
|
12
|
+
createMemoryEventBus,
|
|
13
|
+
createMemoryOutboxStore,
|
|
14
|
+
resetJobs,
|
|
15
|
+
resetJobsFacade,
|
|
16
|
+
resetTasks,
|
|
17
|
+
} from '@ultimat3/jobs';
|
|
18
|
+
import { createMemoryDriver as createMemoryMailDriver } from '@ultimat3/mail';
|
|
19
|
+
import { DEFAULT_PRESENCE_TTL_MS, InProcessTransport } from '@ultimat3/realtime';
|
|
20
|
+
import { defineStorage, localDriver } from '@ultimat3/storage';
|
|
21
|
+
import type { RunningServices } from './dev-runtime';
|
|
22
|
+
import { resolveServices } from './dev-services';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every service a role touches, embedded but real — no PGlite boot for a role-wiring test.
|
|
26
|
+
*
|
|
27
|
+
* `root` is a parameter and not a constant: each test file owns its own directory and deletes it,
|
|
28
|
+
* so two files sharing one on-disk storage root cannot leave the other's fixture half-removed.
|
|
29
|
+
*/
|
|
30
|
+
export function fixtureRuntime(root: string): RunningServices {
|
|
31
|
+
const services = resolveServices(root, {});
|
|
32
|
+
const transport = new InProcessTransport();
|
|
33
|
+
return {
|
|
34
|
+
services,
|
|
35
|
+
db: { async ping() {}, async close() {} } as unknown as RunningServices['db'],
|
|
36
|
+
jobs: createMemoryDriver(),
|
|
37
|
+
// A real store, not a stub: the `worker` role starts the outbox relay against it, and a relay
|
|
38
|
+
// whose `claim()` rejects on the first 200ms tick is an unhandled rejection in whichever test
|
|
39
|
+
// happens to still be running.
|
|
40
|
+
outbox: createMemoryOutboxStore(),
|
|
41
|
+
events: createMemoryEventBus(),
|
|
42
|
+
transport,
|
|
43
|
+
transportDetail: 'in-process fanout',
|
|
44
|
+
// The sync role reads this to build its `PresenceRegistry`; the default is what a boot with no
|
|
45
|
+
// `NATS_URL` resolves to, so the fixture is the real number rather than a rounder one.
|
|
46
|
+
presenceTtlMs: DEFAULT_PRESENCE_TTL_MS,
|
|
47
|
+
storage: defineStorage({ disks: { local: localDriver({ root: `${root}/storage` }) } }),
|
|
48
|
+
mail: createMemoryMailDriver(),
|
|
49
|
+
mailDetail: 'embedded',
|
|
50
|
+
purge: noopPurgeDriver(),
|
|
51
|
+
purgeDetail: 'none',
|
|
52
|
+
stop: async () => transport.close(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The process-global state one started role leaves behind. `resetLifecycle` is the load-bearing
|
|
58
|
+
* one: core's lifecycle is process-wide and a stopped server leaves it drained, so without it the
|
|
59
|
+
* SECOND web role in a file answers every request `X_DRAINING` — a suite that only passes when its
|
|
60
|
+
* tests are run one at a time. `@ultimat3/http`'s own server suite resets it for the same reason.
|
|
61
|
+
*/
|
|
62
|
+
export function resetDevRolesState(): void {
|
|
63
|
+
resetJobs();
|
|
64
|
+
resetJobsFacade();
|
|
65
|
+
resetTasks();
|
|
66
|
+
resetLifecycle();
|
|
67
|
+
}
|