@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.27

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.
Files changed (34) hide show
  1. package/DEV_RUNTIME_KNOBS.md +9 -6
  2. package/akanApp/akanApp.host.ts +79 -22
  3. package/akanApp/devHostPolicy.test.ts +56 -11
  4. package/akanApp/devHostPolicy.ts +10 -10
  5. package/csrE2e/csrE2eHarness.fixture.ts +55 -5
  6. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  7. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  8. package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
  9. package/frontendBuild/clientEntryDiscovery.ts +112 -70
  10. package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
  11. package/frontendBuild/csrDevArtifactWriter.ts +4 -0
  12. package/frontendBuild/csrDevBundler.ts +1 -1
  13. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  14. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +95 -20
  17. package/frontendBuild/csrDevPatcher.test.ts +234 -2
  18. package/frontendBuild/csrDevPatcher.ts +184 -10
  19. package/frontendBuild/csrDevPaths.ts +32 -11
  20. package/frontendBuild/csrDevResolver.test.ts +76 -2
  21. package/frontendBuild/csrDevResolver.ts +141 -12
  22. package/frontendBuild/csrDevTypes.ts +2 -0
  23. package/frontendBuild/devRegistryBundler.ts +18 -3
  24. package/frontendBuild/ssrDevBundler.ts +2 -8
  25. package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
  26. package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
  29. package/integration/devStability.integration.test.ts +9 -2
  30. package/mobile/nativeApp.test.ts +15 -0
  31. package/mobile/nativeApp.ts +54 -14
  32. package/mobile/nativeAppLine.test.ts +62 -0
  33. package/mobile/nativeAppLine.ts +63 -0
  34. package/package.json +2 -2
@@ -15,6 +15,12 @@ interface PrepassMetafile {
15
15
  inputs: Record<string, { imports: MetafileImport[] }>;
16
16
  }
17
17
 
18
+ interface BrowserMap {
19
+ /** What the `browser` object maps to `false`: an empty module in a browser build. */
20
+ offFiles: Set<string>;
21
+ offNames: Set<string>;
22
+ }
23
+
18
24
  export interface CsrDevResolverOptions {
19
25
  paths: CsrDevPaths;
20
26
  context: CsrDevContext;
@@ -22,6 +28,8 @@ export interface CsrDevResolverOptions {
22
28
  resolution?: Record<string, Record<string, string>>;
23
29
  /** Specifiers per importer id whose recorded resolution came from Bun's runtime resolver, not the browser build. */
24
30
  runtimeResolved?: Record<string, string[]>;
31
+ /** When this process began: a package installed after it is one Bun's resolver here has not seen. */
32
+ startedAt?: number;
25
33
  }
26
34
 
27
35
  //* Resolution comes from a plain browser build: Bun's runtime resolver ignores the `browser` condition and picks Node
@@ -34,10 +42,22 @@ export class CsrDevResolver {
34
42
  readonly #resolution = new Map<string, Map<string, string>>();
35
43
  readonly #fallbacks = new Set<string>();
36
44
  readonly #runtimeResolved = new Map<string, Set<string>>();
45
+ readonly #browserMaps = new Map<string, BrowserMap | null>();
46
+ /** Files a browser build loaded, so every import they make has its browser resolution recorded. */
47
+ readonly #loaded = new Set<string>();
48
+ readonly #startedAt: number;
37
49
  #prepassDone = false;
38
50
 
39
- constructor({ paths, context, entryFiles, resolution = {}, runtimeResolved = {} }: CsrDevResolverOptions) {
51
+ constructor({
52
+ paths,
53
+ context,
54
+ entryFiles,
55
+ resolution = {},
56
+ runtimeResolved = {},
57
+ startedAt = Date.now() - process.uptime() * 1000,
58
+ }: CsrDevResolverOptions) {
40
59
  this.#paths = paths;
60
+ this.#startedAt = startedAt;
41
61
  this.#context = context;
42
62
  this.#entryFiles = entryFiles;
43
63
  for (const [importer, specifiers] of Object.entries(runtimeResolved))
@@ -73,9 +93,52 @@ export class CsrDevResolver {
73
93
  optimizeImports: this.#context.optimizeImports,
74
94
  plugins: [PagesBundleBuilder.createCssStubPlugin()],
75
95
  });
76
- const { inputs } = result.metafile as unknown as PrepassMetafile;
96
+ this.#record((result.metafile as unknown as PrepassMetafile).inputs);
97
+ this.#prepassDone = true;
98
+ }
99
+
100
+ //? The browser build drops a side-effect-free barrel's unused re-exports without loading them, yet the registry
101
+ //? compiles every file an import names. Such a file's own imports are resolved by a browser build of its own, one
102
+ //? level deep, not by the runtime resolver's Node conditions (vfile's `#minpath` would reach `node:path`).
103
+ async cover(files: string[]): Promise<void> {
104
+ if (!this.#prepassDone) return;
105
+ const pending = files.filter((file) => CsrDevPaths.isScript(file) && !this.#loaded.has(file));
106
+ if (pending.length === 0) return;
107
+ for (const file of pending) this.#loaded.add(file);
108
+ const entries = new Set(pending);
109
+ const result = await Bun.build({
110
+ entrypoints: pending,
111
+ target: "browser",
112
+ metafile: true,
113
+ throw: false,
114
+ external: [...(this.#context.externals ?? [])],
115
+ env: "AKAN_PUBLIC_*",
116
+ define: this.#context.define,
117
+ plugins: [
118
+ {
119
+ name: "akan-csr-cover",
120
+ //? What an entry imports is resolved, never parsed: a CommonJS stub, since an empty ESM module fails a named
121
+ //? import ("No matching export") and a failed build hands back no metafile at all.
122
+ setup: (build) => {
123
+ build.onLoad({ filter: /.*/ }, (args) =>
124
+ entries.has(CsrDevPaths.realpath(args.path))
125
+ ? undefined
126
+ : { contents: "module.exports = {};", loader: "js" },
127
+ );
128
+ },
129
+ },
130
+ ],
131
+ });
132
+ //? Left unrecorded when the build fails: those imports keep the runtime resolver's answer, as before.
133
+ if (result.metafile) this.#record((result.metafile as unknown as PrepassMetafile).inputs, entries);
134
+ }
135
+
136
+ #record(inputs: PrepassMetafile["inputs"], only?: Set<string>): void {
77
137
  for (const [input, { imports }] of Object.entries(inputs)) {
78
138
  const importer = CsrDevPaths.realpath(path.resolve(input));
139
+ if (only && !only.has(importer)) continue;
140
+ //? A re-export the build tree-shook keeps no specifier, so its file is resolved again as an entry of its own.
141
+ if (imports.every((record) => record.original)) this.#loaded.add(importer);
79
142
  for (const record of imports) {
80
143
  if (!record.original) continue;
81
144
  if (record.external) {
@@ -89,13 +152,14 @@ export class CsrDevResolver {
89
152
  this.#runtimeResolved.get(importer)?.delete(record.original);
90
153
  }
91
154
  }
92
- this.#prepassDone = true;
93
155
  }
94
156
 
95
157
  resolve(importer: string, specifier: string, kind: "import" | "require" = "import"): string | null {
96
158
  if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
159
+ const disabled = this.#disabledInBrowser(importer, specifier, kind);
160
+ if (disabled) return disabled;
97
161
  const known = this.#resolution.get(importer)?.get(specifier);
98
- const usable = known && this.#stillAnswers(importer, specifier, known) ? known : null;
162
+ const usable = known && this.#stillAnswers(importer, specifier, known, kind) ? known : null;
99
163
  const target = usable ?? this.#resolveUnknown(importer, specifier, kind);
100
164
  if (target === null || CsrDevPaths.isStub(target)) return target;
101
165
  if (!CsrDevPaths.isScript(target)) return CsrDevResolver.inline;
@@ -103,14 +167,18 @@ export class CsrDevResolver {
103
167
  return target;
104
168
  }
105
169
 
106
- //? A record stands until its file is gone, or until a file created beside the folder it went into takes the import
107
- //? over (`Foo.tsx` next to `Foo/index.tsx`), as it does for Bun and every other build. Nothing else overturns it: the
108
- //? prepass chose between sibling files and package fields as Bun does, which a disk lookup would only approximate.
109
- #stillAnswers(importer: string, specifier: string, known: string): boolean {
170
+ //? A relative record in app code stands while the disk still resolves it to the same file, as Bun would: a sibling
171
+ //? with a stronger extension (`x.tsx` beside `x.ts`), a file beside the folder it went into, or a record a CommonJS
172
+ //? re-export folded onto another file (`./a` → `x.js`) resolves anew. A package's files, and app code under a
173
+ //? package.json that maps files with a `browser` object, keep the prepass's answer, which a disk lookup would only
174
+ //? approximate.
175
+ #stillAnswers(importer: string, specifier: string, known: string, kind: "import" | "require"): boolean {
110
176
  if (CsrDevPaths.isStub(known)) return true;
111
177
  if (!fs.existsSync(known)) return false;
112
178
  if (!specifier.startsWith(".") || CsrDevPaths.isVendorFile(importer)) return true;
113
- return !CsrDevPaths.isShadowed(path.resolve(path.dirname(importer), specifier), known);
179
+ if (this.#hasBrowserMap(path.dirname(importer))) return true;
180
+ const onDisk = CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind);
181
+ return onDisk !== null && CsrDevPaths.realpath(onDisk) === known;
114
182
  }
115
183
 
116
184
  isRegistryModule(file: string): boolean {
@@ -123,6 +191,59 @@ export class CsrDevResolver {
123
191
  });
124
192
  }
125
193
 
194
+ //? `false` in a package's `browser` object is an empty module to a browser build (object-inspect's
195
+ //? `./util.inspect.js`, which requires `util`). Its record carries no specifier, so the field is read here.
196
+ #disabledInBrowser(importer: string, specifier: string, kind: "import" | "require"): string | null {
197
+ const map = this.#browserMapOf(path.dirname(importer));
198
+ if (!map || (map.offFiles.size === 0 && map.offNames.size === 0)) return null;
199
+ if (!specifier.startsWith(".") && !path.isAbsolute(specifier))
200
+ return map.offNames.has(specifier) ? `${CsrDevPaths.emptyStubPrefix}${specifier}` : null;
201
+ const target = CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind);
202
+ return target && map.offFiles.has(CsrDevPaths.realpath(target))
203
+ ? `${CsrDevPaths.emptyStubPrefix}${this.#paths.idOf(target)}`
204
+ : null;
205
+ }
206
+
207
+ #hasBrowserMap(dir: string): boolean {
208
+ return this.#browserMapOf(dir) !== null;
209
+ }
210
+
211
+ #browserMapOf(dir: string): BrowserMap | null {
212
+ const cached = this.#browserMaps.get(dir);
213
+ if (cached !== undefined) return cached;
214
+ const pkgFile = path.join(dir, "package.json");
215
+ const parent = path.dirname(dir);
216
+ let map: BrowserMap | null = null;
217
+ if (fs.existsSync(pkgFile)) {
218
+ try {
219
+ const browser = (JSON.parse(fs.readFileSync(pkgFile, "utf8")) as { browser?: unknown }).browser;
220
+ if (typeof browser === "object" && browser !== null) {
221
+ const off = Object.entries(browser)
222
+ .filter(([, target]) => target === false)
223
+ .map(([key]) => key);
224
+ const files = off
225
+ .filter((key) => key.startsWith("."))
226
+ .map((key) => CsrDevPaths.resolveRelative(dir, key))
227
+ .filter((file): file is string => !!file);
228
+ map = {
229
+ offFiles: new Set(files.map((file) => CsrDevPaths.realpath(file))),
230
+ offNames: new Set(off.filter((key) => !key.startsWith("."))),
231
+ };
232
+ }
233
+ } catch {
234
+ // An unreadable package.json maps nothing; Bun's build fails on it anyway.
235
+ }
236
+ } else if (parent !== dir) map = this.#browserMapOf(parent);
237
+ this.#browserMaps.set(dir, map);
238
+ return map;
239
+ }
240
+
241
+ /** A module that left the graph takes its records with it. */
242
+ forget(importer: string): void {
243
+ this.#resolution.delete(importer);
244
+ this.#runtimeResolved.delete(importer);
245
+ }
246
+
126
247
  serializeRuntimeResolved(): Record<string, string[]> {
127
248
  return Object.fromEntries(
128
249
  [...this.#runtimeResolved]
@@ -154,7 +275,7 @@ export class CsrDevResolver {
154
275
  const cannotResolve = () => new Error(`[csr-dev] cannot resolve "${specifier}" from ${this.#paths.idOf(importer)}`);
155
276
  if (relative) {
156
277
  const onDisk =
157
- CsrDevPaths.resolveOnDisk(path.resolve(path.dirname(importer), specifier), kind) ??
278
+ CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind) ??
158
279
  CsrDevResolver.#onDisk(CsrDevPaths.tryResolve(specifier, path.dirname(importer)));
159
280
  if (!onDisk) throw cannotResolve();
160
281
  return CsrDevPaths.realpath(onDisk);
@@ -165,13 +286,21 @@ export class CsrDevResolver {
165
286
  if (sibling) return sibling;
166
287
  const resolved = CsrDevPaths.tryResolve(specifier, path.dirname(importer));
167
288
  const moved = resolved !== null && path.isAbsolute(resolved) && !fs.existsSync(resolved);
289
+ //? Bun keeps a process's node_modules listings: a package installed while the builder runs (`bun add`) resolves only
290
+ //? in a fresh process, so one installed since this process began goes to a worker. Any other miss, a subpath typo
291
+ //? of a package installed long ago included, fails here as the worker would.
292
+ const installed =
293
+ resolved === null && CsrDevPaths.installedSince(specifier, path.dirname(importer), this.#startedAt);
168
294
  if (!this.#prepassDone || moved) {
169
295
  //? A specifier no resolver finds in the user's own code is a typo, failed here like a missing relative file: the
170
296
  //? resolution build a worker would run for it fails the same way. A package's `browser` field may map one away.
171
- if (resolved === null && !CsrDevPaths.isVendorFile(importer)) throw cannotResolve();
297
+ if (resolved === null && !installed && !CsrDevPaths.isVendorFile(importer)) throw cannotResolve();
172
298
  return null;
173
299
  }
174
- if (!resolved) throw cannotResolve();
300
+ if (!resolved) {
301
+ if (installed) return null;
302
+ throw cannotResolve();
303
+ }
175
304
  if (!path.isAbsolute(resolved)) return `${CsrDevPaths.stubPrefix}${specifier}`;
176
305
  this.#fallbacks.add(specifier);
177
306
  this.#runtimeResolved.set(importer, (this.#runtimeResolved.get(importer) ?? new Set<string>()).add(specifier));
@@ -18,6 +18,8 @@ export interface CsrDevGraph {
18
18
  /** Specifiers per importer id `resolution` holds from Bun's runtime resolver rather than the browser build. */
19
19
  runtimeResolved?: Record<string, string[]>;
20
20
  pending: string[];
21
+ /** A build worker's failed round: the ids it built, and the files it failed in or named, by hash. */
22
+ failed?: { roots: string[]; files: Record<string, string> };
21
23
  /** The stamp of the signal and dictionary sources the last whole build inlined; see `RegistryMetadataFingerprint`. */
22
24
  metadata?: string;
23
25
  }
@@ -81,12 +81,25 @@ export abstract class DevRegistryBundler {
81
81
  return update;
82
82
  }
83
83
 
84
- /** Of these roots outside the graph, the ones something still reaches (an entry, a module's import): those stay pending. */
84
+ /** Of these files, the ones the registry still reaches from its roots through imports: those it keeps building. */
85
85
  async wantedRoots(files: string[], graph: CsrDevGraph): Promise<Set<string>> {
86
- const reached = new Set([...Object.values(graph.entries), ...Object.values(graph.modules).flatMap((m) => m.deps)]);
86
+ const reached = await this.reachable(graph);
87
87
  return new Set(files.filter((file) => reached.has(this.paths.idOf(file))));
88
88
  }
89
89
 
90
+ /** The ids its roots reach through imports, the refresh runtime included. */
91
+ async reachable(graph: CsrDevGraph): Promise<Set<string>> {
92
+ const roots = await this.rootFiles(Object.values(graph.entries).map((id) => this.paths.fileOf(id)));
93
+ const reached = new Set<string>();
94
+ const stack = [graph.refresh, ...roots.map((file) => this.paths.idOf(file))];
95
+ for (let id = stack.pop(); id !== undefined; id = stack.pop()) {
96
+ if (reached.has(id)) continue;
97
+ reached.add(id);
98
+ stack.push(...(graph.modules[id]?.deps ?? []));
99
+ }
100
+ return reached;
101
+ }
102
+
90
103
  async holds(files: string[]): Promise<boolean> {
91
104
  const graph = await this.writer.readJson<CsrDevGraph>("graph.json");
92
105
  return graph !== null && files.every((file) => graph.modules[this.paths.idOf(file)] !== undefined);
@@ -166,7 +179,9 @@ export abstract class DevRegistryBundler {
166
179
  deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.paths.idOf(dep))),
167
180
  ...(module.helpers ? { helpers: module.helpers.hash } : {}),
168
181
  };
169
- graph.pending = [];
182
+ //? Only what this round compiled leaves pending: a root another route's check failed on stays for its retry.
183
+ const compiledIds = new Set(compiled.map((module) => module.id));
184
+ graph.pending = graph.pending.filter((id) => !compiledIds.has(id));
170
185
  graph.resolution = resolver.serialize();
171
186
  graph.runtimeResolved = resolver.serializeRuntimeResolved();
172
187
  }
@@ -8,7 +8,7 @@ import { bundleDefine } from "./bundleDefine";
8
8
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
9
9
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
10
10
  import { CsrDevPaths } from "./csrDevPaths";
11
- import type { CsrDevContext, CsrDevGraph } from "./csrDevTypes";
11
+ import type { CsrDevContext } from "./csrDevTypes";
12
12
  import { DevRegistryBundler } from "./devRegistryBundler";
13
13
  import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
14
14
 
@@ -16,7 +16,7 @@ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
16
16
  //* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
17
17
  //* so the page keeps one React and one store registry.
18
18
  export class SsrDevBundler extends DevRegistryBundler {
19
- static readonly #formatVersion = 1;
19
+ static readonly #formatVersion = 2;
20
20
  static readonly bootstrapKey = "";
21
21
  readonly reloadsOnEntryChange = false;
22
22
  #discovery: ClientEntryDiscovery | null = null;
@@ -79,12 +79,6 @@ export class SsrDevBundler extends DevRegistryBundler {
79
79
  return this.#discovery;
80
80
  }
81
81
 
82
- override async wantedRoots(files: string[], graph: CsrDevGraph): Promise<Set<string>> {
83
- const entries = new Set(await this.clientEntries());
84
- const reached = await super.wantedRoots(files, graph);
85
- return new Set(files.filter((file) => reached.has(file) || entries.has(CsrDevPaths.realpath(file))));
86
- }
87
-
88
82
  protected async rootFiles(entryFiles: string[]): Promise<string[]> {
89
83
  return [...entryFiles, ...(await this.clientEntries())];
90
84
  }
@@ -1,4 +1,8 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
2
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
3
7
  import { IncrementalBuilderHost } from "./incrementalBuilder.host";
4
8
 
@@ -172,6 +176,36 @@ describe("IncrementalBuilderHost", () => {
172
176
  host.stop();
173
177
  expect(host.recycle("after stop")).toBe(false);
174
178
  });
179
+
180
+ test("a patcher a crash turned off stays off in the host that replaces this one", async () => {
181
+ const cwdPath = await mkdtemp(path.join(os.tmpdir(), "akan-builder-patcher-"));
182
+ try {
183
+ const spawns = mockSpawns();
184
+ const app = { cwdPath } as never;
185
+ const host = new IncrementalBuilderHost({ app, entry: "/tmp/builder.ts", env: {}, onMessage: () => undefined });
186
+ host.start();
187
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
188
+ const marker = path.join(cwdPath, ".akan/artifact", SSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER);
189
+ await Bun.write(marker, "");
190
+ spawns[0]?.options.onExit?.();
191
+ await wait(1_050);
192
+ expect(spawns[1]?.options.env?.AKAN_DEV_CSR_PATCHER).toBe("off");
193
+ expect(host.patcherOff).toBe(true);
194
+ host.stop();
195
+ const next = new IncrementalBuilderHost({
196
+ app,
197
+ entry: "/tmp/builder.ts",
198
+ env: {},
199
+ onMessage: () => undefined,
200
+ patcherOff: host.patcherOff,
201
+ });
202
+ next.start();
203
+ expect(spawns[2]?.options.env?.AKAN_DEV_CSR_PATCHER).toBe("off");
204
+ next.stop();
205
+ } finally {
206
+ await rm(cwdPath, { recursive: true, force: true });
207
+ }
208
+ });
175
209
  });
176
210
 
177
211
  describe("IncrementalBuilderHost.maxRssBytes", () => {
@@ -30,6 +30,8 @@ interface IncrementalBuilderHostOptions {
30
30
  onMessage: (message: BuilderMessage) => void;
31
31
  /** Required with `stdio: "pipe"` (an undrained pipe blocks the builder); receives decoded chunks, not lines. */
32
32
  onOutput?: (kind: "stdout" | "stderr", text: string) => void;
33
+ /** Carried over from the host this one replaces: a patcher a crash turned off stays off. */
34
+ patcherOff?: boolean;
33
35
  }
34
36
 
35
37
  /** `recycling`: the builder finishes accepted work but refuses new requests; hold them for the replacement. */
@@ -73,17 +75,29 @@ export class IncrementalBuilderHost {
73
75
  // its own exit, so an unanswered page request would spin forever.
74
76
  readonly #inFlight = new Map<number, "build-route" | "build-csr">();
75
77
  #startOptions: IncrementalBuilderStartOptions = {};
76
- constructor({ app, entry, env, stdio = "inherit", onMessage, onOutput }: IncrementalBuilderHostOptions) {
78
+ constructor({
79
+ app,
80
+ entry,
81
+ env,
82
+ stdio = "inherit",
83
+ onMessage,
84
+ onOutput,
85
+ patcherOff = false,
86
+ }: IncrementalBuilderHostOptions) {
77
87
  this.app = app;
78
88
  this.entry = entry;
79
89
  this.env = env;
80
90
  this.#stdio = stdio;
81
91
  this.#onMessage = onMessage;
82
92
  this.#onOutput = onOutput ?? null;
93
+ this.#patcherOff = patcherOff;
83
94
  }
84
95
  get status() {
85
96
  return this.#status;
86
97
  }
98
+ get patcherOff(): boolean {
99
+ return this.#patcherOff;
100
+ }
87
101
  /** For reading RSS between builds: the builder's own metrics sample the post-work peak, stale once arenas return. */
88
102
  get pid(): number | null {
89
103
  return this.#proc?.pid ?? null;
@@ -292,7 +306,8 @@ export class IncrementalBuilderHost {
292
306
  {
293
307
  stdio = "inherit",
294
308
  onOutput,
295
- }: { stdio?: DevStdioMode; onOutput?: (kind: "stdout" | "stderr", text: string) => void } = {},
309
+ patcherOff = false,
310
+ }: Pick<IncrementalBuilderHostOptions, "stdio" | "onOutput" | "patcherOff"> = {},
296
311
  ) {
297
312
  const candidates = [
298
313
  path.join(app.workspace.workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts"),
@@ -305,7 +320,7 @@ export class IncrementalBuilderHost {
305
320
  ];
306
321
  for (const c of candidates)
307
322
  if (await Bun.file(c).exists())
308
- return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput });
323
+ return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput, patcherOff });
309
324
  throw new Error(`[cli] frontend builder entry not found; looked in: ${candidates.join(", ")}`);
310
325
  }
311
326
  }
@@ -77,7 +77,7 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
77
77
  error: expect.stringContaining(reason),
78
78
  });
79
79
  expect(messages.find((message) => message.type === "build-status")).toMatchObject({
80
- data: { generation: 6, phase: "route", ok: false, message: expect.stringContaining(reason) },
80
+ data: { generation: 6, phase: "route", ok: false, message: expect.stringContaining(reason), scope: "page:/" },
81
81
  });
82
82
  expect(log).toContain(reason);
83
83
  }, 30_000);
@@ -148,6 +148,67 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
148
148
  }
149
149
  }, 60_000);
150
150
 
151
+ test("a route build's check whose own worker is killed is tried again after the crash window", async () => {
152
+ const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
153
+ const appDir = path.join(root, "apps/demo");
154
+ const runs = path.join(root, "ssr-runs.log");
155
+ // The boot build succeeds, the route check's worker is killed, and the retry after the window succeeds.
156
+ await writeText(
157
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
158
+ `import fs from "node:fs";
159
+ const { generation, needs } = JSON.parse(process.argv[2]);
160
+ if (needs.includes("ssr")) {
161
+ fs.appendFileSync(${JSON.stringify(runs)}, Date.now() + "\\n");
162
+ if (fs.readFileSync(${JSON.stringify(runs)}, "utf8").trim().split("\\n").length === 2) process.exit(1);
163
+ }
164
+ process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
165
+ `,
166
+ );
167
+ const page = path.join(appDir, "page/_index.tsx");
168
+ await writeText(page, 'import { Card } from "../ui/Card";\nexport default Card;\n');
169
+ await writeText(path.join(appDir, "ui/Card.tsx"), '"use client";\nexport const Card = () => null;\n');
170
+
171
+ const messages: BuilderMessage[] = [];
172
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
173
+ cwd: appDir,
174
+ env: {
175
+ ...process.env,
176
+ AKAN_WORKSPACE_ROOT: root,
177
+ AKAN_PUBLIC_APP_NAME: "demo",
178
+ AKAN_PUBLIC_REPO_NAME: "repo",
179
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
180
+ AKAN_PUBLIC_ENV: "local",
181
+ AKAN_WATCH: "0",
182
+ },
183
+ stdio: ["ignore", "ignore", "ignore"],
184
+ serialization: "advanced",
185
+ ipc: (message: BuilderMessage) => {
186
+ messages.push(message);
187
+ },
188
+ });
189
+ const ssrRuns = async () =>
190
+ (await Bun.file(runs).exists()) ? (await Bun.file(runs).text()).trim().split("\n").map(Number) : [];
191
+ try {
192
+ await until(proc, "boot-armed", () => messages.some((message) => message.type === "boot-armed"));
193
+ proc.send({
194
+ type: "build-route",
195
+ id: 1,
196
+ routeId: "page:/",
197
+ seeds: [page],
198
+ knownEntries: [],
199
+ generation: 3,
200
+ } satisfies BuilderReq);
201
+ await until(proc, "build-route-res", () => messages.some((message) => message.type === "build-route-res"));
202
+ const deadline = Date.now() + 20_000;
203
+ while ((await ssrRuns()).length < 3 && Date.now() < deadline) await wait(100);
204
+ const [, killed = 0, retried = 0] = await ssrRuns();
205
+ expect(retried - killed).toBeGreaterThanOrEqual(9_500);
206
+ } finally {
207
+ proc.kill();
208
+ await proc.exited;
209
+ }
210
+ }, 60_000);
211
+
151
212
  test("a route build's registry check that has nothing to add reports no ssr status, so it cannot hide a failure", async () => {
152
213
  const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
153
214
  const appDir = path.join(root, "apps/demo");