@akanjs/devkit 2.4.1-rc.3 → 2.4.1-rc.5
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 +199 -2
- package/akanApp/akanApp.host.ts +399 -9
- 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 +34 -1
- package/incrementalBuilder/buildBatchProtocol.ts +13 -2
- package/incrementalBuilder/builderChannel.test.ts +144 -0
- package/incrementalBuilder/builderChannel.ts +72 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +88 -3
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -3
- package/incrementalBuilder/incrementalBuilder.proc.ts +93 -34
- package/integration/devStability.integration.test.ts +260 -101
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +528 -39
- package/package.json +2 -2
|
@@ -68,7 +68,7 @@ describe("IncrementalBuilderHost", () => {
|
|
|
68
68
|
|
|
69
69
|
host.start({ onRestartReady });
|
|
70
70
|
spawns[0]?.options.ipc?.({ type: "builder-ready" });
|
|
71
|
-
expect(spawns[0]?.options.env?.
|
|
71
|
+
expect(spawns[0]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
|
|
72
72
|
|
|
73
73
|
const reason = "rss=1300MiB>=1200MiB after 3 build(s)";
|
|
74
74
|
expect(host.recycle(reason)).toBe(true);
|
|
@@ -80,7 +80,7 @@ describe("IncrementalBuilderHost", () => {
|
|
|
80
80
|
// A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
|
|
81
81
|
spawns[0]?.options.onExit?.();
|
|
82
82
|
expect(spawns).toHaveLength(2);
|
|
83
|
-
expect(spawns[1]?.options.env?.
|
|
83
|
+
expect(spawns[1]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBe("1");
|
|
84
84
|
spawns[1]?.options.ipc?.({ type: "builder-ready" });
|
|
85
85
|
expect(onRestartReady).toHaveBeenCalledTimes(1);
|
|
86
86
|
expect(host.status).toBe("ready");
|
|
@@ -90,11 +90,96 @@ describe("IncrementalBuilderHost", () => {
|
|
|
90
90
|
expect(spawns).toHaveLength(2);
|
|
91
91
|
await wait(1_050);
|
|
92
92
|
expect(spawns).toHaveLength(3);
|
|
93
|
-
expect(spawns[2]?.options.env?.
|
|
93
|
+
expect(spawns[2]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
|
|
94
94
|
|
|
95
95
|
host.stop();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test("fails the requests a departing builder never answered", async () => {
|
|
99
|
+
const spawns = mockSpawns();
|
|
100
|
+
const messages: unknown[] = [];
|
|
101
|
+
const host = new IncrementalBuilderHost({
|
|
102
|
+
app: { cwdPath: "/tmp/app" } as never,
|
|
103
|
+
entry: "/tmp/builder.ts",
|
|
104
|
+
env: {},
|
|
105
|
+
onMessage: (message) => messages.push(message),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
host.start();
|
|
109
|
+
spawns[0]?.options.ipc?.({ type: "builder-ready" });
|
|
110
|
+
expect(host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] })).toBe(true);
|
|
111
|
+
expect(host.send({ type: "build-csr", id: 2, reason: "device webview" })).toBe(true);
|
|
112
|
+
// Answered before the exit, so this one must not be failed again afterwards.
|
|
113
|
+
expect(host.send({ type: "build-route", id: 3, routeId: "c", seeds: [], knownEntries: [] })).toBe(true);
|
|
114
|
+
spawns[0]?.options.ipc?.({ type: "build-route-res", id: 3, ok: true, data: { routeId: "c" } });
|
|
115
|
+
messages.length = 0;
|
|
116
|
+
|
|
117
|
+
host.recycle("rss=1300MiB>=1200MiB after 3 build(s)");
|
|
118
|
+
spawns[0]?.options.onExit?.();
|
|
119
|
+
|
|
120
|
+
// Nothing else answers these: the builder only refuses requests that arrive after it starts shutting
|
|
121
|
+
// down, and a kill or a truncated write sends nothing at all.
|
|
122
|
+
expect(messages).toEqual([
|
|
123
|
+
{
|
|
124
|
+
type: "build-route-res",
|
|
125
|
+
id: 1,
|
|
126
|
+
ok: false,
|
|
127
|
+
error: "builder exited to release bundler memory before answering; reload to retry",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: "build-csr-res",
|
|
131
|
+
id: 2,
|
|
132
|
+
ok: false,
|
|
133
|
+
error: "builder exited to release bundler memory before answering; reload to retry",
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
// The replacement owes nothing, so its own exit stays quiet.
|
|
138
|
+
spawns[1]?.options.ipc?.({ type: "builder-ready" });
|
|
139
|
+
messages.length = 0;
|
|
140
|
+
spawns[1]?.options.onExit?.();
|
|
141
|
+
expect(messages).toEqual([]);
|
|
142
|
+
await wait(1_050);
|
|
143
|
+
|
|
144
|
+
host.stop();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("names a crash rather than a recycle, and answers on stop too", async () => {
|
|
148
|
+
const spawns = mockSpawns();
|
|
149
|
+
const messages: unknown[] = [];
|
|
150
|
+
const host = new IncrementalBuilderHost({
|
|
151
|
+
app: { cwdPath: "/tmp/app" } as never,
|
|
152
|
+
entry: "/tmp/builder.ts",
|
|
153
|
+
env: {},
|
|
154
|
+
onMessage: (message) => messages.push(message),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
host.start();
|
|
158
|
+
spawns[0]?.options.ipc?.({ type: "builder-ready" });
|
|
159
|
+
host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] });
|
|
160
|
+
messages.length = 0;
|
|
161
|
+
spawns[0]?.options.onExit?.();
|
|
162
|
+
expect(messages).toEqual([
|
|
163
|
+
{
|
|
164
|
+
type: "build-route-res",
|
|
165
|
+
id: 1,
|
|
166
|
+
ok: false,
|
|
167
|
+
error: "builder exited unexpectedly before answering; reload once it is back",
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
// `stop()` clears the process before its exit callback runs, so the callback bails on its identity
|
|
172
|
+
// check and cannot be the only place this happens.
|
|
173
|
+
await wait(1_050);
|
|
174
|
+
spawns[1]?.options.ipc?.({ type: "builder-ready" });
|
|
175
|
+
messages.length = 0;
|
|
176
|
+
expect(host.send({ type: "build-route", id: 2, routeId: "b", seeds: [], knownEntries: [] })).toBe(true);
|
|
177
|
+
host.stop();
|
|
178
|
+
expect(messages).toEqual([
|
|
179
|
+
{ type: "build-route-res", id: 2, ok: false, error: "builder was stopped before answering" },
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
98
183
|
test("only recycles a builder that is ready", () => {
|
|
99
184
|
const spawns = mockSpawns();
|
|
100
185
|
const host = new IncrementalBuilderHost({
|
|
@@ -27,6 +27,11 @@ interface IncrementalBuilderStartOptions {
|
|
|
27
27
|
onExit?: () => void;
|
|
28
28
|
onReady?: () => void;
|
|
29
29
|
onRestartReady?: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Ask the builder to re-announce the artifact it boots with. Needed whenever a *previous* builder's
|
|
32
|
+
* artifact may still be live in a running backend — after an rss recycle, and after an idle wake.
|
|
33
|
+
*/
|
|
34
|
+
announceBootState?: boolean;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export class IncrementalBuilderHost {
|
|
@@ -56,6 +61,17 @@ export class IncrementalBuilderHost {
|
|
|
56
61
|
#recycleRequested: boolean = false;
|
|
57
62
|
#spawnAfterRecycle: boolean = false;
|
|
58
63
|
#manualStop = false;
|
|
64
|
+
/**
|
|
65
|
+
* Requests handed to the running builder that it has not answered yet, keyed by the backend's
|
|
66
|
+
* correlation id.
|
|
67
|
+
*
|
|
68
|
+
* Nothing else answers a request whose builder exits while holding it: the builder only refuses
|
|
69
|
+
* requests that arrive *after* it starts shutting down, a kill or crash sends nothing at all, and even
|
|
70
|
+
* a clean drain races its own `process.exit`. The builder exits routinely — it is recycled whenever its
|
|
71
|
+
* RSS passes the ceiling — so a page request that happened to be mid route-build left the backend's
|
|
72
|
+
* promise pending and the browser tab spinning with no error and nothing to retry.
|
|
73
|
+
*/
|
|
74
|
+
readonly #inFlight = new Map<number, "build-route" | "build-csr">();
|
|
59
75
|
#startOptions: IncrementalBuilderStartOptions = {};
|
|
60
76
|
constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
|
|
61
77
|
this.app = app;
|
|
@@ -66,28 +82,38 @@ export class IncrementalBuilderHost {
|
|
|
66
82
|
get status() {
|
|
67
83
|
return this.#status;
|
|
68
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* The running builder's pid, so the host can read its RSS from the OS between builds. The builder
|
|
87
|
+
* only reports its own metrics at work-completion points, which is the *peak*; on a platform that
|
|
88
|
+
* returns bundler arenas to the OS while idle, that sample goes stale within seconds.
|
|
89
|
+
*/
|
|
90
|
+
get pid(): number | null {
|
|
91
|
+
return this.#proc?.pid ?? null;
|
|
92
|
+
}
|
|
69
93
|
start(options: IncrementalBuilderStartOptions = {}) {
|
|
70
94
|
if (this.#proc) this.stop();
|
|
71
95
|
this.#manualStop = false;
|
|
72
96
|
this.#startOptions = options;
|
|
97
|
+
this.#spawnAfterRecycle = options.announceBootState ?? false;
|
|
73
98
|
this.#spawn(false);
|
|
74
99
|
return this;
|
|
75
100
|
}
|
|
76
101
|
#spawn(isRestart: boolean) {
|
|
77
102
|
this.#status = isRestart ? "restarting" : "starting";
|
|
78
103
|
this.ready = false;
|
|
79
|
-
// A
|
|
80
|
-
//
|
|
104
|
+
// A fresh builder rebuilds every artifact while the running backend still holds the previous one;
|
|
105
|
+
// the flag is what tells it to re-announce what it booted with.
|
|
81
106
|
const afterRecycle = this.#spawnAfterRecycle;
|
|
82
107
|
this.#spawnAfterRecycle = false;
|
|
83
108
|
let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
|
|
84
109
|
proc = Bun.spawn(["bun", this.entry], {
|
|
85
110
|
cwd: this.app.cwdPath,
|
|
86
|
-
env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? {
|
|
111
|
+
env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
|
|
87
112
|
stdio: ["ignore", "inherit", "inherit"],
|
|
88
113
|
ipc: (msg: BuilderMessage) => {
|
|
89
114
|
if (this.#proc !== proc) return;
|
|
90
115
|
if (!msg || typeof msg !== "object") return;
|
|
116
|
+
if (msg.type === "build-route-res" || msg.type === "build-csr-res") this.#inFlight.delete(msg.id);
|
|
91
117
|
if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
|
|
92
118
|
if (msg.type === "builder-ready" && !this.ready) {
|
|
93
119
|
this.ready = true;
|
|
@@ -105,6 +131,11 @@ export class IncrementalBuilderHost {
|
|
|
105
131
|
const wasRecycle = this.#recycleRequested;
|
|
106
132
|
this.#clearRecycle();
|
|
107
133
|
this.ready = false;
|
|
134
|
+
this.#failInFlight(
|
|
135
|
+
wasRecycle
|
|
136
|
+
? "builder exited to release bundler memory before answering; reload to retry"
|
|
137
|
+
: "builder exited unexpectedly before answering; reload once it is back",
|
|
138
|
+
);
|
|
108
139
|
if (this.#manualStop || this.#status === "stopped") return;
|
|
109
140
|
if (!wasReady) {
|
|
110
141
|
this.#status = "stopped";
|
|
@@ -189,6 +220,7 @@ export class IncrementalBuilderHost {
|
|
|
189
220
|
}
|
|
190
221
|
try {
|
|
191
222
|
this.#proc.send(message);
|
|
223
|
+
if (message.type === "build-route" || message.type === "build-csr") this.#inFlight.set(message.id, message.type);
|
|
192
224
|
return true;
|
|
193
225
|
} catch (error) {
|
|
194
226
|
this.logger.warn(
|
|
@@ -197,9 +229,24 @@ export class IncrementalBuilderHost {
|
|
|
197
229
|
return false;
|
|
198
230
|
}
|
|
199
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Answer every request the departing builder still owed, as if it had failed them itself. `onExit`
|
|
234
|
+
* cannot do this alone: `stop()` clears `#proc` first, so the exit callback bails on its identity check.
|
|
235
|
+
*/
|
|
236
|
+
#failInFlight(reason: string): void {
|
|
237
|
+
if (!this.#inFlight.size) return;
|
|
238
|
+
const lost = [...this.#inFlight];
|
|
239
|
+
this.#inFlight.clear();
|
|
240
|
+
this.logger.warn(`failing ${lost.length} unanswered builder request(s): ${reason}`);
|
|
241
|
+
for (const [id, type] of lost) {
|
|
242
|
+
if (type === "build-route") this.#onMessage({ type: "build-route-res", id, ok: false, error: reason });
|
|
243
|
+
else this.#onMessage({ type: "build-csr-res", id, ok: false, error: reason });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
200
246
|
stop() {
|
|
201
247
|
this.#manualStop = true;
|
|
202
248
|
this.#clearRecycle();
|
|
249
|
+
this.#failInFlight("builder was stopped before answering");
|
|
203
250
|
if (this.#restartTimer) {
|
|
204
251
|
clearTimeout(this.#restartTimer);
|
|
205
252
|
this.#restartTimer = null;
|
|
@@ -12,14 +12,12 @@ import {
|
|
|
12
12
|
GraphClientEntryDiscovery,
|
|
13
13
|
HmrWatcher,
|
|
14
14
|
RouteClientBuilder,
|
|
15
|
-
SsrBaseArtifactBuilder,
|
|
16
15
|
WatchRootResolver,
|
|
17
16
|
} from "@akanjs/devkit/frontendBuild";
|
|
18
17
|
import { Logger } from "akanjs/common";
|
|
19
18
|
import type {
|
|
20
19
|
BaseBuildArtifact,
|
|
21
20
|
BuilderCsrReq,
|
|
22
|
-
BuilderCsrRes,
|
|
23
21
|
BuilderMessage,
|
|
24
22
|
BuilderReq,
|
|
25
23
|
BuilderRes,
|
|
@@ -28,6 +26,7 @@ import type {
|
|
|
28
26
|
} from "akanjs/server";
|
|
29
27
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
30
28
|
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
29
|
+
import { BuilderChannel } from "./builderChannel";
|
|
31
30
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
32
31
|
|
|
33
32
|
interface IncrementalBuilderOptions {
|
|
@@ -53,6 +52,7 @@ class IncrementalBuilder {
|
|
|
53
52
|
#changePlanner: DevChangePlanner;
|
|
54
53
|
#generatedIndexSync: DevGeneratedIndexSync;
|
|
55
54
|
#autoImportSync: AutoImportSync;
|
|
55
|
+
#watcher: HmrWatcher | null = null;
|
|
56
56
|
#generation = 0;
|
|
57
57
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
58
58
|
#workQueue: Promise<void> = Promise.resolve();
|
|
@@ -82,8 +82,14 @@ class IncrementalBuilder {
|
|
|
82
82
|
return `${this.#app.cwdPath}/.akan/artifact`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
|
|
87
|
+
* work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
|
|
88
|
+
*/
|
|
89
|
+
async handleBuildRoute(msg: BuilderReq): Promise<void> {
|
|
90
|
+
await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
|
|
91
|
+
BuilderChannel.send(await this.#handleBuildRoute(msg)),
|
|
92
|
+
);
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
|
|
@@ -125,7 +131,7 @@ class IncrementalBuilder {
|
|
|
125
131
|
{ generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
|
|
126
132
|
): void {
|
|
127
133
|
if (typeof generation !== "number") return;
|
|
128
|
-
|
|
134
|
+
BuilderChannel.emit({
|
|
129
135
|
type: "build-status",
|
|
130
136
|
data: {
|
|
131
137
|
generation,
|
|
@@ -163,7 +169,7 @@ class IncrementalBuilder {
|
|
|
163
169
|
*/
|
|
164
170
|
#reportMetrics(): void {
|
|
165
171
|
if (!this.#idle || this.#shuttingDown) return;
|
|
166
|
-
|
|
172
|
+
BuilderChannel.emit({
|
|
167
173
|
type: "builder-metrics",
|
|
168
174
|
data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
|
|
169
175
|
});
|
|
@@ -188,7 +194,14 @@ class IncrementalBuilder {
|
|
|
188
194
|
}
|
|
189
195
|
await this.#workQueue.catch(() => undefined);
|
|
190
196
|
await this.#cssRebuildQueue.catch(() => undefined);
|
|
191
|
-
|
|
197
|
+
// Drained queues do not mean the host has the results. The events those work items produced are the
|
|
198
|
+
// largest messages this process sends, and `process.exit` discards an ipc write that has not
|
|
199
|
+
// flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
|
|
200
|
+
// anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
|
|
201
|
+
const flushed = await BuilderChannel.drain();
|
|
202
|
+
this.#logger.info(
|
|
203
|
+
`drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
|
|
204
|
+
);
|
|
192
205
|
process.exit(0);
|
|
193
206
|
}
|
|
194
207
|
|
|
@@ -260,7 +273,8 @@ class IncrementalBuilder {
|
|
|
260
273
|
await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(appDir, artifactDir, batch));
|
|
261
274
|
},
|
|
262
275
|
});
|
|
263
|
-
watcher.start();
|
|
276
|
+
await watcher.start();
|
|
277
|
+
this.#watcher = watcher;
|
|
264
278
|
this.#logger.verbose(`watching ${roots.length} roots`);
|
|
265
279
|
}
|
|
266
280
|
|
|
@@ -276,6 +290,10 @@ class IncrementalBuilder {
|
|
|
276
290
|
if (autoImport.changedFiles.length > 0)
|
|
277
291
|
this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
|
|
278
292
|
const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
|
|
293
|
+
//* Both passes above write source files, and this generation's build consumes what they wrote. Hand
|
|
294
|
+
//* them to the watcher so its verification scan does not read them back as a user edit and spend a
|
|
295
|
+
//* second generation rebuilding identical content.
|
|
296
|
+
await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
|
|
279
297
|
const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
280
298
|
generation,
|
|
281
299
|
batch,
|
|
@@ -299,7 +317,7 @@ class IncrementalBuilder {
|
|
|
299
317
|
|
|
300
318
|
if (hasSyncErrors) {
|
|
301
319
|
this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
|
|
302
|
-
|
|
320
|
+
BuilderChannel.emit(event);
|
|
303
321
|
return;
|
|
304
322
|
}
|
|
305
323
|
if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
|
|
@@ -335,7 +353,7 @@ class IncrementalBuilder {
|
|
|
335
353
|
needs.push("css");
|
|
336
354
|
}
|
|
337
355
|
|
|
338
|
-
|
|
356
|
+
BuilderChannel.emit(event);
|
|
339
357
|
|
|
340
358
|
if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
|
|
341
359
|
// A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
|
|
@@ -362,15 +380,18 @@ class IncrementalBuilder {
|
|
|
362
380
|
}): Promise<BuildBatchResult> {
|
|
363
381
|
const started = Date.now();
|
|
364
382
|
const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
|
|
365
|
-
|
|
383
|
+
BuilderChannel.emit(msg),
|
|
366
384
|
);
|
|
367
385
|
if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
|
|
368
386
|
if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
|
|
369
387
|
// A worker that died before reporting streamed no build-status of its own, so report one per need
|
|
370
388
|
// it was given: the generation must go red rather than look like it silently succeeded.
|
|
371
389
|
if (result.crashed) {
|
|
390
|
+
// `base` is excluded because it is not a `BuildPhase`: a boot build has no phase board to fail, and
|
|
391
|
+
// it never travels through here — `#buildBootDeps` runs it and throws into the degraded-boot path.
|
|
372
392
|
for (const need of needs)
|
|
373
|
-
|
|
393
|
+
if (need !== "base")
|
|
394
|
+
this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
|
|
374
395
|
}
|
|
375
396
|
if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
376
397
|
return result;
|
|
@@ -401,7 +422,7 @@ class IncrementalBuilder {
|
|
|
401
422
|
|
|
402
423
|
async boot(): Promise<void> {
|
|
403
424
|
if (this.#watch) await this.installWatcher();
|
|
404
|
-
|
|
425
|
+
BuilderChannel.emit({ type: "builder-ready" });
|
|
405
426
|
this.#logger.verbose(`ready (watch=${this.#watch})`);
|
|
406
427
|
}
|
|
407
428
|
|
|
@@ -429,7 +450,9 @@ class IncrementalBuilder {
|
|
|
429
450
|
async announceBootState(): Promise<void> {
|
|
430
451
|
const generation = ++this.#generation;
|
|
431
452
|
const reason = "builder-recycle" as const;
|
|
432
|
-
|
|
453
|
+
// Awaited rather than emitted: this runs during a recycle, so the host may ask this builder to shut
|
|
454
|
+
// down at any moment, and "announced boot state" must mean the announcement left the process.
|
|
455
|
+
await BuilderChannel.send({
|
|
433
456
|
type: "pages-updated",
|
|
434
457
|
data: {
|
|
435
458
|
bundlePath: this.#artifact.pagesBundlePath,
|
|
@@ -448,7 +471,7 @@ class IncrementalBuilder {
|
|
|
448
471
|
]),
|
|
449
472
|
),
|
|
450
473
|
);
|
|
451
|
-
|
|
474
|
+
await BuilderChannel.send({
|
|
452
475
|
type: "css-updated",
|
|
453
476
|
data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
|
|
454
477
|
});
|
|
@@ -460,8 +483,8 @@ class IncrementalBuilder {
|
|
|
460
483
|
* dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
|
|
461
484
|
* points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
|
|
462
485
|
*/
|
|
463
|
-
async handleBuildCsr(msg: BuilderCsrReq): Promise<
|
|
464
|
-
|
|
486
|
+
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
487
|
+
await this.#enqueueWork("build-csr", async (): Promise<void> => {
|
|
465
488
|
const started = Date.now();
|
|
466
489
|
// Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
|
|
467
490
|
// never carried a csr status for it before. The error travels in the response below.
|
|
@@ -471,11 +494,12 @@ class IncrementalBuilder {
|
|
|
471
494
|
const error = result.errors.csr;
|
|
472
495
|
if (error) {
|
|
473
496
|
this.#logger.error(`csr-build failed: ${error}`);
|
|
474
|
-
|
|
497
|
+
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
498
|
+
return;
|
|
475
499
|
}
|
|
476
500
|
this.#csrActive = true;
|
|
477
501
|
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
478
|
-
|
|
502
|
+
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
|
|
479
503
|
});
|
|
480
504
|
}
|
|
481
505
|
|
|
@@ -487,10 +511,40 @@ class IncrementalBuilder {
|
|
|
487
511
|
return process.env.AKAN_DEV_CSR_REBUILD === "1";
|
|
488
512
|
}
|
|
489
513
|
|
|
490
|
-
|
|
491
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Build the boot artifact in a process that exits afterwards, and keep only the serializable result.
|
|
516
|
+
*
|
|
517
|
+
* This runs in a worker for the same reason every other build does, and it was the largest single
|
|
518
|
+
* holdout: measured on `apps/akan`, `SsrBaseArtifactBuilder.build()` retains **+1143 MB** that
|
|
519
|
+
* `Bun.gc(true)` cannot touch, which is 65 % of the builder's post-boot RSS. Nothing was lost by
|
|
520
|
+
* moving it — the builder only ever kept `artifact` and `optimizedFonts`, both plain data, and the
|
|
521
|
+
* artifact is written to `base-artifact.json` regardless.
|
|
522
|
+
*
|
|
523
|
+
* `GraphClientEntryDiscovery.create` stays here because route builds need it live, and it costs
|
|
524
|
+
* nothing to keep: measured at **0 ms and 0 MB**, because it builds its graph lazily on first use.
|
|
525
|
+
*/
|
|
526
|
+
static async #buildBootDeps(app: App, runner: BuildBatchRunner): Promise<IncrementalBuilderBootDeps> {
|
|
527
|
+
const result = await runner.run({
|
|
528
|
+
appName: app.name,
|
|
529
|
+
workspaceRoot: app.workspace.workspaceRoot,
|
|
530
|
+
repoName: app.workspace.repoName,
|
|
531
|
+
generation: 0,
|
|
532
|
+
needs: ["base"],
|
|
533
|
+
changedFiles: [],
|
|
534
|
+
// Discovered by the worker: at boot the watcher has no validated keys to seed, and the boot build
|
|
535
|
+
// globs them itself anyway.
|
|
536
|
+
pageKeys: null,
|
|
537
|
+
optimizedFonts: null,
|
|
538
|
+
cssAssets: null,
|
|
539
|
+
artifactDir: path.resolve(`${app.cwdPath}/.akan/artifact`),
|
|
540
|
+
});
|
|
541
|
+
// A failed boot build has to throw, not degrade quietly: `main` catches this to enter the degraded
|
|
542
|
+
// watch mode that keeps the dev server alive until the error is fixed.
|
|
543
|
+
if (result.errors.base) throw new Error(result.errors.base);
|
|
544
|
+
if (!result.artifact || !result.optimizedFonts)
|
|
545
|
+
throw new Error("boot build reported success without an artifact; the build worker likely died");
|
|
492
546
|
const discovery = await GraphClientEntryDiscovery.create(app);
|
|
493
|
-
return { artifact, optimizedFonts, discovery };
|
|
547
|
+
return { artifact: result.artifact, optimizedFonts: result.optimizedFonts, discovery };
|
|
494
548
|
}
|
|
495
549
|
|
|
496
550
|
/**
|
|
@@ -522,18 +576,19 @@ class IncrementalBuilder {
|
|
|
522
576
|
app: App,
|
|
523
577
|
bootError: unknown,
|
|
524
578
|
logger: Logger,
|
|
579
|
+
runner: BuildBatchRunner,
|
|
525
580
|
): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
|
|
526
581
|
const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
|
|
527
582
|
logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
|
|
528
583
|
let generation = 0;
|
|
529
584
|
const sendFailure = (files: string[], message: string) => {
|
|
530
|
-
|
|
585
|
+
BuilderChannel.emit({
|
|
531
586
|
type: "build-status",
|
|
532
587
|
data: { generation, phase: "pages", ok: false, files, message: `Boot build failed: ${message}` },
|
|
533
588
|
});
|
|
534
589
|
};
|
|
535
590
|
sendFailure([], firstMessage);
|
|
536
|
-
|
|
591
|
+
BuilderChannel.emit({ type: "builder-ready" });
|
|
537
592
|
return new Promise((resolve, reject) => {
|
|
538
593
|
void (async () => {
|
|
539
594
|
const roots = await new WatchRootResolver(app).resolve();
|
|
@@ -546,7 +601,7 @@ class IncrementalBuilder {
|
|
|
546
601
|
try {
|
|
547
602
|
// A broken akan.config.ts caches its import failure; re-import it before rebuilding.
|
|
548
603
|
if (new Set(batch.kinds).has("config")) await app.getConfig({ refresh: true });
|
|
549
|
-
const deps = await IncrementalBuilder.#buildBootDeps(app);
|
|
604
|
+
const deps = await IncrementalBuilder.#buildBootDeps(app, runner);
|
|
550
605
|
const builder = new IncrementalBuilder({ app, watch: true, initialGeneration: generation, ...deps });
|
|
551
606
|
watcher.stop();
|
|
552
607
|
logger.info(`boot build recovered generation=${generation}`);
|
|
@@ -558,7 +613,7 @@ class IncrementalBuilder {
|
|
|
558
613
|
}
|
|
559
614
|
},
|
|
560
615
|
});
|
|
561
|
-
watcher.start();
|
|
616
|
+
await watcher.start();
|
|
562
617
|
logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
|
|
563
618
|
})().catch(reject);
|
|
564
619
|
});
|
|
@@ -589,39 +644,43 @@ class IncrementalBuilder {
|
|
|
589
644
|
if (msg.type === "build-route") {
|
|
590
645
|
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
591
646
|
if (!builder || builder.shuttingDown) {
|
|
592
|
-
|
|
647
|
+
BuilderChannel.emit({ type: "build-route-res", id: msg.id, ok: false, error });
|
|
593
648
|
return;
|
|
594
649
|
}
|
|
595
|
-
void builder.handleBuildRoute(msg)
|
|
650
|
+
void builder.handleBuildRoute(msg);
|
|
596
651
|
return;
|
|
597
652
|
}
|
|
598
653
|
if (msg.type === "build-csr") {
|
|
599
654
|
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
600
655
|
if (!builder || builder.shuttingDown) {
|
|
601
|
-
|
|
656
|
+
BuilderChannel.emit({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
602
657
|
return;
|
|
603
658
|
}
|
|
604
|
-
void builder.handleBuildCsr(msg)
|
|
659
|
+
void builder.handleBuildCsr(msg);
|
|
605
660
|
}
|
|
606
661
|
});
|
|
607
662
|
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
608
|
-
// as an orphaned watcher that keeps rebuilding for nobody.
|
|
663
|
+
// as an orphaned watcher that keeps rebuilding for nobody. Nothing is drained here on purpose —
|
|
664
|
+
// there is no longer anyone on the other end to flush to.
|
|
609
665
|
process.on("disconnect", () => {
|
|
610
666
|
logger.warn("host IPC channel closed; exiting builder");
|
|
611
667
|
process.exit(0);
|
|
612
668
|
});
|
|
613
669
|
let recoveredFiles: string[] | null = null;
|
|
670
|
+
// Owned by `main` rather than the instance: the boot build has to run before an instance exists, and
|
|
671
|
+
// a degraded boot re-runs it once per file change until it succeeds.
|
|
672
|
+
const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
|
|
614
673
|
try {
|
|
615
|
-
builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app)) });
|
|
674
|
+
builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app, bootRunner)) });
|
|
616
675
|
} catch (err) {
|
|
617
676
|
if (!watch) throw err;
|
|
618
|
-
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger);
|
|
677
|
+
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
|
|
619
678
|
builder = recovered.builder;
|
|
620
679
|
recoveredFiles = recovered.changedFiles;
|
|
621
680
|
}
|
|
622
681
|
await builder.boot();
|
|
623
682
|
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
624
|
-
else if (process.env.
|
|
683
|
+
else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
|
|
625
684
|
await builder.rearmCsrFromEnv();
|
|
626
685
|
}
|
|
627
686
|
}
|