@ultimat3/cli 19.2.0 → 19.3.2
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 +135 -9
- package/README.md +1 -1
- package/package.json +29 -29
- package/src/app-agents-md.ts +14 -3
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +96 -25
- package/src/budgets.ts +17 -6
- package/src/cmd-dev-fixture.ts +25 -0
- package/src/cmd-dev.ts +48 -46
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +25 -3
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- 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 +35 -9
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-files.ts +24 -2
- 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/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/scaffold-fixture.ts +17 -0
- package/src/serve.ts +40 -5
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/action.ts +47 -16
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/island.ts +4 -2
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +7 -0
- package/src/templates/scaffold-entries.ts +4 -2
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/slice-foundation.ts +36 -0
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +11 -8
- package/src/verify-floor.ts +59 -3
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/src/dev-queue.ts
CHANGED
|
@@ -78,8 +78,16 @@ export interface RunningQueue {
|
|
|
78
78
|
* Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
|
|
79
79
|
* it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
|
|
80
80
|
* so `DATABASE_REPLICA_URL` was read by no booted process at all.
|
|
81
|
+
*
|
|
82
|
+
* `env` is REQUIRED, and that is the repair: it defaulted to `process.env` and `startQueue` passed
|
|
83
|
+
* nothing, so the standby was decided from the process while the middleware that opens the
|
|
84
|
+
* `withReplicaReads` scope was decided from the boot's own `options.env` (`cmd-dev.ts`,
|
|
85
|
+
* `serve.ts`). Two sources for one question answer differently the moment a boot is handed an
|
|
86
|
+
* environment it did not inherit — a routed client with no scope, or a scope with no standby, and
|
|
87
|
+
* neither reports anything. A default here is what let the caller forget; the type is what stops
|
|
88
|
+
* the next one. Exported for the test that proves which environment decides.
|
|
81
89
|
*/
|
|
82
|
-
function startDb(services: DevServices, env: ReplicaEnv
|
|
90
|
+
export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
83
91
|
const binding = services.db;
|
|
84
92
|
const client =
|
|
85
93
|
binding.mode === 'embedded'
|
|
@@ -218,8 +226,14 @@ async function releaseQueue(
|
|
|
218
226
|
export async function startQueue(
|
|
219
227
|
services: DevServices,
|
|
220
228
|
overrides?: RuntimeOverrides,
|
|
229
|
+
/**
|
|
230
|
+
* The boot's own environment — `x dev`'s, the container role's, the CLI command's `ctx.env`.
|
|
231
|
+
* `process.env` is the default for a caller that has no other answer, and it is the ONLY place
|
|
232
|
+
* this file reads it: see `startDb` for what two readers of one question cost.
|
|
233
|
+
*/
|
|
234
|
+
env: ReplicaEnv = process.env,
|
|
221
235
|
): Promise<RunningQueue> {
|
|
222
|
-
const { client: db, replica } = startDb(services);
|
|
236
|
+
const { client: db, replica } = startDb(services, env);
|
|
223
237
|
try {
|
|
224
238
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
225
239
|
// fails at boot rather than on some later query.
|
|
@@ -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
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
renderHead,
|
|
28
28
|
routeDataFor,
|
|
29
29
|
routeEntries,
|
|
30
|
+
routeFor,
|
|
31
|
+
routeStatusOf,
|
|
30
32
|
seoRenderers,
|
|
31
33
|
} from '@ultimat3/render';
|
|
32
34
|
import type { IsrController } from '@ultimat3/render/server';
|
|
@@ -212,12 +214,15 @@ async function resultFor(
|
|
|
212
214
|
// ONCE per request, before the mode is chosen. Every branch below reads this same object, so a
|
|
213
215
|
// route's `load` runs exactly once however its mode splits head from body.
|
|
214
216
|
const data = await routeDataFor(entry.config, request);
|
|
217
|
+
// The status the loader answered through `withStatus`, 200 when it said nothing. Read once,
|
|
218
|
+
// here, and handed to every mode: this file mints the `Response`, render owns the seam.
|
|
219
|
+
const status = routeStatusOf(data);
|
|
215
220
|
switch (entry.config.render) {
|
|
216
221
|
case 'static': {
|
|
217
222
|
// Not `renderStatic`: that enumerates every prerendered path for the build. A request
|
|
218
223
|
// names exactly one, and it earns the same content-hashed headers.
|
|
219
224
|
const body = await documentFrom(entry, request, data, options);
|
|
220
|
-
return { status
|
|
225
|
+
return { status, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
221
226
|
}
|
|
222
227
|
case 'isr': {
|
|
223
228
|
// `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
|
|
@@ -227,9 +232,12 @@ async function resultFor(
|
|
|
227
232
|
// The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
|
|
228
233
|
// already negotiated for THIS request — never `currentLocale()`, which would read the same
|
|
229
234
|
// value through an ambient store the key does not need.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
235
|
+
// `{ html, status }`, never the bare string: the entry stores the status beside the HTML
|
|
236
|
+
// and serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
|
|
237
|
+
const served = await isr.serve(isrKey(url, ctx.locale), async () => ({
|
|
238
|
+
html: await documentFrom(entry, request, data, options),
|
|
239
|
+
status,
|
|
240
|
+
}));
|
|
233
241
|
return served.result;
|
|
234
242
|
}
|
|
235
243
|
case 'stream': {
|
|
@@ -253,13 +261,14 @@ async function resultFor(
|
|
|
253
261
|
holes: [],
|
|
254
262
|
},
|
|
255
263
|
{ buildId: options.buildId },
|
|
264
|
+
status,
|
|
256
265
|
);
|
|
257
266
|
}
|
|
258
267
|
default:
|
|
259
268
|
return renderSsr(
|
|
260
269
|
{ entry, params: request.params, url, ctx },
|
|
261
270
|
() => documentFrom(entry, request, data, options),
|
|
262
|
-
{ buildId: options.buildId },
|
|
271
|
+
{ buildId: options.buildId, status },
|
|
263
272
|
);
|
|
264
273
|
}
|
|
265
274
|
}
|
|
@@ -281,15 +290,32 @@ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
|
|
|
281
290
|
...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
|
|
282
291
|
});
|
|
283
292
|
|
|
284
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* One HTTP route per registered `route` primitive, in the table's own order.
|
|
295
|
+
*
|
|
296
|
+
* The URL and the method are the table's at the time this is called; the ENTRY — the component the
|
|
297
|
+
* handler renders AND the `meta` the pipeline enforces — is read back from the table on every
|
|
298
|
+
* request. `x dev` re-registers a route module when its source changes (`app-load.ts`), and a
|
|
299
|
+
* handler closing over the entry it was built from kept serving the first component after every
|
|
300
|
+
* save — the table had moved and this closure had not. `meta` was the same defect one stage
|
|
301
|
+
* earlier: a snapshot taken here, so a `policy` added in a save was not enforced until a restart
|
|
302
|
+
* while the page behind it was already the new one — the pipeline's `auth` and `authz` stages read
|
|
303
|
+
* `route.meta` per request, and this getter is what makes that read the table's. The path cannot
|
|
304
|
+
* move under a reload (the table derives it from the file, and the file is the reload's key), so
|
|
305
|
+
* the entry captured here is only the fallback for a table cleared under a running server, which
|
|
306
|
+
* only a test does — and then guard and page fall back together.
|
|
307
|
+
*/
|
|
285
308
|
export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
286
309
|
const isr = options.isr ?? createIsrController({ buildId: options.buildId });
|
|
287
|
-
return routeEntries().map((
|
|
310
|
+
return routeEntries().map((registered) => ({
|
|
288
311
|
method: 'GET' as const,
|
|
289
|
-
path:
|
|
290
|
-
meta:
|
|
312
|
+
path: registered.path,
|
|
313
|
+
get meta(): HttpRouteMeta {
|
|
314
|
+
return metaOf(routeFor(registered.path) ?? registered);
|
|
315
|
+
},
|
|
291
316
|
// `ctx.params` is the router's own match — the CLI never re-parses a path it did not match.
|
|
292
317
|
handler: async (request, ctx): Promise<Response> => {
|
|
318
|
+
const entry = routeFor(registered.path) ?? registered;
|
|
293
319
|
const data: DevRouteData = { url: request.url.href, params: ctx.params };
|
|
294
320
|
return responseOf(await resultFor(entry, data, options, isr, asCtx(ctx)));
|
|
295
321
|
},
|
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,7 +15,7 @@ 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
21
|
import { DEV_BINDING } from './web-binding';
|
|
@@ -53,7 +53,11 @@ class SyncPortInUseError extends UltimateError {
|
|
|
53
53
|
super({
|
|
54
54
|
code: 'X_PORT_IN_USE',
|
|
55
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`,
|
|
56
|
-
|
|
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`,
|
|
57
61
|
meta: { port: input.port, webPort: input.webPort },
|
|
58
62
|
});
|
|
59
63
|
}
|
|
@@ -183,7 +187,11 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
183
187
|
// so the one socket that streams live database patches was the one socket on every
|
|
184
188
|
// interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
|
|
185
189
|
const binding = options.http ?? DEV_BINDING;
|
|
186
|
-
|
|
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 });
|
|
187
195
|
return {
|
|
188
196
|
url: listener.url,
|
|
189
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
CHANGED
|
@@ -1,53 +1,75 @@
|
|
|
1
|
-
// Which
|
|
2
|
-
// `
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
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';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
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.
|
|
18
25
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - `node_modules` — an install, not an edit. `x dev`
|
|
24
|
-
*
|
|
25
|
-
* - `.
|
|
26
|
-
* and ref files continuously, and none of them is a source edit; a checkout of a branch IS one,
|
|
27
|
-
* but it also writes the source files themselves, which this list does not touch.
|
|
28
|
-
* - `.personal` — an app's uncommitted local state (ai-maxxing's fleet file, its inventory, its
|
|
29
|
-
* credentials). Written by scripts while the dev server runs.
|
|
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.
|
|
30
33
|
* - `.claude` — agent scratch, session logs and worktrees. ai-maxxing keeps two entire copies of
|
|
31
34
|
* the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
34
38
|
*/
|
|
35
|
-
export const
|
|
39
|
+
export const ALWAYS_IGNORED_DIRECTORIES: readonly string[] = [
|
|
40
|
+
'.git',
|
|
36
41
|
'.x',
|
|
37
42
|
'node_modules',
|
|
38
|
-
'.git',
|
|
39
43
|
'.personal',
|
|
40
44
|
'.claude',
|
|
41
|
-
'dist',
|
|
42
|
-
'coverage',
|
|
43
45
|
];
|
|
44
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
|
+
|
|
45
55
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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.
|
|
49
64
|
*/
|
|
50
|
-
export function
|
|
51
|
-
const
|
|
52
|
-
return
|
|
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
|
+
};
|
|
53
75
|
}
|