@ultimat3/cli 11.2.0 → 12.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 +52 -2
- package/README.md +4 -4
- package/package.json +28 -28
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +79 -9
- package/src/cmd-dev.ts +6 -0
- package/src/cmd-doctor.ts +74 -16
- package/src/cmd-errors.ts +6 -0
- package/src/cmd-generate.ts +2 -27
- package/src/cmd-i18n.ts +16 -1
- package/src/cmd-shot-island.ts +5 -0
- package/src/cmd-shot.ts +17 -10
- package/src/compile-externals.ts +7 -4
- package/src/dev-queue.ts +49 -9
- package/src/dev-replica.ts +99 -0
- package/src/dev-roles-fixture.ts +7 -0
- package/src/dev-roles.ts +44 -30
- package/src/dev-sync.ts +45 -1
- package/src/error-codes.ts +16 -0
- package/src/i18n-index.ts +40 -0
- package/src/i18n-registration.ts +43 -1
- package/src/island-bundle.ts +18 -7
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +6 -1
- package/src/parse.ts +39 -6
- package/src/port-probe.ts +22 -0
- package/src/serve.ts +7 -1
- package/src/shot-browser.ts +84 -0
- package/src/templates/scaffold-app.ts +2 -0
- package/src/templates/scaffold-docs.ts +10 -4
- package/src/templates/scaffold-http.ts +84 -0
- package/src/templates/scaffold-repo.ts +20 -0
- package/src/templates/scaffold-roles.ts +31 -3
- package/src/verify-checks.ts +36 -0
- package/src/verify-step.ts +8 -0
- package/src/island-solid-production.ts +0 -129
package/src/cmd-shot-island.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { SHOT_DIR } from './shot-server';
|
|
|
27
27
|
export function islandBrowser(input: {
|
|
28
28
|
readonly root: string;
|
|
29
29
|
readonly executablePath?: string | undefined;
|
|
30
|
+
readonly cdpUrl?: string | undefined;
|
|
30
31
|
}): IslandBrowser {
|
|
31
32
|
const byViewport = new Map<string, Promise<ScrapeDriver>>();
|
|
32
33
|
return (viewport: IslandViewport): Promise<ScrapeDriver> => {
|
|
@@ -36,6 +37,7 @@ export function islandBrowser(input: {
|
|
|
36
37
|
const started = appBrowser({
|
|
37
38
|
root: input.root,
|
|
38
39
|
...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
|
|
40
|
+
...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
|
|
39
41
|
viewport: { width: viewport.width, height: viewport.height },
|
|
40
42
|
});
|
|
41
43
|
byViewport.set(key, started);
|
|
@@ -86,6 +88,8 @@ export interface IslandShotInput {
|
|
|
86
88
|
readonly timeoutMs: number;
|
|
87
89
|
readonly extraHosts?: string | undefined;
|
|
88
90
|
readonly executablePath?: string | undefined;
|
|
91
|
+
/** A provider's session, or a sidecar. One attach per viewport, memoised like a launch. */
|
|
92
|
+
readonly cdpUrl?: string | undefined;
|
|
89
93
|
readonly boot: () => Promise<ShotServer>;
|
|
90
94
|
/** Injected by a test, so the whole path is proved on a machine with no Chrome. */
|
|
91
95
|
readonly driver?: IslandBrowser | undefined;
|
|
@@ -124,6 +128,7 @@ export async function islandShot(input: IslandShotInput): Promise<IslandArtifact
|
|
|
124
128
|
islandBrowser({
|
|
125
129
|
root: input.root,
|
|
126
130
|
...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
|
|
131
|
+
...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
|
|
127
132
|
}),
|
|
128
133
|
boot: input.boot,
|
|
129
134
|
settleMs: input.settleMs,
|
package/src/cmd-shot.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
|
11
11
|
import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
12
12
|
import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
|
|
13
13
|
import { requireAppRoot } from './app-root';
|
|
14
|
-
import { appBrowser
|
|
14
|
+
import { appBrowser } from './browser-launcher';
|
|
15
15
|
import { islandShot, islandShotResult, refuseRouteWithIsland } from './cmd-shot-island';
|
|
16
16
|
import type { CliCommand, CommandContext } from './command';
|
|
17
17
|
import { BadFlagError, MissingPositionalError } from './errors';
|
|
@@ -19,6 +19,7 @@ import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
|
19
19
|
import type { CommandResult } from './output';
|
|
20
20
|
import type { ParsedArgs } from './parse';
|
|
21
21
|
import { flagBool, flagString } from './parse';
|
|
22
|
+
import { shotBrowserChoice } from './shot-browser';
|
|
22
23
|
import type { BootDevServer, ShotServer } from './shot-server';
|
|
23
24
|
import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
|
|
24
25
|
import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
|
|
@@ -285,6 +286,11 @@ export const shotCommand: CliCommand = {
|
|
|
285
286
|
{ name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
|
|
286
287
|
{ name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
|
|
287
288
|
{ name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
|
|
289
|
+
{
|
|
290
|
+
name: 'cdp-url',
|
|
291
|
+
type: 'string',
|
|
292
|
+
summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
|
|
293
|
+
},
|
|
288
294
|
{ name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
|
|
289
295
|
// A FLAG on `x shot` and never a second command: photographing a route and photographing a
|
|
290
296
|
// component are one job with two subjects, and a parallel command would be the second path
|
|
@@ -314,15 +320,14 @@ export const shotCommand: CliCommand = {
|
|
|
314
320
|
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
315
321
|
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
316
322
|
const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
323
|
+
// Which browser this run gets — start one here, or attach to one somebody else is running.
|
|
324
|
+
// Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
|
|
325
|
+
// provider session exists to pay for a typo.
|
|
326
|
+
const { cdpUrl, executablePath } = shotBrowserChoice({
|
|
327
|
+
cdpFlag: flagString(ctx.args, 'cdp-url'),
|
|
328
|
+
browserFlag: flagString(ctx.args, 'browser'),
|
|
329
|
+
env: ctx.env,
|
|
330
|
+
});
|
|
326
331
|
const out = flagString(ctx.args, 'out');
|
|
327
332
|
const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
|
|
328
333
|
if (island !== undefined && island !== '') {
|
|
@@ -337,6 +342,7 @@ export const shotCommand: CliCommand = {
|
|
|
337
342
|
settleMs,
|
|
338
343
|
timeoutMs,
|
|
339
344
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
345
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
340
346
|
...(flagString(ctx.args, 'allow-hosts') === undefined
|
|
341
347
|
? {}
|
|
342
348
|
: { extraHosts: flagString(ctx.args, 'allow-hosts') }),
|
|
@@ -349,6 +355,7 @@ export const shotCommand: CliCommand = {
|
|
|
349
355
|
const driver = await appBrowser({
|
|
350
356
|
root,
|
|
351
357
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
358
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
352
359
|
});
|
|
353
360
|
return shotResult(
|
|
354
361
|
await runShot({
|
package/src/compile-externals.ts
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
* `package.json` inside a `catch` — and both sit in `loadCtsDefault`, which only runs while Babel
|
|
9
9
|
* loads a `.cts` CONFIG FILE. `solid-loader.ts` passes `babelrc: false, configFile: false`, so no
|
|
10
10
|
* config file is ever loaded and neither line is reachable at run time. The bundler walks them
|
|
11
|
-
* anyway, and that is the whole failure: Bun 1.3
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* anyway, and that is the whole failure: Bun 1.3 refuses the build with
|
|
12
|
+
* `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
|
|
13
|
+
* unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
|
|
14
|
+
* That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
|
|
15
|
+
* `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
|
|
16
|
+
* 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
|
|
17
|
+
* `--compile` graph that must not reach an unresolvable `require`, on either Bun.
|
|
15
18
|
*
|
|
16
19
|
* Marking the dead specifier external rather than the two live ones: `serve.ts` calls
|
|
17
20
|
* `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
|
package/src/dev-queue.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type PostgresIdempotencyStore,
|
|
9
9
|
postgresIdempotencyStore,
|
|
10
10
|
resetIdempotency,
|
|
11
|
+
SQL_AUDIT_TABLE,
|
|
11
12
|
SQL_IDEMPOTENCY_TABLE,
|
|
12
13
|
setIdempotencyStore,
|
|
13
14
|
} from '@ultimat3/action';
|
|
@@ -36,12 +37,19 @@ import {
|
|
|
36
37
|
setJobDriver,
|
|
37
38
|
setJobsFacade,
|
|
38
39
|
} from '@ultimat3/jobs';
|
|
40
|
+
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './dev-replica';
|
|
39
41
|
import type { DevServices } from './dev-services';
|
|
40
42
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
43
|
|
|
42
44
|
/** Both embedded and external clients boot lazily and close explicitly. */
|
|
43
45
|
export type DevDbClient = PgliteClient | PostgresClient;
|
|
44
46
|
|
|
47
|
+
/** The primary this boot owns, plus the standby pool it opened — `stop()` closes both. */
|
|
48
|
+
interface StartedDb {
|
|
49
|
+
readonly client: DevDbClient;
|
|
50
|
+
readonly replica: PostgresClient | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
export interface RunningQueue {
|
|
46
54
|
readonly db: DevDbClient;
|
|
47
55
|
readonly jobs: JobDriver;
|
|
@@ -62,7 +70,20 @@ export interface RunningQueue {
|
|
|
62
70
|
stop(): Promise<void>;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The boot's own client, and the AMBIENT one, which are deliberately not always the same object.
|
|
75
|
+
*
|
|
76
|
+
* `setDbClient` receives the replicated pair when `DATABASE_REPLICA_URL` names a standby, so an
|
|
77
|
+
* app repository reading through `db()` inside an open `withReplicaReads` scope can be served by
|
|
78
|
+
* it. Everything this file does itself — `applySchema`, the `PgExecutor` behind the queue, the
|
|
79
|
+
* outbox, `ping()`, `close()` — keeps the PRIMARY: DDL, a claim and a migration are writes by
|
|
80
|
+
* definition, and routing one would be `25006` at best.
|
|
81
|
+
*
|
|
82
|
+
* Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
|
|
83
|
+
* it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
|
|
84
|
+
* so `DATABASE_REPLICA_URL` was read by no booted process at all.
|
|
85
|
+
*/
|
|
86
|
+
function startDb(services: DevServices, env: ReplicaEnv = process.env): StartedDb {
|
|
66
87
|
const binding = services.db;
|
|
67
88
|
const client =
|
|
68
89
|
binding.mode === 'embedded'
|
|
@@ -70,8 +91,9 @@ function startDb(services: DevServices): DevDbClient {
|
|
|
70
91
|
// here is a second thing to keep right when the form changes.
|
|
71
92
|
createPgliteClient({ dataDir: pgliteDataDir(binding.url) })
|
|
72
93
|
: createPostgresClient({ url: binding.url });
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const attached = attachReplica(client, replicaUrlFor(binding, env));
|
|
95
|
+
setDbClient(attached.client);
|
|
96
|
+
return { client, replica: attached.replica };
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
/**
|
|
@@ -111,6 +133,13 @@ async function applySchema(client: DevDbClient): Promise<void> {
|
|
|
111
133
|
for (const ddl of [
|
|
112
134
|
SQL_JOBS_TABLE,
|
|
113
135
|
SQL_IDEMPOTENCY_TABLE,
|
|
136
|
+
// The DDL only, and deliberately NO `setAuditSink` beside `setIdempotencyStore` below: there
|
|
137
|
+
// is no default audit sink on purpose, so `X_AUDIT_SINK_MISSING` keeps firing at boot for an
|
|
138
|
+
// app that declares `audit: true` and installs none. Applying the table without installing a
|
|
139
|
+
// sink is the same call `SQL_RATE_LIMIT_TABLE` already makes — one round trip at boot on a
|
|
140
|
+
// possibly-unused table, against `postgresAuditSink` failing its first write with
|
|
141
|
+
// `relation "x_audit" does not exist`.
|
|
142
|
+
SQL_AUDIT_TABLE,
|
|
114
143
|
SQL_RATE_LIMIT_TABLE,
|
|
115
144
|
SQL_AUTH_LIMIT_TABLES,
|
|
116
145
|
]) {
|
|
@@ -148,7 +177,11 @@ async function applySchema(client: DevDbClient): Promise<void> {
|
|
|
148
177
|
* `startServices` runs before `loadApp`, so the store is in place before `registerAction`
|
|
149
178
|
* evaluates a `scope: 'shared'` declaration against it.
|
|
150
179
|
*/
|
|
151
|
-
async function startJobs(
|
|
180
|
+
async function startJobs(
|
|
181
|
+
client: DevDbClient,
|
|
182
|
+
replica: PostgresClient | undefined,
|
|
183
|
+
overrides?: RuntimeOverrides,
|
|
184
|
+
): Promise<RunningQueue> {
|
|
152
185
|
await applySchema(client);
|
|
153
186
|
const executor = pgExecutorFor(client);
|
|
154
187
|
const driver = overrides?.jobs ?? createPgDriver({ executor });
|
|
@@ -171,7 +204,7 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
|
|
|
171
204
|
outbox,
|
|
172
205
|
events,
|
|
173
206
|
idempotency,
|
|
174
|
-
stop: () => releaseQueue(client, driver),
|
|
207
|
+
stop: () => releaseQueue(client, driver, replica),
|
|
175
208
|
};
|
|
176
209
|
}
|
|
177
210
|
|
|
@@ -184,7 +217,11 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
|
|
|
184
217
|
* The facade goes with the driver for the same reason: an enqueue routed through a store bound to
|
|
185
218
|
* a closed client is a staged row nothing will ever publish.
|
|
186
219
|
*/
|
|
187
|
-
async function releaseQueue(
|
|
220
|
+
async function releaseQueue(
|
|
221
|
+
db: DevDbClient,
|
|
222
|
+
jobs: JobDriver | undefined,
|
|
223
|
+
replica?: PostgresClient,
|
|
224
|
+
): Promise<void> {
|
|
188
225
|
// The idempotency store goes back to the memory default for the same reason the facade does: it
|
|
189
226
|
// holds this client, and the next command in this process would reserve keys over a closed one.
|
|
190
227
|
resetIdempotency();
|
|
@@ -193,6 +230,9 @@ async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promi
|
|
|
193
230
|
setDbClient(undefined);
|
|
194
231
|
await jobs?.close?.();
|
|
195
232
|
await db.close();
|
|
233
|
+
// After the primary, and never instead of it: a standby pool left open is a connection slot on
|
|
234
|
+
// the other server that nothing in this process can reach again.
|
|
235
|
+
await replica?.close();
|
|
196
236
|
}
|
|
197
237
|
|
|
198
238
|
/**
|
|
@@ -205,18 +245,18 @@ export async function startQueue(
|
|
|
205
245
|
services: DevServices,
|
|
206
246
|
overrides?: RuntimeOverrides,
|
|
207
247
|
): Promise<RunningQueue> {
|
|
208
|
-
const db = startDb(services);
|
|
248
|
+
const { client: db, replica } = startDb(services);
|
|
209
249
|
try {
|
|
210
250
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
211
251
|
// fails at boot rather than on some later query.
|
|
212
252
|
await db.ping();
|
|
213
|
-
return await startJobs(db, overrides);
|
|
253
|
+
return await startJobs(db, replica, overrides);
|
|
214
254
|
} catch (error) {
|
|
215
255
|
// `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
|
|
216
256
|
// the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
|
|
217
257
|
// release them. The rejection that started the unwind is the one worth reporting.
|
|
218
258
|
try {
|
|
219
|
-
await releaseQueue(db, undefined);
|
|
259
|
+
await releaseQueue(db, undefined, replica);
|
|
220
260
|
} catch {
|
|
221
261
|
// Cleanup noise never replaces the boot failure.
|
|
222
262
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Read-replica routing, WIRED. `@ultimat3/db` ships both halves and a booted process reached
|
|
2
|
+
// neither, so the capability was shipped and unactivated — the "declared and never wired" class
|
|
3
|
+
// this release exists to eliminate.
|
|
4
|
+
//
|
|
5
|
+
// Two things had to be false for it to route, and both were:
|
|
6
|
+
//
|
|
7
|
+
// 1. `defaultClient()` is the one place db composes `replicatedClient(primary, replica)` from
|
|
8
|
+
// `DATABASE_REPLICA_URL`, and it runs only from `baseClient()` — "the client an app installed
|
|
9
|
+
// none for". Every process the framework boots installs one: `dev-queue.ts` calls
|
|
10
|
+
// `setDbClient(createPgliteClient(…) | createPostgresClient({ url }))`, so `defaultClient()` was
|
|
11
|
+
// unreachable from `x dev`, from `apps/web/server.ts` and from every container role.
|
|
12
|
+
// 2. Routing needs an OPEN scope as well as a configured replica, and nothing opened one.
|
|
13
|
+
//
|
|
14
|
+
// This file answers both from the boot, which is the only tier that may know about a request AND
|
|
15
|
+
// about a pool. It is deliberately NOT a change to `@ultimat3/http`'s pipeline: that would make the
|
|
16
|
+
// HTTP tier depend on `@ultimat3/db` — legal downward, and still a package that would then know
|
|
17
|
+
// what a database is.
|
|
18
|
+
|
|
19
|
+
import type { DbClient, PostgresClient } from '@ultimat3/db';
|
|
20
|
+
import {
|
|
21
|
+
createPostgresClient,
|
|
22
|
+
REPLICA_URL_ENV,
|
|
23
|
+
replicatedClient,
|
|
24
|
+
withReplicaReads,
|
|
25
|
+
} from '@ultimat3/db';
|
|
26
|
+
import type { Middleware } from '@ultimat3/http';
|
|
27
|
+
import type { ServiceBinding } from './dev-services';
|
|
28
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
29
|
+
|
|
30
|
+
export type ReplicaEnv = Readonly<Record<string, string | undefined>>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The replica url this boot should use, or `undefined`. Two conditions, and the second is the one
|
|
34
|
+
* a homework app depends on: an EMBEDDED database is PGlite in this process, which has no standby
|
|
35
|
+
* and never will, so a `DATABASE_REPLICA_URL` left over in a shell must not silently open a second
|
|
36
|
+
* pool beside it.
|
|
37
|
+
*/
|
|
38
|
+
export function replicaUrlFor(binding: ServiceBinding, env: ReplicaEnv): string | undefined {
|
|
39
|
+
if (binding.mode !== 'external') return undefined;
|
|
40
|
+
const url = env[REPLICA_URL_ENV];
|
|
41
|
+
return url === undefined || url.trim() === '' ? undefined : url;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a boot has to hold on to: the ambient client, and the pool `stop()` must close. */
|
|
45
|
+
export interface ReplicaAttachment {
|
|
46
|
+
/** What `setDbClient()` receives — the pair when one is configured, the primary otherwise. */
|
|
47
|
+
readonly client: DbClient;
|
|
48
|
+
/** The standby pool this boot opened, or nothing. `ReplicatedClient` has no `close()`. */
|
|
49
|
+
readonly replica: PostgresClient | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The primary as it is, or the primary with a standby behind it. Composed here rather than by
|
|
54
|
+
* calling `defaultClient()`, because that function builds its own primary from `DATABASE_URL` and
|
|
55
|
+
* this boot has already RESOLVED which database it is talking to (`resolveServices`) — asking the
|
|
56
|
+
* environment a second question is how a process ends up with two answers to "which database is
|
|
57
|
+
* this". The pieces are db's own, so the routing rule is still stated in one place.
|
|
58
|
+
*/
|
|
59
|
+
export function attachReplica(
|
|
60
|
+
primary: DbClient,
|
|
61
|
+
replicaUrl: string | undefined,
|
|
62
|
+
): ReplicaAttachment {
|
|
63
|
+
if (replicaUrl === undefined) return { client: primary, replica: undefined };
|
|
64
|
+
const replica = createPostgresClient({ url: replicaUrl, applicationName: 'ultimate-replica' });
|
|
65
|
+
return { client: replicatedClient(primary, replica), replica };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The scope, opened once per request, OUTSIDE the handler — so a write early in a request pins
|
|
70
|
+
* every later read in it to the primary, which is what read-your-writes means. `compose()` wraps
|
|
71
|
+
* each route handler, and every read and write a request makes happens inside one.
|
|
72
|
+
*
|
|
73
|
+
* EMPTY when no replica is configured, and that is the whole cost argument: a homework app
|
|
74
|
+
* installs no middleware, allocates no scope object and enters no `AsyncLocalStorage` run per
|
|
75
|
+
* request. With one configured, the list is a single frame.
|
|
76
|
+
*/
|
|
77
|
+
export function replicaMiddleware(binding: ServiceBinding, env: ReplicaEnv): readonly Middleware[] {
|
|
78
|
+
if (replicaUrlFor(binding, env) === undefined) return [];
|
|
79
|
+
return [(request, ctx, next) => withReplicaReads(() => next(request, ctx))];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The boot's `RuntimeOverrides` with the scope middleware laid in FRONT of whatever a host
|
|
84
|
+
* supplied — `compose([a, b])` runs `a` outermost, and the scope must open outside every read and
|
|
85
|
+
* write the request makes or a write early in it cannot pin the reads after it.
|
|
86
|
+
*
|
|
87
|
+
* Returns the caller's own value UNCHANGED when no replica is configured, which is what keeps a
|
|
88
|
+
* homework app from paying for this: no key added, no empty array, nothing for `startRoles` to
|
|
89
|
+
* forward.
|
|
90
|
+
*/
|
|
91
|
+
export function replicaOverrides(
|
|
92
|
+
overrides: RuntimeOverrides | undefined,
|
|
93
|
+
binding: ServiceBinding,
|
|
94
|
+
env: ReplicaEnv,
|
|
95
|
+
): RuntimeOverrides | undefined {
|
|
96
|
+
const middleware = replicaMiddleware(binding, env);
|
|
97
|
+
if (middleware.length === 0) return overrides;
|
|
98
|
+
return { ...overrides, middleware: [...middleware, ...(overrides?.middleware ?? [])] };
|
|
99
|
+
}
|
package/src/dev-roles-fixture.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { noopPurgeDriver } from '@ultimat3/cache';
|
|
9
9
|
import { resetLifecycle } from '@ultimat3/core';
|
|
10
|
+
import { resetHttpConfig } from '@ultimat3/http';
|
|
10
11
|
import {
|
|
11
12
|
createMemoryDriver,
|
|
12
13
|
createMemoryEventBus,
|
|
@@ -64,4 +65,10 @@ export function resetDevRolesState(): void {
|
|
|
64
65
|
resetJobsFacade();
|
|
65
66
|
resetTasks();
|
|
66
67
|
resetLifecycle();
|
|
68
|
+
// `configureHttp()` is a process-global registration made at MODULE scope, so one file that
|
|
69
|
+
// loads an app leaves that app's CORS origins, body limit and buckets standing for every later
|
|
70
|
+
// file in the same `bun test` process — and a scaffolded app now ships such a module
|
|
71
|
+
// (`apps/web/app/http.ts`). Nothing is broken today; this is the same rule the four resets above
|
|
72
|
+
// already follow, applied to the fifth global before it is the one that costs an afternoon.
|
|
73
|
+
resetHttpConfig();
|
|
67
74
|
}
|
package/src/dev-roles.ts
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
|
|
11
11
|
import type { RateLimitStore, Route, ServerHandle, ServerHooks } from '@ultimat3/http';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
configuredAuthenticator,
|
|
14
|
+
configuredHttp,
|
|
15
|
+
createServer,
|
|
16
|
+
defineHttpConfig,
|
|
17
|
+
mergeHttpConfig,
|
|
18
|
+
} from '@ultimat3/http';
|
|
13
19
|
import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
|
|
14
20
|
import {
|
|
15
21
|
createOutboxRelay,
|
|
@@ -257,37 +263,45 @@ function startWeb(options: StartRolesOptions): ServerHandle {
|
|
|
257
263
|
? {}
|
|
258
264
|
: { middleware: options.overrides.middleware }),
|
|
259
265
|
...(store === undefined ? {} : { rateLimitStore: store }),
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
266
|
+
// The app's own declaration UNDERNEATH, this boot's facts on top. Without the first half the
|
|
267
|
+
// entire HTTP tuning surface was unreachable from a shipped app — this literal was its only
|
|
268
|
+
// construction, so `cors.origins` stayed `[]` in every deployment (no cross-origin call could
|
|
269
|
+
// ever succeed), `bodyLimitBytes` stayed 1 MiB and `requestTimeoutMs` 30s for a bank and a
|
|
270
|
+
// blog alike. The ORDER is not a preference: `buildId`, the port, the CSP hashes of what this
|
|
271
|
+
// process emits and the scope of the store it installed are facts only the boot has.
|
|
272
|
+
config: defineHttpConfig(
|
|
273
|
+
mergeHttpConfig(configuredHttp(), {
|
|
274
|
+
port: options.port,
|
|
275
|
+
dev: binding.dev,
|
|
276
|
+
buildId: options.buildId,
|
|
277
|
+
hostname: binding.hostname,
|
|
278
|
+
signInPath: options.signInPath ?? null,
|
|
279
|
+
// One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
|
|
280
|
+
...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
|
|
281
|
+
// `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error, and it
|
|
282
|
+
// is DERIVED from the store rather than hardcoded — a literal here would be a second
|
|
283
|
+
// declaration quietly contradicting the object beside it, and `assertRateLimitScope` holds
|
|
284
|
+
// the two halves together. It answered `'process'` on every real boot until `startServices`
|
|
285
|
+
// resolved a store, so the shipped chart's three `web` replicas enforced
|
|
286
|
+
// `login: { limit: 5 }` as fifteen attempts, with `x verify` green.
|
|
287
|
+
rateLimit: { scope: store?.scope ?? 'process' },
|
|
288
|
+
// Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
|
|
289
|
+
// nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
|
|
290
|
+
// function of that body. Read after `loadApp` — importing the app IS what registered them.
|
|
291
|
+
// BOTH directives, and the script half is the one that was missing: the hydration runtime
|
|
292
|
+
// is emitted inline in every document that carries an island, so `script-src 'self'` meant
|
|
293
|
+
// no island booted anywhere the policy is enforced — which is every container, and never
|
|
294
|
+
// `x dev`, where it is report-only.
|
|
295
|
+
security: {
|
|
296
|
+
csp: {
|
|
297
|
+
extend: {
|
|
298
|
+
'style-src': inlineStyleSources(options.inlineStyles ?? []),
|
|
299
|
+
'script-src': inlineScriptSources(),
|
|
300
|
+
},
|
|
287
301
|
},
|
|
288
302
|
},
|
|
289
|
-
},
|
|
290
|
-
|
|
303
|
+
}),
|
|
304
|
+
),
|
|
291
305
|
}).start();
|
|
292
306
|
}
|
|
293
307
|
|
package/src/dev-sync.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@ultimat3/realtime/server';
|
|
17
17
|
import type { StartRolesOptions } from './dev-roles';
|
|
18
18
|
import { neighbouringPort, PORT_RANGE } from './flag-number';
|
|
19
|
+
import { portFree } from './port-probe';
|
|
19
20
|
import { syncAuthenticator } from './sync-authenticator';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -35,6 +36,46 @@ class SyncPortUnavailableError extends UltimateError {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The neighbour was already listening. `X_PORT_IN_USE` is this package's own and is exactly what
|
|
41
|
+
* `x doctor` reports for the same condition, so one taken port has one name wherever it is found.
|
|
42
|
+
*
|
|
43
|
+
* What shipped instead: `listenSyncNode`'s `Bun.serve` threw, `startSync` re-threw, and the
|
|
44
|
+
* dispatcher rendered the caught value into `X_CLI_UNEXPECTED`'s cause —
|
|
45
|
+
* `cause: Error: Failed to start server. Is port 4000 in use?`, `fix: x doctor --json`, from a
|
|
46
|
+
* command that had just printed `web listening on 3999`. Three defects in one output: an
|
|
47
|
+
* unstable code, a caught value rendered into a refusal, and a `fix:` that answered
|
|
48
|
+
* "no findings — environment is shippable" when run (#F5).
|
|
49
|
+
*/
|
|
50
|
+
class SyncPortInUseError extends UltimateError {
|
|
51
|
+
constructor(input: { port: number; webPort: number }) {
|
|
52
|
+
super({
|
|
53
|
+
code: 'X_PORT_IN_USE',
|
|
54
|
+
cause: `the sync role binds PORT + 1, so \`x dev --port ${input.webPort}\` needs port ${input.port} and something is already listening on it`,
|
|
55
|
+
fix: `x dev --port ${neighbouringPort(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
|
|
56
|
+
meta: { port: input.port, webPort: input.webPort },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a failed `listenSyncNode` really was, ASKED rather than read off the caught value: the
|
|
63
|
+
* thrown thing is `Bun.serve`'s own English and interpolating it into a `cause:` is what
|
|
64
|
+
* `scripts/catch-render.ts` refuses. `undefined` means "not a taken port" and the original value
|
|
65
|
+
* is re-thrown untouched — a catch-all that renamed every listener failure would be worse than
|
|
66
|
+
* the bare one it replaced.
|
|
67
|
+
*
|
|
68
|
+
* `probe` is injected so a test can be exactly "the port was taken" without racing a real socket.
|
|
69
|
+
*/
|
|
70
|
+
export async function syncBindRefusal(
|
|
71
|
+
webPort: number,
|
|
72
|
+
port: number,
|
|
73
|
+
probe: (value: number) => Promise<boolean> = portFree,
|
|
74
|
+
): Promise<UltimateError | undefined> {
|
|
75
|
+
if (await probe(port)) return undefined;
|
|
76
|
+
return new SyncPortInUseError({ port, webPort });
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
/**
|
|
39
80
|
* The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
|
|
40
81
|
* one to it would pick a specific port instead.
|
|
@@ -131,8 +172,9 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
131
172
|
}),
|
|
132
173
|
});
|
|
133
174
|
await node.start();
|
|
175
|
+
const port = syncPortFor(options.port);
|
|
134
176
|
try {
|
|
135
|
-
const listener = listenSyncNode(node, { port
|
|
177
|
+
const listener = listenSyncNode(node, { port });
|
|
136
178
|
return {
|
|
137
179
|
url: listener.url,
|
|
138
180
|
stop: async () => {
|
|
@@ -142,6 +184,8 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
142
184
|
};
|
|
143
185
|
} catch (error) {
|
|
144
186
|
await node.stop();
|
|
187
|
+
const refusal = await syncBindRefusal(options.port, port);
|
|
188
|
+
if (refusal !== undefined) throw refusal;
|
|
145
189
|
throw error;
|
|
146
190
|
}
|
|
147
191
|
}
|
package/src/error-codes.ts
CHANGED
|
@@ -52,6 +52,13 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
52
52
|
'X_STORAGE_UNWRITABLE',
|
|
53
53
|
'X_STORAGE_SECRET_DEV',
|
|
54
54
|
'X_MANIFEST_STALE',
|
|
55
|
+
// The other half of the same file, and the one that was silence: `x manifest` writes
|
|
56
|
+
// `x.manifest.json`, `AGENTS.md` tells an agent that facts live in it, `x dev` prints its path —
|
|
57
|
+
// and nothing ever ran the command, so the gate's `manifest` step reported green over a file
|
|
58
|
+
// that has never existed in any app `x new` produced. Its own code because the repair differs
|
|
59
|
+
// from both neighbours: `X_MANIFEST_DRIFT` means the committed file disagrees with the code and
|
|
60
|
+
// `X_MANIFEST_STALE` means `openapi.json` does; this one means there is nothing there at all.
|
|
61
|
+
'X_MANIFEST_MISSING',
|
|
55
62
|
'X_BUDGET_UNMEASURED',
|
|
56
63
|
// The other half of #271, and the half no runtime can raise: a route reads a live hook and boots
|
|
57
64
|
// no module in a browser, so its rows have nowhere to arrive and the page renders its loading
|
|
@@ -146,6 +153,14 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
146
153
|
*/
|
|
147
154
|
export const CLI_BORROWED_ERROR_CODES = [
|
|
148
155
|
'X_NOT_IMPLEMENTED',
|
|
156
|
+
// `@ultimat3/policy`'s, and the gate's `policy` step reports it verbatim — cause, fix and the
|
|
157
|
+
// nearest declared name all come from `permissionUnknown`. A CLI-owned twin would be a second
|
|
158
|
+
// wording for the condition `can()` already refuses at run time, and the two would drift.
|
|
159
|
+
'X_PERMISSION_UNKNOWN',
|
|
160
|
+
// `@ultimat3/db`'s, reported by `x doctor`'s database probe with that package's own two-branch
|
|
161
|
+
// fix. The CLI is the half that asks BEFORE a pool is opened; the condition and the remedy are
|
|
162
|
+
// both db's, and a CLI twin would be one unreachable database with two names.
|
|
163
|
+
'X_DB_UNAVAILABLE',
|
|
149
164
|
'X_CONFIG_INVALID',
|
|
150
165
|
'X_ENV_MISSING',
|
|
151
166
|
'X_ENV_EXAMPLE_DRIFT',
|
|
@@ -195,6 +210,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
195
210
|
X_PACKAGE_UNREFERENCED: 'a published workspace is not in the root tsconfig build graph',
|
|
196
211
|
X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
|
|
197
212
|
X_MANIFEST_STALE: 'openapi.json is stale',
|
|
213
|
+
X_MANIFEST_MISSING: 'the app ships no x.manifest.json',
|
|
198
214
|
X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
|
|
199
215
|
X_LIVE_ROUTE_NO_ISLAND: 'a route reads live rows and boots nothing that could receive them',
|
|
200
216
|
X_BUILD_FAILED: 'x build failed',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The one writer of `packages/i18n/src/index.ts`, shared by `x g` and `x i18n add|sync`.
|
|
2
|
+
//
|
|
3
|
+
// A catalog file existing on disk and the app being able to SELECT that locale are two different
|
|
4
|
+
// facts, and only this closes the gap: the index hardcodes `locales: { en }`, so a locale whose
|
|
5
|
+
// catalog nothing registered renders `⟦key⟧` — which the gate's `i18n` step refuses outright
|
|
6
|
+
// (`X_CATALOG_UNREGISTERED`). `x i18n add fr` wrote the file, touched nothing else, and left
|
|
7
|
+
// `x verify` red with a fix line that named an edit nobody could perform (#F4).
|
|
8
|
+
|
|
9
|
+
// why: Bun has no synchronous existence check — `Bun.file(p).exists()` is async, and this decides
|
|
10
|
+
// whether to write at all, before any await the caller could interleave with.
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { containedPath } from './generate-write';
|
|
13
|
+
import { CATALOG_ROOT, i18nIndex } from './templates';
|
|
14
|
+
|
|
15
|
+
export const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
|
|
16
|
+
|
|
17
|
+
/** Every locale with a catalog on disk, sorted — the file names are the tags. */
|
|
18
|
+
export async function catalogLocales(root: string): Promise<readonly string[]> {
|
|
19
|
+
const catalogDir = containedPath(root, CATALOG_ROOT);
|
|
20
|
+
if (!existsSync(catalogDir)) return [];
|
|
21
|
+
const locales: string[] = [];
|
|
22
|
+
for await (const entry of new Bun.Glob('*.json').scan({ cwd: catalogDir, absolute: false })) {
|
|
23
|
+
locales.push(entry.replace(/\.json$/, ''));
|
|
24
|
+
}
|
|
25
|
+
return locales.sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Re-derives the FULL locale set from `packages/i18n/catalogs/` — never just the locale one
|
|
30
|
+
* invocation asked for — and rewrites the index to match. It bypasses `writeFiles` on purpose:
|
|
31
|
+
* this file is a projection of the catalog directory, never app-authored content a conflict check
|
|
32
|
+
* should protect. An app with no i18n package (deleted, or never scaffolded) is left alone, and
|
|
33
|
+
* that is what `written` reports.
|
|
34
|
+
*/
|
|
35
|
+
export async function syncI18nIndex(root: string): Promise<boolean> {
|
|
36
|
+
const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
|
|
37
|
+
if (!existsSync(indexAbsolute)) return false;
|
|
38
|
+
await Bun.write(indexAbsolute, i18nIndex(await catalogLocales(root)));
|
|
39
|
+
return true;
|
|
40
|
+
}
|