@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.
- package/DEV_RUNTIME_KNOBS.md +9 -6
- package/akanApp/akanApp.host.ts +79 -22
- package/akanApp/devHostPolicy.test.ts +56 -11
- package/akanApp/devHostPolicy.ts +10 -10
- package/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
- package/frontendBuild/clientEntryDiscovery.ts +112 -70
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
- package/frontendBuild/csrDevModuleCompiler.ts +95 -20
- package/frontendBuild/csrDevPatcher.test.ts +234 -2
- package/frontendBuild/csrDevPatcher.ts +184 -10
- package/frontendBuild/csrDevPaths.ts +32 -11
- package/frontendBuild/csrDevResolver.test.ts +76 -2
- package/frontendBuild/csrDevResolver.ts +141 -12
- package/frontendBuild/csrDevTypes.ts +2 -0
- package/frontendBuild/devRegistryBundler.ts +18 -3
- package/frontendBuild/ssrDevBundler.ts +2 -8
- package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
- package/integration/devStability.integration.test.ts +9 -2
- package/mobile/nativeApp.test.ts +15 -0
- package/mobile/nativeApp.ts +54 -14
- package/mobile/nativeAppLine.test.ts +62 -0
- package/mobile/nativeAppLine.ts +63 -0
- 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({
|
|
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
|
-
|
|
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
|
|
107
|
-
//?
|
|
108
|
-
//?
|
|
109
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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({
|
|
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
|
-
|
|
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");
|