@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.4
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 +283 -2
- package/akanApp/akanApp.host.ts +578 -12
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +199 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +119 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +260 -170
- package/integration/devStability.integration.test.ts +308 -78
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +555 -40
- 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,
|
|
@@ -23,56 +19,61 @@ import { Logger } from "akanjs/common";
|
|
|
23
19
|
import type {
|
|
24
20
|
BaseBuildArtifact,
|
|
25
21
|
BuilderCsrReq,
|
|
26
|
-
BuilderCsrRes,
|
|
27
22
|
BuilderMessage,
|
|
28
23
|
BuilderReq,
|
|
29
24
|
BuilderRes,
|
|
30
25
|
BuildPhase,
|
|
31
26
|
BuildRouteResultPayload,
|
|
32
27
|
} from "akanjs/server";
|
|
28
|
+
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
29
|
+
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
30
|
+
import { BuilderReply } from "./builderReply";
|
|
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;
|
|
56
|
+
#watcher: HmrWatcher | null = null;
|
|
61
57
|
#generation = 0;
|
|
62
58
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
63
59
|
#workQueue: Promise<void> = Promise.resolve();
|
|
60
|
+
#inFlight = 0;
|
|
61
|
+
#workCount = 0;
|
|
62
|
+
#shuttingDown = false;
|
|
64
63
|
#cssRebuildQueue: Promise<void> = Promise.resolve();
|
|
65
64
|
#cssRebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
-
#pendingCssRebuild: {
|
|
67
|
-
null;
|
|
65
|
+
#pendingCssRebuild: { generation?: number; changedFiles?: string[] } | null = null;
|
|
68
66
|
constructor(options: IncrementalBuilderOptions) {
|
|
69
67
|
this.#app = options.app;
|
|
70
68
|
this.#artifact = options.artifact;
|
|
71
69
|
this.#watch = options.watch;
|
|
72
|
-
this.#cssCompiler = options.cssCompiler;
|
|
73
70
|
this.#optimizedFonts = options.optimizedFonts;
|
|
74
71
|
this.#discovery = options.discovery;
|
|
75
72
|
this.#generation = options.initialGeneration ?? 0;
|
|
73
|
+
this.#batchRunner = new BuildBatchRunner({
|
|
74
|
+
workspaceRoot: options.app.workspace.workspaceRoot,
|
|
75
|
+
cwd: options.app.cwdPath,
|
|
76
|
+
});
|
|
76
77
|
this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
77
78
|
this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
78
79
|
this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
@@ -82,8 +83,14 @@ class IncrementalBuilder {
|
|
|
82
83
|
return `${this.#app.cwdPath}/.akan/artifact`;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
|
|
88
|
+
* work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
|
|
89
|
+
*/
|
|
90
|
+
async handleBuildRoute(msg: BuilderReq): Promise<void> {
|
|
91
|
+
await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
|
|
92
|
+
BuilderReply.send(await this.#handleBuildRoute(msg)),
|
|
93
|
+
);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
|
|
@@ -138,14 +145,63 @@ class IncrementalBuilder {
|
|
|
138
145
|
}
|
|
139
146
|
async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
140
147
|
const started = Date.now();
|
|
148
|
+
this.#inFlight += 1;
|
|
141
149
|
const run = this.#workQueue.then(fn, fn);
|
|
142
150
|
this.#workQueue = run.then(() => undefined).catch(() => undefined);
|
|
143
151
|
try {
|
|
144
152
|
return await run;
|
|
145
153
|
} finally {
|
|
154
|
+
this.#inFlight -= 1;
|
|
155
|
+
this.#workCount += 1;
|
|
146
156
|
this.#logger.verbose(`[work-queue] ${label} finished in ${Date.now() - started}ms`);
|
|
157
|
+
this.#reportMetrics();
|
|
147
158
|
}
|
|
148
159
|
}
|
|
160
|
+
|
|
161
|
+
/** No queued work and no debounced css rebuild, so nothing is lost if the process exits now. */
|
|
162
|
+
get #idle(): boolean {
|
|
163
|
+
return this.#inFlight === 0 && this.#cssRebuildTimer === null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Reported only when the builder is idle. The host's only lever against the bundler arenas
|
|
168
|
+
* `Bun.build` retains is to recycle this process, and a recycle decided while work is queued would
|
|
169
|
+
* either truncate that work or race the shutdown drain — so a busy builder simply says nothing.
|
|
170
|
+
*/
|
|
171
|
+
#reportMetrics(): void {
|
|
172
|
+
if (!this.#idle || this.#shuttingDown) return;
|
|
173
|
+
process.send?.({
|
|
174
|
+
type: "builder-metrics",
|
|
175
|
+
data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Finish queued work, then exit so the OS reclaims the bundler arenas. The host restarts a
|
|
181
|
+
* replacement; build requests that arrive during the drain are refused with the same retry error
|
|
182
|
+
* the backend already handles for any other builder restart.
|
|
183
|
+
*/
|
|
184
|
+
async shutdown(reason: string): Promise<void> {
|
|
185
|
+
if (this.#shuttingDown) return;
|
|
186
|
+
this.#shuttingDown = true;
|
|
187
|
+
const started = Date.now();
|
|
188
|
+
this.#logger.info(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
|
|
189
|
+
if (this.#cssRebuildTimer) {
|
|
190
|
+
// Only reachable if a css batch landed between the idle report and this request: the fresh
|
|
191
|
+
// boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
|
|
192
|
+
clearTimeout(this.#cssRebuildTimer);
|
|
193
|
+
this.#cssRebuildTimer = null;
|
|
194
|
+
this.#pendingCssRebuild = null;
|
|
195
|
+
}
|
|
196
|
+
await this.#workQueue.catch(() => undefined);
|
|
197
|
+
await this.#cssRebuildQueue.catch(() => undefined);
|
|
198
|
+
this.#logger.info(`drained in ${Date.now() - started}ms; exiting for recycle`);
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
get shuttingDown(): boolean {
|
|
203
|
+
return this.#shuttingDown;
|
|
204
|
+
}
|
|
149
205
|
batchTouchesPagesTree(appDir: string, batch: ChangeBatch): boolean {
|
|
150
206
|
const absAppDir = path.resolve(appDir);
|
|
151
207
|
for (const f of batch.files) {
|
|
@@ -167,75 +223,23 @@ class IncrementalBuilder {
|
|
|
167
223
|
}
|
|
168
224
|
return false;
|
|
169
225
|
}
|
|
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 };
|
|
226
|
+
/** Debounced css-only rebuild; the compile itself runs in a disposable worker like every other build. */
|
|
227
|
+
scheduleCssRebuild({ generation, changedFiles }: { generation?: number; changedFiles?: string[] }) {
|
|
228
|
+
this.#pendingCssRebuild = { generation, changedFiles };
|
|
223
229
|
if (this.#cssRebuildTimer) clearTimeout(this.#cssRebuildTimer);
|
|
224
230
|
this.#cssRebuildTimer = setTimeout(() => {
|
|
225
231
|
this.#cssRebuildTimer = null;
|
|
226
232
|
const next = this.#pendingCssRebuild;
|
|
227
233
|
this.#pendingCssRebuild = null;
|
|
228
234
|
if (!next) return;
|
|
235
|
+
this.#inFlight += 1;
|
|
229
236
|
this.#cssRebuildQueue = this.#cssRebuildQueue
|
|
230
237
|
.then(async () => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
changedFiles: next.changedFiles,
|
|
238
|
+
await this.#runBatch({
|
|
239
|
+
generation: next.generation ?? this.#generation,
|
|
240
|
+
needs: ["css"],
|
|
241
|
+
changedFiles: next.changedFiles ?? [],
|
|
236
242
|
});
|
|
237
|
-
this.#sendBuildStatus("css", { generation: next.generation, ok: true, files: next.changedFiles });
|
|
238
|
-
this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
239
243
|
})
|
|
240
244
|
.catch((err) => {
|
|
241
245
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -246,29 +250,13 @@ class IncrementalBuilder {
|
|
|
246
250
|
files: next.changedFiles,
|
|
247
251
|
message,
|
|
248
252
|
});
|
|
253
|
+
})
|
|
254
|
+
.finally(() => {
|
|
255
|
+
this.#inFlight -= 1;
|
|
256
|
+
this.#reportMetrics();
|
|
249
257
|
});
|
|
250
258
|
}, 150);
|
|
251
259
|
}
|
|
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
260
|
async installWatcher() {
|
|
273
261
|
const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, this.#artifactDir];
|
|
274
262
|
const roots = await new WatchRootResolver(this.#app).resolve();
|
|
@@ -279,7 +267,8 @@ class IncrementalBuilder {
|
|
|
279
267
|
await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(appDir, artifactDir, batch));
|
|
280
268
|
},
|
|
281
269
|
});
|
|
282
|
-
watcher.start();
|
|
270
|
+
await watcher.start();
|
|
271
|
+
this.#watcher = watcher;
|
|
283
272
|
this.#logger.verbose(`watching ${roots.length} roots`);
|
|
284
273
|
}
|
|
285
274
|
|
|
@@ -295,6 +284,10 @@ class IncrementalBuilder {
|
|
|
295
284
|
if (autoImport.changedFiles.length > 0)
|
|
296
285
|
this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
|
|
297
286
|
const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
|
|
287
|
+
//* Both passes above write source files, and this generation's build consumes what they wrote. Hand
|
|
288
|
+
//* them to the watcher so its verification scan does not read them back as a user edit and spend a
|
|
289
|
+
//* second generation rebuilding identical content.
|
|
290
|
+
await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
|
|
298
291
|
const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
299
292
|
generation,
|
|
300
293
|
batch,
|
|
@@ -338,49 +331,86 @@ class IncrementalBuilder {
|
|
|
338
331
|
this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
|
|
339
332
|
}
|
|
340
333
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
this.#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
);
|
|
334
|
+
const needs: BuildBatchNeed[] = [];
|
|
335
|
+
if (kinds.includes("code") && rebuildClient) {
|
|
336
|
+
if (this.#shouldRebuildCsr()) needs.push("csr");
|
|
337
|
+
else
|
|
338
|
+
this.#logger.verbose(
|
|
339
|
+
`csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
|
|
340
|
+
);
|
|
341
|
+
needs.push("pages");
|
|
342
|
+
// Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
|
|
343
|
+
// client rebuild or a direct stylesheet edit can change the compiled CSS. Folded into this
|
|
344
|
+
// generation's batch rather than debounced separately: the work queue already serializes
|
|
345
|
+
// generations, so by the time a second batch runs the 150ms debounce would have fired anyway,
|
|
346
|
+
// and a second worker spawn per save costs more than the coalescing ever saved.
|
|
347
|
+
needs.push("css");
|
|
356
348
|
}
|
|
357
349
|
|
|
358
350
|
process.send?.(event);
|
|
359
351
|
|
|
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 });
|
|
352
|
+
if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
|
|
353
|
+
// A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
|
|
354
|
+
// without a pages build in front of them there is nothing else to space them out.
|
|
355
|
+
else if (kinds.includes("css")) {
|
|
356
|
+
this.scheduleCssRebuild({ generation, changedFiles: files });
|
|
380
357
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
381
358
|
}
|
|
382
359
|
}
|
|
383
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Run one generation of build work in a process that exits afterwards. The worker streams the
|
|
363
|
+
* messages the backend and the HMR overlay consume, which this relays untouched, so the sequence a
|
|
364
|
+
* browser observes is the same one the in-process build produced.
|
|
365
|
+
*/
|
|
366
|
+
async #runBatch({
|
|
367
|
+
generation,
|
|
368
|
+
needs,
|
|
369
|
+
changedFiles,
|
|
370
|
+
}: {
|
|
371
|
+
generation: number;
|
|
372
|
+
needs: BuildBatchNeed[];
|
|
373
|
+
changedFiles: string[];
|
|
374
|
+
}): Promise<BuildBatchResult> {
|
|
375
|
+
const started = Date.now();
|
|
376
|
+
const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
|
|
377
|
+
process.send?.(msg),
|
|
378
|
+
);
|
|
379
|
+
if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
|
|
380
|
+
if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
|
|
381
|
+
// A worker that died before reporting streamed no build-status of its own, so report one per need
|
|
382
|
+
// it was given: the generation must go red rather than look like it silently succeeded.
|
|
383
|
+
if (result.crashed) {
|
|
384
|
+
for (const need of needs)
|
|
385
|
+
this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
|
|
386
|
+
}
|
|
387
|
+
if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async #batchRequest({
|
|
392
|
+
generation,
|
|
393
|
+
needs,
|
|
394
|
+
changedFiles,
|
|
395
|
+
}: {
|
|
396
|
+
generation: number;
|
|
397
|
+
needs: BuildBatchNeed[];
|
|
398
|
+
changedFiles: string[];
|
|
399
|
+
}): Promise<BuildBatchRequest> {
|
|
400
|
+
return {
|
|
401
|
+
appName: this.#app.name,
|
|
402
|
+
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
403
|
+
repoName: this.#app.workspace.repoName,
|
|
404
|
+
generation,
|
|
405
|
+
needs,
|
|
406
|
+
changedFiles,
|
|
407
|
+
pageKeys: await this.#app.getPageKeys(),
|
|
408
|
+
optimizedFonts: this.#optimizedFonts,
|
|
409
|
+
cssAssets: this.#artifact.cssAssets ?? null,
|
|
410
|
+
artifactDir: path.resolve(this.#artifactDir),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
384
414
|
async boot(): Promise<void> {
|
|
385
415
|
if (this.#watch) await this.installWatcher();
|
|
386
416
|
process.send?.({ type: "builder-ready" });
|
|
@@ -394,20 +424,47 @@ class IncrementalBuilder {
|
|
|
394
424
|
async announceRecoveredState(changedFiles: string[]): Promise<void> {
|
|
395
425
|
const generation = ++this.#generation;
|
|
396
426
|
await this.#enqueueWork("boot-recovered", async () => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
427
|
+
await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Re-announce the state this builder just booted with, after the host recycled the previous one.
|
|
433
|
+
*
|
|
434
|
+
* The backend reads `base-artifact.json` once at boot and never re-reads it, so a pages or css hash
|
|
435
|
+
* that moved while the builder was being replaced would otherwise leave it pointing at the previous
|
|
436
|
+
* artifact until the next save. Announced from the boot artifact rather than by rebuilding: the
|
|
437
|
+
* bundles are already on disk, and spending another ~200MB of bundler arena in a process that was
|
|
438
|
+
* just recycled to reclaim memory would defeat the purpose. The host suppresses the announcement
|
|
439
|
+
* when the hashes match, which is the common case, so a clean recycle never reloads a browser.
|
|
440
|
+
*/
|
|
441
|
+
async announceBootState(): Promise<void> {
|
|
442
|
+
const generation = ++this.#generation;
|
|
443
|
+
const reason = "builder-recycle" as const;
|
|
444
|
+
process.send?.({
|
|
445
|
+
type: "pages-updated",
|
|
446
|
+
data: {
|
|
447
|
+
bundlePath: this.#artifact.pagesBundlePath,
|
|
448
|
+
buildId: this.#artifact.pagesBundleBuildId,
|
|
449
|
+
generation,
|
|
450
|
+
changedFiles: [],
|
|
451
|
+
reason,
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
const cssAssets = this.#artifact.cssAssets ?? {};
|
|
455
|
+
const cssBase64ByUrl = Object.fromEntries(
|
|
456
|
+
await Promise.all(
|
|
457
|
+
Object.values(cssAssets).map(async ({ cssUrl, cssRelPath }) => [
|
|
458
|
+
cssUrl,
|
|
459
|
+
Buffer.from(await Bun.file(path.join(this.#artifactDir, cssRelPath)).arrayBuffer()).toString("base64"),
|
|
460
|
+
]),
|
|
461
|
+
),
|
|
462
|
+
);
|
|
463
|
+
process.send?.({
|
|
464
|
+
type: "css-updated",
|
|
465
|
+
data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
|
|
410
466
|
});
|
|
467
|
+
this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
|
|
411
468
|
}
|
|
412
469
|
|
|
413
470
|
/**
|
|
@@ -415,19 +472,23 @@ class IncrementalBuilder {
|
|
|
415
472
|
* dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
|
|
416
473
|
* points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
|
|
417
474
|
*/
|
|
418
|
-
async handleBuildCsr(msg: BuilderCsrReq): Promise<
|
|
419
|
-
|
|
475
|
+
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
476
|
+
await this.#enqueueWork("build-csr", async (): Promise<void> => {
|
|
420
477
|
const started = Date.now();
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
this.#
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
this.#logger.error(`csr-build failed: ${
|
|
429
|
-
|
|
478
|
+
// Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
|
|
479
|
+
// never carried a csr status for it before. The error travels in the response below.
|
|
480
|
+
const result = await this.#batchRunner.run(
|
|
481
|
+
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
482
|
+
);
|
|
483
|
+
const error = result.errors.csr;
|
|
484
|
+
if (error) {
|
|
485
|
+
this.#logger.error(`csr-build failed: ${error}`);
|
|
486
|
+
await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
487
|
+
return;
|
|
430
488
|
}
|
|
489
|
+
this.#csrActive = true;
|
|
490
|
+
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
491
|
+
await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: true });
|
|
431
492
|
});
|
|
432
493
|
}
|
|
433
494
|
|
|
@@ -440,12 +501,28 @@ class IncrementalBuilder {
|
|
|
440
501
|
}
|
|
441
502
|
|
|
442
503
|
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();
|
|
504
|
+
const { artifact, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
|
|
447
505
|
const discovery = await GraphClientEntryDiscovery.create(app);
|
|
448
|
-
return { artifact,
|
|
506
|
+
return { artifact, optimizedFonts, discovery };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* A session that already armed dev CSR keeps it armed across builder restarts through the env flag,
|
|
511
|
+
* so the artifact has to be rebuilt for the replacement. Queued after `builder-ready` and run in a
|
|
512
|
+
* disposable worker rather than inline: a full minified browser-target build of every page costs
|
|
513
|
+
* ~350MB of bundler arena that an inline build would never give back, and nothing serves CSR until a
|
|
514
|
+
* `/__csr` or `?csr=true` request arrives anyway.
|
|
515
|
+
*/
|
|
516
|
+
async rearmCsrFromEnv(): Promise<void> {
|
|
517
|
+
if (!IncrementalBuilder.#csrArmedByEnv()) return;
|
|
518
|
+
this.#csrActive = true;
|
|
519
|
+
await this.#enqueueWork("build-csr-rearm", async () => {
|
|
520
|
+
const result = await this.#batchRunner.run(
|
|
521
|
+
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
522
|
+
);
|
|
523
|
+
if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
|
|
524
|
+
else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
|
|
525
|
+
});
|
|
449
526
|
}
|
|
450
527
|
|
|
451
528
|
/**
|
|
@@ -494,7 +571,7 @@ class IncrementalBuilder {
|
|
|
494
571
|
}
|
|
495
572
|
},
|
|
496
573
|
});
|
|
497
|
-
watcher.start();
|
|
574
|
+
await watcher.start();
|
|
498
575
|
logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
|
|
499
576
|
})().catch(reject);
|
|
500
577
|
});
|
|
@@ -511,22 +588,33 @@ class IncrementalBuilder {
|
|
|
511
588
|
// Registered before the boot build so backend requests get an error response (instead of hanging
|
|
512
589
|
// the backend) while the builder is still booting or recovering from a failed build.
|
|
513
590
|
const bootingError = "builder is recovering from a failed boot build; retry after the build error is fixed";
|
|
591
|
+
const recyclingError = "builder is recycling to release bundler memory; retry after it restarts";
|
|
514
592
|
process.on("message", (msg: BuilderMessage) => {
|
|
515
593
|
if (!msg || typeof msg !== "object") return;
|
|
516
|
-
if (msg.type === "
|
|
594
|
+
if (msg.type === "builder-shutdown") {
|
|
517
595
|
if (!builder) {
|
|
518
|
-
|
|
596
|
+
logger.warn(`ignoring shutdown request (${msg.reason}); builder is still recovering from a failed boot`);
|
|
519
597
|
return;
|
|
520
598
|
}
|
|
521
|
-
void builder.
|
|
599
|
+
void builder.shutdown(msg.reason);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (msg.type === "build-route") {
|
|
603
|
+
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
604
|
+
if (!builder || builder.shuttingDown) {
|
|
605
|
+
process.send?.({ type: "build-route-res", id: msg.id, ok: false, error });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
void builder.handleBuildRoute(msg);
|
|
522
609
|
return;
|
|
523
610
|
}
|
|
524
611
|
if (msg.type === "build-csr") {
|
|
525
|
-
|
|
526
|
-
|
|
612
|
+
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
613
|
+
if (!builder || builder.shuttingDown) {
|
|
614
|
+
process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
527
615
|
return;
|
|
528
616
|
}
|
|
529
|
-
void builder.handleBuildCsr(msg)
|
|
617
|
+
void builder.handleBuildCsr(msg);
|
|
530
618
|
}
|
|
531
619
|
});
|
|
532
620
|
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
@@ -546,6 +634,8 @@ class IncrementalBuilder {
|
|
|
546
634
|
}
|
|
547
635
|
await builder.boot();
|
|
548
636
|
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
637
|
+
else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
|
|
638
|
+
await builder.rearmCsrFromEnv();
|
|
549
639
|
}
|
|
550
640
|
}
|
|
551
641
|
|