@ultimat3/cli 19.1.3 → 19.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +125 -8
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/app-openapi.ts +13 -5
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +53 -4
- package/src/budgets.ts +60 -7
- package/src/cmd-dev.ts +49 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-shot.ts +3 -1
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +28 -7
- package/src/dev-roles.ts +9 -8
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +17 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +75 -0
- package/src/doctor-offline.ts +122 -0
- package/src/duplicate-packages.ts +278 -0
- package/src/error-catalog.ts +4 -5
- package/src/error-codes.ts +6 -0
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/i18n-registration.ts +34 -5
- package/src/index.ts +3 -1
- package/src/island-bundle.ts +123 -10
- package/src/island-harness.ts +11 -4
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-errors.ts +2 -0
- package/src/mcp-host.ts +21 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +68 -16
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +17 -2
- package/src/shot-browser.ts +23 -4
- package/src/source-files.ts +3 -1
- package/src/static-report.ts +21 -1
- package/src/style-bundle.ts +124 -0
- package/src/style-csp.ts +14 -12
- package/src/style-routes.ts +56 -0
- package/src/sw-artifacts.ts +84 -12
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/resource-form-island.ts +13 -3
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +13 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +13 -7
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +33 -8
- package/src/web-binding.ts +22 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// One rebuild at a time. A reload is a full `appManifest()` plus a `buildIslands()` over every
|
|
2
|
+
// island, and the watcher had no in-flight guard: a 45ms drip of writes — a slow `git checkout`, a
|
|
3
|
+
// formatter walking the tree, `x db gen` — started one per file, 40 for 40 files, each assigning
|
|
4
|
+
// the same two state slots in COMPLETION order. So a slower earlier rebuild could land on top of a
|
|
5
|
+
// newer one, and the dev server then served a manifest built from source that had already changed.
|
|
6
|
+
|
|
7
|
+
/** What a tick does while a rebuild is already running: nothing, except become the next one. */
|
|
8
|
+
export type ReloadTrigger = (file: string) => void;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Serialise `run`, keeping only the LAST tick that arrived while it was busy. N ticks during one
|
|
12
|
+
* rebuild are exactly one more rebuild, for the newest file — never N queued ones, and never a
|
|
13
|
+
* dropped tick, which would leave the process serving the state before the author's last save.
|
|
14
|
+
*
|
|
15
|
+
* `onError` defaults to swallowing, because the caller that cares reports its own failure as a
|
|
16
|
+
* finding; a rejection escaping here would be an unhandled rejection that takes `x dev` down.
|
|
17
|
+
*/
|
|
18
|
+
export function coalesceReloads(
|
|
19
|
+
run: (file: string) => Promise<void> | void,
|
|
20
|
+
onError: (error: unknown, file: string) => void = () => undefined,
|
|
21
|
+
): ReloadTrigger {
|
|
22
|
+
let running = false;
|
|
23
|
+
let pending: string | undefined;
|
|
24
|
+
|
|
25
|
+
const start = (file: string): void => {
|
|
26
|
+
running = true;
|
|
27
|
+
// `Promise.resolve().then` rather than a bare call: a SYNCHRONOUS throw from `run` would
|
|
28
|
+
// otherwise escape the fs callback that triggered it, where nothing is listening.
|
|
29
|
+
void (async () => await run(file))()
|
|
30
|
+
.catch((error: unknown) => onError(error, file))
|
|
31
|
+
.finally(() => {
|
|
32
|
+
running = false;
|
|
33
|
+
const next = pending;
|
|
34
|
+
pending = undefined;
|
|
35
|
+
if (next !== undefined) start(next);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (file: string): void => {
|
|
40
|
+
if (running) {
|
|
41
|
+
pending = file;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
start(file);
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/dev-render.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
// document, it never decides what a mode means or what headers it earns.
|
|
4
4
|
//
|
|
5
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// writer, with the surface's compiled CSS LINKED — one content-hashed file per surface
|
|
7
|
+
// (`style-bundle.ts`), served `immutable`.
|
|
8
|
+
//
|
|
9
|
+
// It was inlined until 2026-09-06, on the argument that a `site/` page is a 0kb-JS artifact a CDN
|
|
10
|
+
// serves as one file and a link would add a round trip. The round trip is real and it is paid
|
|
11
|
+
// once: measured against ai-maxxing, every `app/` document carried the SAME 156,738-byte `<style>`
|
|
12
|
+
// block — 92% of the dashboard document — inside a response the pipeline sends
|
|
13
|
+
// `Cache-Control: private, no-store`, so the trip that argument saved was re-paid in full on every
|
|
14
|
+
// navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
|
|
15
|
+
// "second file" cost is one `Bun.write`.
|
|
9
16
|
|
|
10
17
|
import type { Ctx } from '@ultimat3/core';
|
|
11
18
|
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
@@ -32,8 +39,8 @@ import {
|
|
|
32
39
|
renderSsr,
|
|
33
40
|
staticHeaders,
|
|
34
41
|
streamResult,
|
|
35
|
-
stylesFor,
|
|
36
42
|
} from '@ultimat3/render/server';
|
|
43
|
+
import { styleBundle } from './style-bundle';
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
46
|
* Specifier → built chunk URL, bound to the route file the specifier is written relative to.
|
|
@@ -92,10 +99,24 @@ const headFor = async (
|
|
|
92
99
|
),
|
|
93
100
|
) + (options.pwaHead ?? '');
|
|
94
101
|
|
|
95
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
|
|
104
|
+
* imports none.
|
|
105
|
+
*
|
|
106
|
+
* In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
|
|
107
|
+
* there is render-blocking in every browser, exactly as the inline block was, so there is no
|
|
108
|
+
* window in which the document paints unstyled. Moving it to the body, or deferring it, is what
|
|
109
|
+
* would introduce a flash — never do that here.
|
|
110
|
+
*
|
|
111
|
+
* Read through `styleBundle()` rather than passed in through `DocumentOptions`: the registry it
|
|
112
|
+
* derives from is process-global (importing the app IS what fills it), so a caller that forgot to
|
|
113
|
+
* thread a resolver would serve a document with no CSS at all — and `appRoutes` is on this
|
|
114
|
+
* package's public surface, called as `appRoutes({ buildId })` by both tracked apps' contract
|
|
115
|
+
* tests. One reader, and it is the same one `styleRoutes` serves from.
|
|
116
|
+
*/
|
|
96
117
|
const styleTag = (entry: RouteEntry): string => {
|
|
97
|
-
const
|
|
98
|
-
return
|
|
118
|
+
const href = styleBundle().hrefFor(entry.surface);
|
|
119
|
+
return href === undefined ? '' : `<link rel="stylesheet" href="${href}">`;
|
|
99
120
|
};
|
|
100
121
|
|
|
101
122
|
/**
|
package/src/dev-roles.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
|
43
43
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
44
44
|
import { inlineScriptSources } from './script-csp';
|
|
45
45
|
import { inlineStyleSources } from './style-csp';
|
|
46
|
+
import { DEV_BINDING, type WebBinding } from './web-binding';
|
|
46
47
|
|
|
47
48
|
/** The roles `x dev` starts when `--role` names none, in boot order. */
|
|
48
49
|
export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
|
|
@@ -113,13 +114,9 @@ export interface StartRolesOptions {
|
|
|
113
114
|
readonly overrides?: RuntimeOverrides;
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Loopback and dev-mode. What `x dev` means, and what a container must override. */
|
|
122
|
-
export const DEV_BINDING: WebBinding = { dev: true, hostname: 'localhost' };
|
|
117
|
+
// Re-exported, not re-declared: `web-binding.ts` is a leaf so `dev-sync` can read the default
|
|
118
|
+
// without importing this module, which imports it.
|
|
119
|
+
export { DEV_BINDING, type WebBinding } from './web-binding';
|
|
123
120
|
|
|
124
121
|
export interface RunningRoles {
|
|
125
122
|
readonly roles: readonly Role[];
|
|
@@ -366,9 +363,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
366
363
|
// First, and for every role rather than only the two that open an HTTP socket: `worker` and
|
|
367
364
|
// `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
|
|
368
365
|
// and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
|
|
366
|
+
// `?? DEV_BINDING`, not "omit the key when `options.http` is undefined". The old spread left
|
|
367
|
+
// the metrics endpoint on Bun's `0.0.0.0` for exactly the caller that asked for loopback —
|
|
368
|
+
// `x dev`, which passes no `http` at all — so the same gap the sync node had was here too.
|
|
369
|
+
const binding = options.http ?? DEV_BINDING;
|
|
369
370
|
const metrics = startMetricsEndpoint({
|
|
370
371
|
port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
|
|
371
|
-
|
|
372
|
+
hostname: binding.hostname,
|
|
372
373
|
});
|
|
373
374
|
started.push(async () => metrics.stop());
|
|
374
375
|
|
package/src/dev-runtime.ts
CHANGED
|
@@ -299,7 +299,10 @@ export async function startServices(
|
|
|
299
299
|
// `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
|
|
300
300
|
// process cannot resolve the bus differently from the container it stands in for.
|
|
301
301
|
const bus: TransportSelection = selectTransport(env);
|
|
302
|
-
|
|
302
|
+
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
303
|
+
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
304
|
+
// a different answer than the middleware that routes to it.
|
|
305
|
+
const queue = await startQueue(services, overrides, env);
|
|
303
306
|
const { db, jobs, outbox, events } = queue;
|
|
304
307
|
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
305
308
|
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
package/src/dev-sync.ts
CHANGED
|
@@ -15,9 +15,10 @@ import {
|
|
|
15
15
|
SocketRegistry,
|
|
16
16
|
} from '@ultimat3/realtime/server';
|
|
17
17
|
import type { StartRolesOptions } from './dev-roles';
|
|
18
|
-
import { neighbouringPort, PORT_RANGE } from './flag-number';
|
|
18
|
+
import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
|
|
19
19
|
import { portFree } from './port-probe';
|
|
20
20
|
import { syncAuthenticator } from './sync-authenticator';
|
|
21
|
+
import { DEV_BINDING } from './web-binding';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Beside its one thrower rather than in `errors.ts`, which is at 461 of the 500-line ceiling —
|
|
@@ -52,7 +53,11 @@ class SyncPortInUseError extends UltimateError {
|
|
|
52
53
|
super({
|
|
53
54
|
code: 'X_PORT_IN_USE',
|
|
54
55
|
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
|
-
|
|
56
|
+
// `portPairAfter`, never `neighbouringPort`: `x dev` binds a PAIR, so the neighbour of the
|
|
57
|
+
// web port IS the sync port this refusal is about — the fix said `x dev --port 4000` for a
|
|
58
|
+
// run that had just died on 4000, and a test named "its fix is a command that ends the
|
|
59
|
+
// failure" pinned it.
|
|
60
|
+
fix: `x dev --port ${portPairAfter(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
|
|
56
61
|
meta: { port: input.port, webPort: input.webPort },
|
|
57
62
|
});
|
|
58
63
|
}
|
|
@@ -177,7 +182,16 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
177
182
|
await node.start();
|
|
178
183
|
const port = syncPortFor(options.port);
|
|
179
184
|
try {
|
|
180
|
-
|
|
185
|
+
// The SAME interface the web role binds, resolved from the same option and the same default.
|
|
186
|
+
// Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
|
|
187
|
+
// so the one socket that streams live database patches was the one socket on every
|
|
188
|
+
// interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
|
|
189
|
+
const binding = options.http ?? DEV_BINDING;
|
|
190
|
+
// No drain grace: there is one node here and it is the one going away. Its clients reconnect
|
|
191
|
+
// to it when `x dev` is back, and a grace that kept their patches flowing meanwhile was five
|
|
192
|
+
// seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a reconnect frame
|
|
193
|
+
// whose target does not exist yet.
|
|
194
|
+
const listener = listenSyncNode(node, { port, hostname: binding.hostname, drainGraceMs: 0 });
|
|
181
195
|
return {
|
|
182
196
|
url: listener.url,
|
|
183
197
|
registry,
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The app root watched one directory at a time, because the watch SET is a registration decision
|
|
2
|
+
// and not a filter. `watch(root, { recursive: true })` takes an inotify descriptor per directory
|
|
3
|
+
// in the tree — including every directory the dev loop then discards events from — so `.git/`,
|
|
4
|
+
// `node_modules/` and `.x/` (which this very process writes to, continuously) each cost a
|
|
5
|
+
// descriptor, a kernel queue entry and a JS callback per write, against a per-user descriptor
|
|
6
|
+
// budget of 8192 on many distributions. Bun 1.4.0's `fs.watch` takes no ignore option, so the
|
|
7
|
+
// only place the answer can be given early is at registration.
|
|
8
|
+
|
|
9
|
+
// why: Bun exposes no filesystem watcher, no directory listing and no synchronous stat —
|
|
10
|
+
// `Bun.file().exists()` is async and answers false for a DIRECTORY, which is the one thing this
|
|
11
|
+
// module has to decide. Delete each when Bun ships an equivalent.
|
|
12
|
+
import type { Dirent } from 'node:fs';
|
|
13
|
+
import { readdirSync, statSync, watch } from 'node:fs';
|
|
14
|
+
// why: Bun exposes no path-join primitive.
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { finiteCount, logger } from '@ultimat3/core';
|
|
17
|
+
import type { DevIgnore } from './dev-watch';
|
|
18
|
+
import { devIgnore } from './dev-watch';
|
|
19
|
+
import { pathSegments } from './path-segments';
|
|
20
|
+
|
|
21
|
+
/** What `node:fs`'s watcher hands a listener — `filename` is optional in fact, not only in type. */
|
|
22
|
+
export type WatchListener = (event: string, filename: string | Buffer | null | undefined) => void;
|
|
23
|
+
|
|
24
|
+
/** The one thing this module needs of a watcher, so a test can stand one up in four lines. */
|
|
25
|
+
export interface DirectoryWatcher {
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type WatchDirectory = (directory: string, listener: WatchListener) => DirectoryWatcher;
|
|
30
|
+
|
|
31
|
+
export interface WatchTreeOptions {
|
|
32
|
+
readonly root: string;
|
|
33
|
+
/** A source change, app-root-relative and POSIX-separated. Debounced. */
|
|
34
|
+
readonly onChange: (file: string) => void;
|
|
35
|
+
/** Trailing debounce. A save touching five files is one reload, not five. Default 30ms. */
|
|
36
|
+
readonly debounceMs?: number;
|
|
37
|
+
/** Test seam: the default is `node:fs`'s `watch(dir, { recursive: false })`. */
|
|
38
|
+
readonly watchDirectory?: WatchDirectory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WatchTree {
|
|
42
|
+
/** Directories holding a descriptor right now, app-root-relative; `''` is the app root. */
|
|
43
|
+
directories(): readonly string[];
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_DEBOUNCE_MS = 30;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every directory under `root` an event could be a source change in, `''` first. Ignored
|
|
51
|
+
* directories are not descended, so a `node_modules` tree costs one `readdir` and nothing else.
|
|
52
|
+
* A symlinked directory is deliberately not followed: `Dirent.isDirectory()` is false for one, and
|
|
53
|
+
* a workspace symlink pointing back into the checkout would otherwise be walked twice.
|
|
54
|
+
*/
|
|
55
|
+
export function admittedDirectories(root: string, ignore: DevIgnore): readonly string[] {
|
|
56
|
+
const admitted: string[] = [''];
|
|
57
|
+
const pending: string[] = [''];
|
|
58
|
+
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
|
59
|
+
for (const entry of childrenOf(join(root, next))) {
|
|
60
|
+
if (!entry.isDirectory()) continue;
|
|
61
|
+
const child = next === '' ? entry.name : `${next}/${entry.name}`;
|
|
62
|
+
if (ignore.ignores(child, true)) continue;
|
|
63
|
+
admitted.push(child);
|
|
64
|
+
pending.push(child);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return admitted.sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One directory's entries, or none. A directory removed between the listing above and this read, or
|
|
72
|
+
* one this user may not open, is neither a source change nor a finding anyone can act on.
|
|
73
|
+
*/
|
|
74
|
+
function childrenOf(path: string): readonly Dirent[] {
|
|
75
|
+
try {
|
|
76
|
+
return readdirSync(path, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nodeWatch: WatchDirectory = (directory, listener) =>
|
|
83
|
+
watch(directory, { recursive: false }, listener);
|
|
84
|
+
|
|
85
|
+
/** Whether the path is a directory right now — the question a `rename` event does not answer. */
|
|
86
|
+
function isDirectoryAt(path: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(path).isDirectory();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function watchTree(options: WatchTreeOptions): WatchTree {
|
|
95
|
+
const { root, onChange } = options;
|
|
96
|
+
// Screened before a single descriptor is taken. `??` guards NULLISH and `NaN` is not nullish, so
|
|
97
|
+
// an unparsed value walks past the default into `setTimeout(fn, NaN)`, which coerces to **0**:
|
|
98
|
+
// the debounce reads as installed and every keystroke runs a full `appManifest()` plus
|
|
99
|
+
// `buildIslands()`. `0` is admitted — "rebuild on the next tick" is a decision — and `Infinity`
|
|
100
|
+
// is not, because a reload that never fires is the same defect facing the other way.
|
|
101
|
+
const debounceMs = finiteCount(
|
|
102
|
+
'watchTree',
|
|
103
|
+
'debounceMs',
|
|
104
|
+
options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
105
|
+
);
|
|
106
|
+
const open = new WatchDirectoryMap(options.watchDirectory ?? nodeWatch, root);
|
|
107
|
+
let ignore = devIgnore(root);
|
|
108
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
+
let last = '';
|
|
110
|
+
let warned = false;
|
|
111
|
+
|
|
112
|
+
const schedule = (file: string): void => {
|
|
113
|
+
last = file;
|
|
114
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
115
|
+
timer = setTimeout(() => onChange(last), debounceMs);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const listen = (directory: string): void => {
|
|
119
|
+
open.add(directory, (event, filename) => {
|
|
120
|
+
if (typeof filename !== 'string' && !(filename instanceof Buffer)) {
|
|
121
|
+
// Bun's watcher delivers no filename when the WATCHED directory itself moves or is removed
|
|
122
|
+
// — `mv myapp myapp2`, a re-clone, a volume remount. Once, because the same rename can
|
|
123
|
+
// arrive on every descriptor at the same instant.
|
|
124
|
+
if (!warned) logger.warn('dev.watch.unnamed_event', { directory: directory || '.' });
|
|
125
|
+
warned = true;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const name = pathSegments(filename.toString()).join('/');
|
|
129
|
+
const file = directory === '' ? name : `${directory}/${name}`;
|
|
130
|
+
// Asked of the DISK, once, because every trailing-slash rule in a `.gitignore` turns on it
|
|
131
|
+
// and a `rename` says only that something moved. One stat against a whole rebuild.
|
|
132
|
+
const isDirectory = open.has(file) || isDirectoryAt(join(root, file));
|
|
133
|
+
if (event === 'rename') follow(file, isDirectory);
|
|
134
|
+
if (file === '.gitignore') {
|
|
135
|
+
// The ignore set is the app's own, so an edit to it changes which directories are watched
|
|
136
|
+
// at all. Not a source change: rebuilding the manifest for it would be a reload the author
|
|
137
|
+
// did not ask for on the one file whose whole job is saying what to leave alone.
|
|
138
|
+
ignore = devIgnore(root);
|
|
139
|
+
reconcile();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (ignore.ignores(file, isDirectory)) return;
|
|
143
|
+
schedule(file);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** A `rename` created or removed something: keep the descriptor set honest either way. */
|
|
148
|
+
const follow = (file: string, isDirectory: boolean): void => {
|
|
149
|
+
if (isDirectory && isDirectoryAt(join(root, file))) {
|
|
150
|
+
if (ignore.ignores(file, true)) return;
|
|
151
|
+
if (!open.has(file)) for (const found of subtree(file)) listen(found);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (open.has(file)) open.remove(file);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** The admitted directories at or under `directory`, discovered rather than assumed. */
|
|
158
|
+
const subtree = (directory: string): readonly string[] =>
|
|
159
|
+
admittedDirectories(join(root, directory), ignore).map((found) =>
|
|
160
|
+
found === '' ? directory : `${directory}/${found}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/** The whole set, re-derived: a directory the author just ignored gives its descriptor back. */
|
|
164
|
+
const reconcile = (): void => {
|
|
165
|
+
const admitted = new Set(admittedDirectories(root, ignore));
|
|
166
|
+
for (const held of open.directories()) if (!admitted.has(held)) open.remove(held);
|
|
167
|
+
for (const directory of admitted) if (!open.has(directory)) listen(directory);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const directory of admittedDirectories(root, ignore)) listen(directory);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
directories: () => open.directories(),
|
|
174
|
+
close(): void {
|
|
175
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
176
|
+
open.closeAll();
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The descriptors this watcher holds, keyed by app-root-relative directory. Its own type because
|
|
183
|
+
* removing one means removing everything under it: a deleted directory takes its children's
|
|
184
|
+
* descriptors with it, and a `Map` iterated by the caller would leak every one of them.
|
|
185
|
+
*/
|
|
186
|
+
class WatchDirectoryMap {
|
|
187
|
+
readonly #watchers = new Map<string, DirectoryWatcher>();
|
|
188
|
+
readonly #watch: WatchDirectory;
|
|
189
|
+
readonly #root: string;
|
|
190
|
+
|
|
191
|
+
constructor(watchDirectory: WatchDirectory, root: string) {
|
|
192
|
+
this.#watch = watchDirectory;
|
|
193
|
+
this.#root = root;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
add(directory: string, listener: WatchListener): void {
|
|
197
|
+
if (this.#watchers.has(directory)) return;
|
|
198
|
+
try {
|
|
199
|
+
this.#watchers.set(directory, this.#watch(join(this.#root, directory), listener));
|
|
200
|
+
} catch {
|
|
201
|
+
// A directory that vanished between the walk and the registration. The parent's own
|
|
202
|
+
// descriptor still reports anything that reappears there.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
has(directory: string): boolean {
|
|
207
|
+
return this.#watchers.has(directory);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
directories(): readonly string[] {
|
|
211
|
+
return [...this.#watchers.keys()].sort();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
remove(directory: string): void {
|
|
215
|
+
for (const held of this.#watchers.keys()) {
|
|
216
|
+
if (held !== directory && !held.startsWith(`${directory}/`)) continue;
|
|
217
|
+
this.#watchers.get(held)?.close();
|
|
218
|
+
this.#watchers.delete(held);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
closeAll(): void {
|
|
223
|
+
for (const watcher of this.#watchers.values()) watcher.close();
|
|
224
|
+
this.#watchers.clear();
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/dev-watch.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Which paths under the app root `x dev` must not watch, and the rule is REGISTRATION rather than
|
|
2
|
+
// filtering: `watch(root, { recursive: true })` takes one inotify descriptor per directory before
|
|
3
|
+
// any filter runs, so an answer given after the event has already cost the kernel queue, a JS
|
|
4
|
+
// callback and a slot out of `max_user_watches`. Measured on a monorepo root: 1901 descriptors,
|
|
5
|
+
// 1490 of them under `.git/` and `node_modules/`, and one `git status` delivering 5 events.
|
|
6
|
+
//
|
|
7
|
+
// The ignore set is the app's own `.gitignore`, read with git's own anchoring (`gitignore.ts`),
|
|
8
|
+
// plus a floor of directory names an ignore file need not name. It was seven hand-listed names,
|
|
9
|
+
// and both halves of that were wrong: nothing read `.gitignore`, so `tsconfig.tsbuildinfo` —
|
|
10
|
+
// rewritten by every `bun run typecheck` — ran a full `appManifest()` plus `buildIslands()`; and
|
|
11
|
+
// `dist` and `coverage` were matched at ANY depth, so an app's own `/dist` or `/coverage` route
|
|
12
|
+
// never reloaded at all, silently.
|
|
13
|
+
|
|
14
|
+
// why: Bun exposes no path-join primitive. The same necessity `fix-path.ts` already records.
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import type { IgnoreScope } from './gitignore';
|
|
17
|
+
import { ignoreScopes, isGitIgnored } from './gitignore';
|
|
18
|
+
import { pathSegments } from './path-segments';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Directory names no `.gitignore` can be relied on to carry, matched as a path SEGMENT at any
|
|
22
|
+
* depth. Every entry earns its line, and every one is either dotted or `node_modules` — which is
|
|
23
|
+
* what makes the any-depth match safe: a `site/` subtree is a URL tree, and neither a dot-directory
|
|
24
|
+
* nor an install is ever a route.
|
|
25
|
+
*
|
|
26
|
+
* - `.git` — git never names its own directory in an ignore file, and `git status`, `git fetch` and
|
|
27
|
+
* every commit rewrite index and ref files continuously. The reason this list exists.
|
|
28
|
+
* - `.x` — the framework's own state: PGlite's data directory (which THIS process writes
|
|
29
|
+
* continuously), the dev lock, the static export, `build-stats.json`.
|
|
30
|
+
* - `node_modules` — an install, not an edit. `x dev` cannot reload for a dependency change: the
|
|
31
|
+
* modules are already in this process's cache.
|
|
32
|
+
* - `.personal` — an app's uncommitted local state, written by scripts while the dev server runs.
|
|
33
|
+
* - `.claude` — agent scratch, session logs and worktrees. ai-maxxing keeps two entire copies of
|
|
34
|
+
* the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
|
|
35
|
+
*
|
|
36
|
+
* `dist` and `coverage` were here and are deliberately not: both are ordinary build output that
|
|
37
|
+
* every ignore file already names, and hand-listing them cost an app its own routes.
|
|
38
|
+
*/
|
|
39
|
+
export const ALWAYS_IGNORED_DIRECTORIES: readonly string[] = [
|
|
40
|
+
'.git',
|
|
41
|
+
'.x',
|
|
42
|
+
'node_modules',
|
|
43
|
+
'.personal',
|
|
44
|
+
'.claude',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** The ignore set as one question, so the walk and the event filter cannot disagree. */
|
|
48
|
+
export interface DevIgnore {
|
|
49
|
+
/** `path` is app-root-relative; `isDirectory` decides every trailing-slash rule git holds. */
|
|
50
|
+
ignores(path: string, isDirectory: boolean): boolean;
|
|
51
|
+
/** The `.gitignore` files this set was built from, outermost first — what `--json` can report. */
|
|
52
|
+
readonly scopes: readonly IgnoreScope[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read once, at boot and again on every write that names `.gitignore`. A snapshot rather than a
|
|
57
|
+
* live reader: the watcher rebuilds the whole set and re-walks, so a directory the author just
|
|
58
|
+
* ignored drops its descriptor instead of keeping one nothing will ever read an event from.
|
|
59
|
+
*
|
|
60
|
+
* Two limits, both deliberate. An ANCESTOR ignore file is read at boot and is not watched — it sits
|
|
61
|
+
* outside the app root, so `x dev` has no descriptor there and a restart is the way to pick up an
|
|
62
|
+
* edit to it. A NESTED one (`apps/web/.gitignore`) is not read at all: `ignoreScopes` walks upward
|
|
63
|
+
* only, and watching for one would mean a scope per directory in the tree.
|
|
64
|
+
*/
|
|
65
|
+
export function devIgnore(root: string): DevIgnore {
|
|
66
|
+
const scopes = ignoreScopes(root);
|
|
67
|
+
return {
|
|
68
|
+
scopes,
|
|
69
|
+
ignores(path: string, isDirectory: boolean): boolean {
|
|
70
|
+
const segments = pathSegments(path);
|
|
71
|
+
if (segments.some((segment) => ALWAYS_IGNORED_DIRECTORIES.includes(segment))) return true;
|
|
72
|
+
return isGitIgnored(scopes, join(root, ...segments), isDirectory);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// `x doctor`'s offline-fallback check: the path the app DECLARED, against the routes it really
|
|
2
|
+
// serves. Its own file because answering it needs the app's config and its route table, and
|
|
3
|
+
// `cmd-doctor.ts`'s job is the probe rather than the loading — the `db-backfill.ts` split, one
|
|
4
|
+
// diagnostic over.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
8
|
+
import { loadApp } from './app-load';
|
|
9
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
12
|
+
|
|
13
|
+
/** A route as this check reads one: the URL it answers, and the surface that answers it. */
|
|
14
|
+
export interface NavigableRoute {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly surface: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OfflineFallbackFact {
|
|
20
|
+
/**
|
|
21
|
+
* `pwa.offline.fallback` as `loadPwaArtifacts` read it, or `null` — which is both "this app
|
|
22
|
+
* declares no PWA" and "it declares one with no fallback". Either way no service worker is
|
|
23
|
+
* emitted at all (`serviceWorkerArtifacts` answers `undefined`), so the two share one remedy.
|
|
24
|
+
*/
|
|
25
|
+
readonly fallback: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Every registered route, or `undefined` when the app would not load. `appEntities`' rule
|
|
28
|
+
* (`schema-drift.ts`) one registry over: a module that will not import leaves the registry
|
|
29
|
+
* short, and a short registry reads as "no route serves it" — a generator handed out for a route
|
|
30
|
+
* the app already has, over one file's syntax error.
|
|
31
|
+
*/
|
|
32
|
+
readonly routes: readonly NavigableRoute[] | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The one surface an OFFLINE navigation can land on. `api/` answers a JSON document and `shared/`
|
|
37
|
+
* is not a URL at all — the pair `sw-artifacts.ts` excludes for the same reason — and `app/` is
|
|
38
|
+
* excluded for a third: `SURFACE_SPECS` allows it `stream | ssr` and nothing else, only a `static`
|
|
39
|
+
* route is prerendered, and the service worker precaches a rendered DOCUMENT
|
|
40
|
+
* (`serviceWorkerArtifacts` reads `documents.get(fallback)`). So an `app/` fallback has nothing
|
|
41
|
+
* to precache and the offline navigation it is supposed to answer reaches the network and fails —
|
|
42
|
+
* this check passing for it is the false green it exists to prevent.
|
|
43
|
+
*
|
|
44
|
+
* It accepted `app` until 2026-09, on the argument that both surfaces answer the same URL and it
|
|
45
|
+
* is the FIX that is opinionated. True about URLs and wrong about offline: the fix's own comment
|
|
46
|
+
* below already refuses `--surface app`, so the check and the remedy disagreed about one code.
|
|
47
|
+
*
|
|
48
|
+
* Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
|
|
49
|
+
* either. `NavigableRoute` carries no render mode, and `describeRoutes()` has one — the narrower
|
|
50
|
+
* check belongs with it.
|
|
51
|
+
*/
|
|
52
|
+
const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A fallback `x g route <name> --surface site` can actually create: ONE path segment, which is
|
|
56
|
+
* what the generator turns into `apps/<app>/site/<name>/page.tsx` and therefore into `/<name>`. A
|
|
57
|
+
* nested or punctuated path would be slugified into a DIFFERENT url (`/support/offline` →
|
|
58
|
+
* `/support-offline`), so offering the command there is a fix that runs and leaves the finding
|
|
59
|
+
* exactly where it was.
|
|
60
|
+
*
|
|
61
|
+
* `--surface site`, and never `app`: the document that answers a lost network must render with no
|
|
62
|
+
* network, no session and no database, which `app/` (`ssr | stream`) cannot promise — the reason
|
|
63
|
+
* `x new` scaffolds it under `site/` (`wiki/Upgrading.md`) — and it is the line `@ultimat3/pwa`'s
|
|
64
|
+
* own `X_PWA_NO_OFFLINE_FALLBACK` hands out. Two fixes for one code are two answers.
|
|
65
|
+
*/
|
|
66
|
+
const GENERATABLE = /^\/([a-z][a-z0-9-]*)$/;
|
|
67
|
+
|
|
68
|
+
const finding = (cause: string, fix: string): Finding => ({
|
|
69
|
+
code: 'X_PWA_NO_OFFLINE_FALLBACK',
|
|
70
|
+
cause,
|
|
71
|
+
fix,
|
|
72
|
+
docs: ERROR_DOCS_URL,
|
|
73
|
+
at: APP_CONFIG_FILE,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The declared fallback, judged against the route table — never against a filename.
|
|
78
|
+
*
|
|
79
|
+
* It WAS a filename: the literal `apps/web/app/offline.tsx`, which `assertRouteFilename` refuses
|
|
80
|
+
* outright (the directory is the URL, so a page is `page.tsx`), while `x new` scaffolds
|
|
81
|
+
* `apps/web/site/offline/page.tsx` and this finding's own `fix:` writes
|
|
82
|
+
* `apps/web/app/offline/page.tsx`. Every one of the three is a different path, so the check was
|
|
83
|
+
* red for every app the framework has ever produced and no invocation could clear it — the shape
|
|
84
|
+
* `budgets.ts` calls a false green read backwards, and the reason a diagnostic is held to being
|
|
85
|
+
* closable by its own fix.
|
|
86
|
+
*/
|
|
87
|
+
export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | undefined {
|
|
88
|
+
if (fact.routes === undefined) return undefined;
|
|
89
|
+
const fallback = fact.fallback;
|
|
90
|
+
if (fallback === null) {
|
|
91
|
+
return finding(
|
|
92
|
+
'no pwa.offline.fallback is declared, so no service worker is emitted and an offline navigation falls back to the browser error page',
|
|
93
|
+
`set pwa: { offline: { fallback: '/offline' } } in ${APP_CONFIG_FILE}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (fact.routes.some((route) => route.path === fallback && NAVIGABLE.has(route.surface))) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const cause = `pwa.offline.fallback is "${fallback}" and no site/ route serves it, so an offline navigation falls back to the browser error page`;
|
|
100
|
+
const name = GENERATABLE.exec(fallback)?.[1];
|
|
101
|
+
return name === undefined
|
|
102
|
+
? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
|
|
103
|
+
: finding(cause, `x g route ${name} --surface site`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The fact, read off a real app root. Both halves come from the framework's own answers — the
|
|
108
|
+
* config through `loadPwaArtifacts` (the one reader of that file) and the routes through
|
|
109
|
+
* `describeRoutes()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
|
|
110
|
+
* built from), so this check and the service worker cannot disagree about which routes exist.
|
|
111
|
+
*/
|
|
112
|
+
export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
|
|
113
|
+
const pwa = await loadPwaArtifacts(root);
|
|
114
|
+
const app = await loadApp(root);
|
|
115
|
+
return {
|
|
116
|
+
fallback: pwa?.offline.fallback ?? null,
|
|
117
|
+
routes:
|
|
118
|
+
app.findings.length > 0
|
|
119
|
+
? undefined
|
|
120
|
+
: describeRoutes().map((route) => ({ path: route.path, surface: route.surface })),
|
|
121
|
+
};
|
|
122
|
+
}
|