@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.3
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/akanApp/akanApp.host.test.ts +84 -0
- package/akanApp/akanApp.host.ts +181 -5
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +114 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +72 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +238 -161
- package/integration/devStability.integration.test.ts +98 -15
- package/integration/devStabilityHarness.ts +28 -2
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
|
@@ -7,14 +7,10 @@ import {
|
|
|
7
7
|
AutoImportSync,
|
|
8
8
|
type ChangeBatch,
|
|
9
9
|
type ClientEntryDiscovery,
|
|
10
|
-
CsrArtifactBuilder,
|
|
11
|
-
type CssCompiler,
|
|
12
10
|
DevChangePlanner,
|
|
13
11
|
DevGeneratedIndexSync,
|
|
14
|
-
FontOptimizer,
|
|
15
12
|
GraphClientEntryDiscovery,
|
|
16
13
|
HmrWatcher,
|
|
17
|
-
PagesBundleBuilder,
|
|
18
14
|
RouteClientBuilder,
|
|
19
15
|
SsrBaseArtifactBuilder,
|
|
20
16
|
WatchRootResolver,
|
|
@@ -30,49 +26,53 @@ import type {
|
|
|
30
26
|
BuildPhase,
|
|
31
27
|
BuildRouteResultPayload,
|
|
32
28
|
} from "akanjs/server";
|
|
29
|
+
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
30
|
+
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
33
31
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
34
32
|
|
|
35
33
|
interface IncrementalBuilderOptions {
|
|
36
34
|
app: App;
|
|
37
35
|
artifact: BaseBuildArtifact;
|
|
38
36
|
watch: boolean;
|
|
39
|
-
|
|
40
|
-
optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
37
|
+
optimizedFonts: OptimizedFonts;
|
|
41
38
|
discovery: ClientEntryDiscovery;
|
|
42
39
|
initialGeneration?: number;
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
type IncrementalBuilderBootDeps = Pick<
|
|
46
|
-
IncrementalBuilderOptions,
|
|
47
|
-
"artifact" | "cssCompiler" | "optimizedFonts" | "discovery"
|
|
48
|
-
>;
|
|
42
|
+
type IncrementalBuilderBootDeps = Pick<IncrementalBuilderOptions, "artifact" | "optimizedFonts" | "discovery">;
|
|
49
43
|
|
|
50
44
|
class IncrementalBuilder {
|
|
51
45
|
#logger = new Logger("IncrementalBuilder");
|
|
52
46
|
#app: App;
|
|
53
47
|
#artifact: BaseBuildArtifact;
|
|
54
48
|
#watch: boolean;
|
|
55
|
-
|
|
56
|
-
#optimizedFonts:
|
|
49
|
+
/** Kept by value, not as a live `FontOptimizer`: it has to travel to each disposable build worker. */
|
|
50
|
+
#optimizedFonts: OptimizedFonts;
|
|
57
51
|
#discovery: ClientEntryDiscovery;
|
|
52
|
+
#batchRunner: BuildBatchRunner;
|
|
58
53
|
#changePlanner: DevChangePlanner;
|
|
59
54
|
#generatedIndexSync: DevGeneratedIndexSync;
|
|
60
55
|
#autoImportSync: AutoImportSync;
|
|
61
56
|
#generation = 0;
|
|
62
57
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
63
58
|
#workQueue: Promise<void> = Promise.resolve();
|
|
59
|
+
#inFlight = 0;
|
|
60
|
+
#workCount = 0;
|
|
61
|
+
#shuttingDown = false;
|
|
64
62
|
#cssRebuildQueue: Promise<void> = Promise.resolve();
|
|
65
63
|
#cssRebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
-
#pendingCssRebuild: {
|
|
67
|
-
null;
|
|
64
|
+
#pendingCssRebuild: { generation?: number; changedFiles?: string[] } | null = null;
|
|
68
65
|
constructor(options: IncrementalBuilderOptions) {
|
|
69
66
|
this.#app = options.app;
|
|
70
67
|
this.#artifact = options.artifact;
|
|
71
68
|
this.#watch = options.watch;
|
|
72
|
-
this.#cssCompiler = options.cssCompiler;
|
|
73
69
|
this.#optimizedFonts = options.optimizedFonts;
|
|
74
70
|
this.#discovery = options.discovery;
|
|
75
71
|
this.#generation = options.initialGeneration ?? 0;
|
|
72
|
+
this.#batchRunner = new BuildBatchRunner({
|
|
73
|
+
workspaceRoot: options.app.workspace.workspaceRoot,
|
|
74
|
+
cwd: options.app.cwdPath,
|
|
75
|
+
});
|
|
76
76
|
this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
77
77
|
this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
78
78
|
this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
@@ -138,13 +138,62 @@ class IncrementalBuilder {
|
|
|
138
138
|
}
|
|
139
139
|
async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
140
140
|
const started = Date.now();
|
|
141
|
+
this.#inFlight += 1;
|
|
141
142
|
const run = this.#workQueue.then(fn, fn);
|
|
142
143
|
this.#workQueue = run.then(() => undefined).catch(() => undefined);
|
|
143
144
|
try {
|
|
144
145
|
return await run;
|
|
145
146
|
} finally {
|
|
147
|
+
this.#inFlight -= 1;
|
|
148
|
+
this.#workCount += 1;
|
|
146
149
|
this.#logger.verbose(`[work-queue] ${label} finished in ${Date.now() - started}ms`);
|
|
150
|
+
this.#reportMetrics();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** No queued work and no debounced css rebuild, so nothing is lost if the process exits now. */
|
|
155
|
+
get #idle(): boolean {
|
|
156
|
+
return this.#inFlight === 0 && this.#cssRebuildTimer === null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reported only when the builder is idle. The host's only lever against the bundler arenas
|
|
161
|
+
* `Bun.build` retains is to recycle this process, and a recycle decided while work is queued would
|
|
162
|
+
* either truncate that work or race the shutdown drain — so a busy builder simply says nothing.
|
|
163
|
+
*/
|
|
164
|
+
#reportMetrics(): void {
|
|
165
|
+
if (!this.#idle || this.#shuttingDown) return;
|
|
166
|
+
process.send?.({
|
|
167
|
+
type: "builder-metrics",
|
|
168
|
+
data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Finish queued work, then exit so the OS reclaims the bundler arenas. The host restarts a
|
|
174
|
+
* replacement; build requests that arrive during the drain are refused with the same retry error
|
|
175
|
+
* the backend already handles for any other builder restart.
|
|
176
|
+
*/
|
|
177
|
+
async shutdown(reason: string): Promise<void> {
|
|
178
|
+
if (this.#shuttingDown) return;
|
|
179
|
+
this.#shuttingDown = true;
|
|
180
|
+
const started = Date.now();
|
|
181
|
+
this.#logger.info(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
|
|
182
|
+
if (this.#cssRebuildTimer) {
|
|
183
|
+
// Only reachable if a css batch landed between the idle report and this request: the fresh
|
|
184
|
+
// boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
|
|
185
|
+
clearTimeout(this.#cssRebuildTimer);
|
|
186
|
+
this.#cssRebuildTimer = null;
|
|
187
|
+
this.#pendingCssRebuild = null;
|
|
147
188
|
}
|
|
189
|
+
await this.#workQueue.catch(() => undefined);
|
|
190
|
+
await this.#cssRebuildQueue.catch(() => undefined);
|
|
191
|
+
this.#logger.info(`drained in ${Date.now() - started}ms; exiting for recycle`);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get shuttingDown(): boolean {
|
|
196
|
+
return this.#shuttingDown;
|
|
148
197
|
}
|
|
149
198
|
batchTouchesPagesTree(appDir: string, batch: ChangeBatch): boolean {
|
|
150
199
|
const absAppDir = path.resolve(appDir);
|
|
@@ -167,75 +216,23 @@ class IncrementalBuilder {
|
|
|
167
216
|
}
|
|
168
217
|
return false;
|
|
169
218
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
{
|
|
173
|
-
) {
|
|
174
|
-
const cssStarted = Date.now();
|
|
175
|
-
const cssByBasePathStarted = Date.now();
|
|
176
|
-
const cssByBasePath = await this.#cssCompiler.getCssByBasePath({ refresh });
|
|
177
|
-
this.#logger.verbose(`css-get-by-base-path ok (${Date.now() - cssByBasePathStarted}ms)`);
|
|
178
|
-
const fontStarted = Date.now();
|
|
179
|
-
const optimizedFonts = await this.#getOptimizedFonts(changedFiles ?? []);
|
|
180
|
-
this.#logger.verbose(`font-assets ready (${Date.now() - fontStarted}ms)`);
|
|
181
|
-
const cssAssetEntries: Array<[string, { cssUrl: string; cssRelPath: string }]> = [];
|
|
182
|
-
const cssBase64ByUrl: Record<string, string> = {};
|
|
183
|
-
await Promise.all(
|
|
184
|
-
Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
|
|
185
|
-
const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
|
|
186
|
-
if (!cssText) return [];
|
|
187
|
-
return [
|
|
188
|
-
(async () => {
|
|
189
|
-
const cssAssetName = basePath || "root";
|
|
190
|
-
const cssHash = Bun.hash(`${basePath}\n${cssText}`).toString(36);
|
|
191
|
-
const cssRelPath = `styles/${cssAssetName}-${cssHash}.css`;
|
|
192
|
-
const cssUrl = `/_akan/styles/${cssAssetName}-${cssHash}.css`;
|
|
193
|
-
await Bun.write(path.join(artifactDir, cssRelPath), cssText);
|
|
194
|
-
cssAssetEntries.push([basePath, { cssUrl, cssRelPath }]);
|
|
195
|
-
cssBase64ByUrl[cssUrl] = Buffer.from(new TextEncoder().encode(cssText)).toString("base64");
|
|
196
|
-
})(),
|
|
197
|
-
];
|
|
198
|
-
}),
|
|
199
|
-
);
|
|
200
|
-
const cssAssets = Object.fromEntries(cssAssetEntries);
|
|
201
|
-
if (JSON.stringify(this.#artifact.cssAssets ?? {}) === JSON.stringify(cssAssets)) {
|
|
202
|
-
this.#logger.verbose("css-rebuild unchanged assets; broadcast skipped");
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
this.#artifact = { ...this.#artifact, cssAssets };
|
|
206
|
-
this.#logger.verbose(`css-compile ok assets=${Object.keys(cssAssets).length} (${Date.now() - cssStarted}ms)`);
|
|
207
|
-
process.send?.({
|
|
208
|
-
type: "css-updated",
|
|
209
|
-
data: {
|
|
210
|
-
cssAssets,
|
|
211
|
-
cssBase64ByUrl,
|
|
212
|
-
generation,
|
|
213
|
-
changedFiles,
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
scheduleCssRebuild(
|
|
219
|
-
artifactDir: string,
|
|
220
|
-
{ refresh, generation, changedFiles }: { refresh: boolean; generation?: number; changedFiles?: string[] },
|
|
221
|
-
) {
|
|
222
|
-
this.#pendingCssRebuild = { artifactDir, refresh, generation, changedFiles };
|
|
219
|
+
/** Debounced css-only rebuild; the compile itself runs in a disposable worker like every other build. */
|
|
220
|
+
scheduleCssRebuild({ generation, changedFiles }: { generation?: number; changedFiles?: string[] }) {
|
|
221
|
+
this.#pendingCssRebuild = { generation, changedFiles };
|
|
223
222
|
if (this.#cssRebuildTimer) clearTimeout(this.#cssRebuildTimer);
|
|
224
223
|
this.#cssRebuildTimer = setTimeout(() => {
|
|
225
224
|
this.#cssRebuildTimer = null;
|
|
226
225
|
const next = this.#pendingCssRebuild;
|
|
227
226
|
this.#pendingCssRebuild = null;
|
|
228
227
|
if (!next) return;
|
|
228
|
+
this.#inFlight += 1;
|
|
229
229
|
this.#cssRebuildQueue = this.#cssRebuildQueue
|
|
230
230
|
.then(async () => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
changedFiles: next.changedFiles,
|
|
231
|
+
await this.#runBatch({
|
|
232
|
+
generation: next.generation ?? this.#generation,
|
|
233
|
+
needs: ["css"],
|
|
234
|
+
changedFiles: next.changedFiles ?? [],
|
|
236
235
|
});
|
|
237
|
-
this.#sendBuildStatus("css", { generation: next.generation, ok: true, files: next.changedFiles });
|
|
238
|
-
this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
239
236
|
})
|
|
240
237
|
.catch((err) => {
|
|
241
238
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -246,29 +243,13 @@ class IncrementalBuilder {
|
|
|
246
243
|
files: next.changedFiles,
|
|
247
244
|
message,
|
|
248
245
|
});
|
|
246
|
+
})
|
|
247
|
+
.finally(() => {
|
|
248
|
+
this.#inFlight -= 1;
|
|
249
|
+
this.#reportMetrics();
|
|
249
250
|
});
|
|
250
251
|
}, 150);
|
|
251
252
|
}
|
|
252
|
-
|
|
253
|
-
async #getOptimizedFonts(changedFiles: string[]) {
|
|
254
|
-
if (!this.#shouldReoptimizeFonts(changedFiles)) {
|
|
255
|
-
this.#logger.verbose(`font-optimize cached files=${this.#optimizedFonts.files.length}`);
|
|
256
|
-
return this.#optimizedFonts;
|
|
257
|
-
}
|
|
258
|
-
const started = Date.now();
|
|
259
|
-
this.#optimizedFonts = await new FontOptimizer(this.#app, "start").optimize();
|
|
260
|
-
this.#logger.verbose(`font-optimize ok files=${this.#optimizedFonts.files.length} (${Date.now() - started}ms)`);
|
|
261
|
-
return this.#optimizedFonts;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
#shouldReoptimizeFonts(changedFiles: string[]) {
|
|
265
|
-
if (changedFiles.length === 0) return false;
|
|
266
|
-
return changedFiles.some((file) => {
|
|
267
|
-
const normalized = path.resolve(file);
|
|
268
|
-
if (/\.(woff2?|ttf|otf)$/i.test(normalized)) return true;
|
|
269
|
-
return this.#optimizedFonts.files.some((fontFile) => path.resolve(fontFile) === normalized);
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
253
|
async installWatcher() {
|
|
273
254
|
const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, this.#artifactDir];
|
|
274
255
|
const roots = await new WatchRootResolver(this.#app).resolve();
|
|
@@ -338,49 +319,86 @@ class IncrementalBuilder {
|
|
|
338
319
|
this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
|
|
339
320
|
}
|
|
340
321
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
this.#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
);
|
|
322
|
+
const needs: BuildBatchNeed[] = [];
|
|
323
|
+
if (kinds.includes("code") && rebuildClient) {
|
|
324
|
+
if (this.#shouldRebuildCsr()) needs.push("csr");
|
|
325
|
+
else
|
|
326
|
+
this.#logger.verbose(
|
|
327
|
+
`csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
|
|
328
|
+
);
|
|
329
|
+
needs.push("pages");
|
|
330
|
+
// Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
|
|
331
|
+
// client rebuild or a direct stylesheet edit can change the compiled CSS. Folded into this
|
|
332
|
+
// generation's batch rather than debounced separately: the work queue already serializes
|
|
333
|
+
// generations, so by the time a second batch runs the 150ms debounce would have fired anyway,
|
|
334
|
+
// and a second worker spawn per save costs more than the coalescing ever saved.
|
|
335
|
+
needs.push("css");
|
|
356
336
|
}
|
|
357
337
|
|
|
358
338
|
process.send?.(event);
|
|
359
339
|
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
type: "pages-updated",
|
|
366
|
-
data: { bundlePath: next.bundlePath, buildId: next.buildId, generation, changedFiles: files },
|
|
367
|
-
});
|
|
368
|
-
this.#sendBuildStatus("pages", { generation, ok: true, files });
|
|
369
|
-
this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
|
|
370
|
-
} catch (err) {
|
|
371
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
372
|
-
this.#logger.error(`pages-rebundle failed: ${message}`);
|
|
373
|
-
this.#sendBuildStatus("pages", { generation, ok: false, files, message });
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
// Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
|
|
377
|
-
// client rebuild or a direct stylesheet edit can change the compiled CSS.
|
|
378
|
-
if (kinds.includes("css") || (kinds.includes("code") && rebuildClient)) {
|
|
379
|
-
this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
|
|
340
|
+
if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
|
|
341
|
+
// A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
|
|
342
|
+
// without a pages build in front of them there is nothing else to space them out.
|
|
343
|
+
else if (kinds.includes("css")) {
|
|
344
|
+
this.scheduleCssRebuild({ generation, changedFiles: files });
|
|
380
345
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
381
346
|
}
|
|
382
347
|
}
|
|
383
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Run one generation of build work in a process that exits afterwards. The worker streams the
|
|
351
|
+
* messages the backend and the HMR overlay consume, which this relays untouched, so the sequence a
|
|
352
|
+
* browser observes is the same one the in-process build produced.
|
|
353
|
+
*/
|
|
354
|
+
async #runBatch({
|
|
355
|
+
generation,
|
|
356
|
+
needs,
|
|
357
|
+
changedFiles,
|
|
358
|
+
}: {
|
|
359
|
+
generation: number;
|
|
360
|
+
needs: BuildBatchNeed[];
|
|
361
|
+
changedFiles: string[];
|
|
362
|
+
}): Promise<BuildBatchResult> {
|
|
363
|
+
const started = Date.now();
|
|
364
|
+
const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
|
|
365
|
+
process.send?.(msg),
|
|
366
|
+
);
|
|
367
|
+
if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
|
|
368
|
+
if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
|
|
369
|
+
// A worker that died before reporting streamed no build-status of its own, so report one per need
|
|
370
|
+
// it was given: the generation must go red rather than look like it silently succeeded.
|
|
371
|
+
if (result.crashed) {
|
|
372
|
+
for (const need of needs)
|
|
373
|
+
this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
|
|
374
|
+
}
|
|
375
|
+
if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async #batchRequest({
|
|
380
|
+
generation,
|
|
381
|
+
needs,
|
|
382
|
+
changedFiles,
|
|
383
|
+
}: {
|
|
384
|
+
generation: number;
|
|
385
|
+
needs: BuildBatchNeed[];
|
|
386
|
+
changedFiles: string[];
|
|
387
|
+
}): Promise<BuildBatchRequest> {
|
|
388
|
+
return {
|
|
389
|
+
appName: this.#app.name,
|
|
390
|
+
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
391
|
+
repoName: this.#app.workspace.repoName,
|
|
392
|
+
generation,
|
|
393
|
+
needs,
|
|
394
|
+
changedFiles,
|
|
395
|
+
pageKeys: await this.#app.getPageKeys(),
|
|
396
|
+
optimizedFonts: this.#optimizedFonts,
|
|
397
|
+
cssAssets: this.#artifact.cssAssets ?? null,
|
|
398
|
+
artifactDir: path.resolve(this.#artifactDir),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
384
402
|
async boot(): Promise<void> {
|
|
385
403
|
if (this.#watch) await this.installWatcher();
|
|
386
404
|
process.send?.({ type: "builder-ready" });
|
|
@@ -394,20 +412,47 @@ class IncrementalBuilder {
|
|
|
394
412
|
async announceRecoveredState(changedFiles: string[]): Promise<void> {
|
|
395
413
|
const generation = ++this.#generation;
|
|
396
414
|
await this.#enqueueWork("boot-recovered", async () => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
415
|
+
await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Re-announce the state this builder just booted with, after the host recycled the previous one.
|
|
421
|
+
*
|
|
422
|
+
* The backend reads `base-artifact.json` once at boot and never re-reads it, so a pages or css hash
|
|
423
|
+
* that moved while the builder was being replaced would otherwise leave it pointing at the previous
|
|
424
|
+
* artifact until the next save. Announced from the boot artifact rather than by rebuilding: the
|
|
425
|
+
* bundles are already on disk, and spending another ~200MB of bundler arena in a process that was
|
|
426
|
+
* just recycled to reclaim memory would defeat the purpose. The host suppresses the announcement
|
|
427
|
+
* when the hashes match, which is the common case, so a clean recycle never reloads a browser.
|
|
428
|
+
*/
|
|
429
|
+
async announceBootState(): Promise<void> {
|
|
430
|
+
const generation = ++this.#generation;
|
|
431
|
+
const reason = "builder-recycle" as const;
|
|
432
|
+
process.send?.({
|
|
433
|
+
type: "pages-updated",
|
|
434
|
+
data: {
|
|
435
|
+
bundlePath: this.#artifact.pagesBundlePath,
|
|
436
|
+
buildId: this.#artifact.pagesBundleBuildId,
|
|
437
|
+
generation,
|
|
438
|
+
changedFiles: [],
|
|
439
|
+
reason,
|
|
440
|
+
},
|
|
410
441
|
});
|
|
442
|
+
const cssAssets = this.#artifact.cssAssets ?? {};
|
|
443
|
+
const cssBase64ByUrl = Object.fromEntries(
|
|
444
|
+
await Promise.all(
|
|
445
|
+
Object.values(cssAssets).map(async ({ cssUrl, cssRelPath }) => [
|
|
446
|
+
cssUrl,
|
|
447
|
+
Buffer.from(await Bun.file(path.join(this.#artifactDir, cssRelPath)).arrayBuffer()).toString("base64"),
|
|
448
|
+
]),
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
process.send?.({
|
|
452
|
+
type: "css-updated",
|
|
453
|
+
data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
|
|
454
|
+
});
|
|
455
|
+
this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
|
|
411
456
|
}
|
|
412
457
|
|
|
413
458
|
/**
|
|
@@ -418,16 +463,19 @@ class IncrementalBuilder {
|
|
|
418
463
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<BuilderCsrRes> {
|
|
419
464
|
return this.#enqueueWork("build-csr", async (): Promise<BuilderCsrRes> => {
|
|
420
465
|
const started = Date.now();
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
this.#
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
this.#logger.error(`csr-build failed: ${
|
|
429
|
-
return { type: "build-csr-res", id: msg.id, ok: false, error
|
|
466
|
+
// Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
|
|
467
|
+
// never carried a csr status for it before. The error travels in the response below.
|
|
468
|
+
const result = await this.#batchRunner.run(
|
|
469
|
+
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
470
|
+
);
|
|
471
|
+
const error = result.errors.csr;
|
|
472
|
+
if (error) {
|
|
473
|
+
this.#logger.error(`csr-build failed: ${error}`);
|
|
474
|
+
return { type: "build-csr-res", id: msg.id, ok: false, error };
|
|
430
475
|
}
|
|
476
|
+
this.#csrActive = true;
|
|
477
|
+
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
478
|
+
return { type: "build-csr-res", id: msg.id, ok: true };
|
|
431
479
|
});
|
|
432
480
|
}
|
|
433
481
|
|
|
@@ -440,12 +488,28 @@ class IncrementalBuilder {
|
|
|
440
488
|
}
|
|
441
489
|
|
|
442
490
|
static async #buildBootDeps(app: App): Promise<IncrementalBuilderBootDeps> {
|
|
443
|
-
const { artifact,
|
|
444
|
-
//* A full minified browser-target build of every page costs ~350MB of bundler arena the process
|
|
445
|
-
//* never returns, so skip it until a `/__csr` or `?csr=true` request arms it via `build-csr`.
|
|
446
|
-
if (IncrementalBuilder.#csrArmedByEnv()) await new CsrArtifactBuilder(app).build();
|
|
491
|
+
const { artifact, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
|
|
447
492
|
const discovery = await GraphClientEntryDiscovery.create(app);
|
|
448
|
-
return { artifact,
|
|
493
|
+
return { artifact, optimizedFonts, discovery };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A session that already armed dev CSR keeps it armed across builder restarts through the env flag,
|
|
498
|
+
* so the artifact has to be rebuilt for the replacement. Queued after `builder-ready` and run in a
|
|
499
|
+
* disposable worker rather than inline: a full minified browser-target build of every page costs
|
|
500
|
+
* ~350MB of bundler arena that an inline build would never give back, and nothing serves CSR until a
|
|
501
|
+
* `/__csr` or `?csr=true` request arrives anyway.
|
|
502
|
+
*/
|
|
503
|
+
async rearmCsrFromEnv(): Promise<void> {
|
|
504
|
+
if (!IncrementalBuilder.#csrArmedByEnv()) return;
|
|
505
|
+
this.#csrActive = true;
|
|
506
|
+
await this.#enqueueWork("build-csr-rearm", async () => {
|
|
507
|
+
const result = await this.#batchRunner.run(
|
|
508
|
+
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
509
|
+
);
|
|
510
|
+
if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
|
|
511
|
+
else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
|
|
512
|
+
});
|
|
449
513
|
}
|
|
450
514
|
|
|
451
515
|
/**
|
|
@@ -511,19 +575,30 @@ class IncrementalBuilder {
|
|
|
511
575
|
// Registered before the boot build so backend requests get an error response (instead of hanging
|
|
512
576
|
// the backend) while the builder is still booting or recovering from a failed build.
|
|
513
577
|
const bootingError = "builder is recovering from a failed boot build; retry after the build error is fixed";
|
|
578
|
+
const recyclingError = "builder is recycling to release bundler memory; retry after it restarts";
|
|
514
579
|
process.on("message", (msg: BuilderMessage) => {
|
|
515
580
|
if (!msg || typeof msg !== "object") return;
|
|
516
|
-
if (msg.type === "
|
|
581
|
+
if (msg.type === "builder-shutdown") {
|
|
517
582
|
if (!builder) {
|
|
518
|
-
|
|
583
|
+
logger.warn(`ignoring shutdown request (${msg.reason}); builder is still recovering from a failed boot`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
void builder.shutdown(msg.reason);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (msg.type === "build-route") {
|
|
590
|
+
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
591
|
+
if (!builder || builder.shuttingDown) {
|
|
592
|
+
process.send?.({ type: "build-route-res", id: msg.id, ok: false, error });
|
|
519
593
|
return;
|
|
520
594
|
}
|
|
521
595
|
void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
|
|
522
596
|
return;
|
|
523
597
|
}
|
|
524
598
|
if (msg.type === "build-csr") {
|
|
525
|
-
|
|
526
|
-
|
|
599
|
+
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
600
|
+
if (!builder || builder.shuttingDown) {
|
|
601
|
+
process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
527
602
|
return;
|
|
528
603
|
}
|
|
529
604
|
void builder.handleBuildCsr(msg).then((res) => process.send?.(res));
|
|
@@ -546,6 +621,8 @@ class IncrementalBuilder {
|
|
|
546
621
|
}
|
|
547
622
|
await builder.boot();
|
|
548
623
|
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
624
|
+
else if (process.env.AKAN_BUILDER_RECYCLED === "1") await builder.announceBootState();
|
|
625
|
+
await builder.rearmCsrFromEnv();
|
|
549
626
|
}
|
|
550
627
|
}
|
|
551
628
|
|