@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.25
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 +37 -22
- package/akanApp/akanApp.host.ts +34 -1
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +25 -0
- package/akanApp/devHostPolicy.ts +27 -2
- package/cloud/globalConfig.ts +3 -17
- package/csrE2e/csrE2eHarness.fixture.ts +66 -11
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/fileSys.ts +16 -1
- package/frontendBuild/buildRouteClient.test.ts +61 -0
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
- package/frontendBuild/clientEntryDiscovery.ts +10 -5
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +158 -24
- package/frontendBuild/csrDevBundler.ts +44 -205
- package/frontendBuild/csrDevModuleCompiler.ts +51 -9
- package/frontendBuild/csrDevPatcher.test.ts +340 -0
- package/frontendBuild/csrDevPatcher.ts +374 -0
- package/frontendBuild/csrDevPaths.ts +60 -0
- package/frontendBuild/csrDevResolver.test.ts +165 -0
- package/frontendBuild/csrDevResolver.ts +70 -18
- package/frontendBuild/csrDevTypes.ts +14 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +177 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +79 -18
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/ssrDevBundler.ts +91 -0
- package/incrementalBuilder/buildBatch.proc.ts +94 -16
- package/incrementalBuilder/buildBatchProtocol.ts +6 -3
- package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
- package/incrementalBuilder/buildBatchRunner.ts +17 -8
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +32 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
- package/integration/devStability.integration.test.ts +18 -7
- package/package.json +2 -2
- package/transforms/rscUseClientTransform.ts +7 -0
- package/transforms/transforms.test.ts +13 -0
- package/transforms/useClientBundlePlugin.ts +4 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
// Module paths, never a barrel: the devkit and `frontendBuild` barrels would hold tailwindcss (~40MB), ssh2, ink
|
|
3
4
|
// and the cloud stack for the whole dev session (`entryModuleGraph.test.ts` enforces it).
|
|
@@ -8,10 +9,14 @@ import { AppExecutor, type PageRoot, WorkspaceExecutor } from "@akanjs/devkit/ex
|
|
|
8
9
|
import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
|
|
9
10
|
import type { ClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientBuildTypes";
|
|
10
11
|
import { GraphClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientEntryDiscovery";
|
|
12
|
+
import { CsrDevBundler } from "@akanjs/devkit/frontendBuild/csrDevBundler";
|
|
13
|
+
import { CsrDevPatcher } from "@akanjs/devkit/frontendBuild/csrDevPatcher";
|
|
11
14
|
import { DevChangePlanner } from "@akanjs/devkit/frontendBuild/devChangePlanner";
|
|
12
15
|
import { DevGeneratedIndexSync } from "@akanjs/devkit/frontendBuild/devGeneratedIndexSync";
|
|
13
16
|
import { HmrWatcher } from "@akanjs/devkit/frontendBuild/hmrWatcher";
|
|
14
17
|
import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuilder";
|
|
18
|
+
import { ServerGraphFile } from "@akanjs/devkit/frontendBuild/serverGraphFile";
|
|
19
|
+
import { SsrDevBundler } from "@akanjs/devkit/frontendBuild/ssrDevBundler";
|
|
15
20
|
import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
|
|
16
21
|
import { Logger } from "akanjs/common";
|
|
17
22
|
import type {
|
|
@@ -23,10 +28,13 @@ import type {
|
|
|
23
28
|
BuildPhase,
|
|
24
29
|
BuildRouteResultPayload,
|
|
25
30
|
ChangeBatch,
|
|
31
|
+
HmrTrace,
|
|
26
32
|
} from "akanjs/server";
|
|
33
|
+
import { CSR_DEV_PATCHING_MARKER } from "akanjs/server/hmr/csrDevManifest";
|
|
27
34
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
28
35
|
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
29
36
|
import { BuilderChannel } from "./builderChannel";
|
|
37
|
+
import { type BatchJob, BuilderWorkQueue, type DiscoveryJob } from "./builderWorkQueue";
|
|
30
38
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
31
39
|
|
|
32
40
|
interface IncrementalBuilderOptions {
|
|
@@ -39,7 +47,7 @@ interface IncrementalBuilderOptions {
|
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
type IncrementalBuilderBootDeps = Pick<IncrementalBuilderOptions, "artifact" | "optimizedFonts" | "discovery">;
|
|
42
|
-
type BatchWork = Pick<BuildBatchRequest, "generation" | "needs" | "changedFiles">;
|
|
50
|
+
type BatchWork = Pick<BuildBatchRequest, "generation" | "needs" | "changedFiles" | "trace">;
|
|
43
51
|
|
|
44
52
|
class IncrementalBuilder {
|
|
45
53
|
#logger = new Logger("IncrementalBuilder");
|
|
@@ -55,8 +63,43 @@ class IncrementalBuilder {
|
|
|
55
63
|
#autoImportSync: AutoImportSync;
|
|
56
64
|
#watcher: HmrWatcher | null = null;
|
|
57
65
|
#generation = 0;
|
|
66
|
+
#discoveredThrough = 0;
|
|
58
67
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
59
|
-
#
|
|
68
|
+
#csrBundler: CsrDevBundler;
|
|
69
|
+
/** Null when `AKAN_DEV_CSR_PATCHER=off`: every CSR save then goes to a build worker, as before the patcher. */
|
|
70
|
+
#patcher: CsrDevPatcher | null;
|
|
71
|
+
/** A worker's full CSR build (arming, re-arming) that a patch must not race for the csr-dev directory. */
|
|
72
|
+
#csrGate: Promise<void> = Promise.resolve();
|
|
73
|
+
#csrArming = false;
|
|
74
|
+
#csrArms = 0;
|
|
75
|
+
#crashRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
readonly #crashRetryEntries = new Set<string>();
|
|
77
|
+
#csrWasActive = false;
|
|
78
|
+
#csrArmSucceeded = false;
|
|
79
|
+
/** Where SSR pages load their client code from in dev; route builds bundle only its server half. */
|
|
80
|
+
#ssrBundler: SsrDevBundler;
|
|
81
|
+
/** Null when `AKAN_DEV_CSR_PATCHER=off`: every registry save then goes to a build worker. */
|
|
82
|
+
#ssrPatcher: CsrDevPatcher | null;
|
|
83
|
+
//* Serializes everything that writes ssr-dev: a save's patch (fast lane), a route build adding the entries it names,
|
|
84
|
+
//* a queued worker batch with the ssr need, and the boot build. Never held while awaiting the slow lane.
|
|
85
|
+
#ssrLock: Promise<void> = Promise.resolve();
|
|
86
|
+
/** How often each reason sent a registry save to a build worker this session: the resident patcher's miss rate. */
|
|
87
|
+
readonly #delegations = new Map<string, number>();
|
|
88
|
+
/** The boot build of the SSR registry while it runs; it holds every entry the routes reach once it lands. */
|
|
89
|
+
#ssrArming: Promise<void> | null = null;
|
|
90
|
+
//* A registry build that failed on the user's code fails again until the code changes, so route builds do not retry
|
|
91
|
+
//* it; the next attempt that touches the registry (a save's patch, a worker batch) settles it. A worker that died
|
|
92
|
+
//* before reporting (an OOM kill) route builds do retry, at most every `#crashRetryMs`.
|
|
93
|
+
#ssrBroken = false;
|
|
94
|
+
#ssrCrashedAt: number | null = null;
|
|
95
|
+
static readonly #crashRetryMs = 10_000;
|
|
96
|
+
/** Arming and the route entries deferred behind it run beside both lanes; a recycle waits for them too. */
|
|
97
|
+
readonly #beside = new Set<Promise<void>>();
|
|
98
|
+
#barrelFailed = false;
|
|
99
|
+
//* Two lanes: a save's codegen and CSR patch in the fast one, which the watcher waits for; build workers, route
|
|
100
|
+
//* builds and discovery in the slow one, which folds queued batches. A save no longer waits behind pages and css.
|
|
101
|
+
#fastQueue: Promise<void> = Promise.resolve();
|
|
102
|
+
#workQueue: BuilderWorkQueue;
|
|
60
103
|
#inFlight = 0;
|
|
61
104
|
#workCount = 0;
|
|
62
105
|
#shuttingDown = false;
|
|
@@ -70,6 +113,7 @@ class IncrementalBuilder {
|
|
|
70
113
|
this.#optimizedFonts = options.optimizedFonts;
|
|
71
114
|
this.#discovery = options.discovery;
|
|
72
115
|
this.#generation = options.initialGeneration ?? 0;
|
|
116
|
+
this.#discoveredThrough = this.#generation;
|
|
73
117
|
this.#batchRunner = new BuildBatchRunner({
|
|
74
118
|
workspaceRoot: options.app.workspace.workspaceRoot,
|
|
75
119
|
cwd: options.app.cwdPath,
|
|
@@ -77,6 +121,20 @@ class IncrementalBuilder {
|
|
|
77
121
|
this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
78
122
|
this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
79
123
|
this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
124
|
+
this.#csrBundler = new CsrDevBundler(options.app);
|
|
125
|
+
this.#patcher =
|
|
126
|
+
process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#csrBundler, { resident: true });
|
|
127
|
+
this.#ssrBundler = new SsrDevBundler(options.app, { discovery: () => this.#discovery });
|
|
128
|
+
this.#ssrPatcher =
|
|
129
|
+
process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#ssrBundler, { resident: true });
|
|
130
|
+
this.#workQueue = new BuilderWorkQueue({
|
|
131
|
+
runBatch: async (batch) => await this.#runQueuedBatch(batch),
|
|
132
|
+
onSettled: (label, ms) => {
|
|
133
|
+
this.#workCount += 1;
|
|
134
|
+
this.#logger.verbose(`[work-queue] ${label} finished in ${ms}ms`);
|
|
135
|
+
this.#reportMetrics();
|
|
136
|
+
},
|
|
137
|
+
});
|
|
80
138
|
}
|
|
81
139
|
|
|
82
140
|
get #artifactDir() {
|
|
@@ -85,12 +143,13 @@ class IncrementalBuilder {
|
|
|
85
143
|
|
|
86
144
|
// The reply is part of the work item on purpose: `shutdown` drains the queue, so "drained" must mean "answered".
|
|
87
145
|
async handleBuildRoute(msg: BuilderReq): Promise<void> {
|
|
88
|
-
await this.#
|
|
146
|
+
await this.#workQueue.enqueue(`build-route:${msg.routeId}`, async () =>
|
|
89
147
|
BuilderChannel.send(await this.#handleBuildRoute(msg)),
|
|
90
148
|
);
|
|
91
149
|
}
|
|
92
150
|
|
|
93
151
|
async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
|
|
152
|
+
const seenGeneration = this.#discoveredThrough;
|
|
94
153
|
try {
|
|
95
154
|
const delta = await new RouteClientBuilder({
|
|
96
155
|
app: this.#app,
|
|
@@ -100,9 +159,13 @@ class IncrementalBuilder {
|
|
|
100
159
|
artifact: this.#artifact,
|
|
101
160
|
knownEntries: new Set<string>(msg.knownEntries),
|
|
102
161
|
discovery: this.#discovery,
|
|
162
|
+
browser: "registry",
|
|
103
163
|
}).build();
|
|
164
|
+
await this.#ensureSsrEntries(delta.registryEntries ?? []);
|
|
104
165
|
this.#logger.verbose(`build-route ok routeId=${msg.routeId} newEntries=${delta.newEntries.length}`);
|
|
105
|
-
|
|
166
|
+
//? The builder's generation, not the route cache's: the cache's does not move when a fixed file maps to no entry,
|
|
167
|
+
//? so the rebuild's ok would carry the failure's own number.
|
|
168
|
+
this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds });
|
|
106
169
|
return {
|
|
107
170
|
type: "build-route-res",
|
|
108
171
|
id: msg.id,
|
|
@@ -116,12 +179,13 @@ class IncrementalBuilder {
|
|
|
116
179
|
clientDepsByEntry: delta.clientDepsByEntry,
|
|
117
180
|
routeId: msg.routeId,
|
|
118
181
|
generation: msg.generation,
|
|
182
|
+
seenGeneration,
|
|
119
183
|
} as BuildRouteResultPayload,
|
|
120
184
|
};
|
|
121
185
|
} catch (err) {
|
|
122
186
|
const errMsg = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
123
187
|
this.#logger.error(`build-route failed routeId=${msg.routeId}: ${errMsg}`);
|
|
124
|
-
this.#sendBuildStatus("route", { generation:
|
|
188
|
+
this.#sendBuildStatus("route", { generation: this.#generation, ok: false, files: msg.seeds, message: errMsg });
|
|
125
189
|
return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
|
|
126
190
|
}
|
|
127
191
|
}
|
|
@@ -141,23 +205,35 @@ class IncrementalBuilder {
|
|
|
141
205
|
},
|
|
142
206
|
});
|
|
143
207
|
}
|
|
144
|
-
async #
|
|
208
|
+
async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
145
209
|
const started = Date.now();
|
|
146
210
|
this.#inFlight += 1;
|
|
147
|
-
const run = this.#
|
|
148
|
-
this.#
|
|
211
|
+
const run = this.#fastQueue.then(fn, fn);
|
|
212
|
+
this.#fastQueue = run.then(() => undefined).catch(() => undefined);
|
|
149
213
|
try {
|
|
150
214
|
return await run;
|
|
151
215
|
} finally {
|
|
152
216
|
this.#inFlight -= 1;
|
|
153
217
|
this.#workCount += 1;
|
|
154
|
-
this.#logger.verbose(`[
|
|
218
|
+
this.#logger.verbose(`[fast-queue] ${label} finished in ${Date.now() - started}ms`);
|
|
155
219
|
this.#reportMetrics();
|
|
156
220
|
}
|
|
157
221
|
}
|
|
158
222
|
|
|
159
223
|
get #idle(): boolean {
|
|
160
|
-
return this.#inFlight === 0 && this.#cssRebuildTimer === null;
|
|
224
|
+
return this.#inFlight === 0 && this.#workQueue.size === 0 && this.#cssRebuildTimer === null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Callers catch their own errors: the promise handed in never rejects.
|
|
228
|
+
#runBeside(work: Promise<void>): Promise<void> {
|
|
229
|
+
this.#inFlight += 1;
|
|
230
|
+
const tracked: Promise<void> = work.finally(() => {
|
|
231
|
+
this.#inFlight -= 1;
|
|
232
|
+
this.#beside.delete(tracked);
|
|
233
|
+
this.#reportMetrics();
|
|
234
|
+
});
|
|
235
|
+
this.#beside.add(tracked);
|
|
236
|
+
return tracked;
|
|
161
237
|
}
|
|
162
238
|
|
|
163
239
|
// Idle only: the host recycles on these metrics, and a recycle decided mid-work would truncate it or race the drain.
|
|
@@ -184,8 +260,15 @@ class IncrementalBuilder {
|
|
|
184
260
|
this.#cssRebuildTimer = null;
|
|
185
261
|
this.#pendingCssRebuild = null;
|
|
186
262
|
}
|
|
187
|
-
|
|
263
|
+
// The fast lane first: a save it is still handling may queue a batch in the slow one.
|
|
264
|
+
await this.#fastQueue.catch(() => undefined);
|
|
265
|
+
await this.#workQueue.drain();
|
|
188
266
|
await this.#cssRebuildQueue.catch(() => undefined);
|
|
267
|
+
while (this.#beside.size > 0) await Promise.all([...this.#beside]);
|
|
268
|
+
if (this.#delegations.size > 0)
|
|
269
|
+
this.#logger.verbose(
|
|
270
|
+
`registry saves handed to build workers this session: ${[...this.#delegations].map(([reason, count]) => `${reason}=${count}`).join(", ")}`,
|
|
271
|
+
);
|
|
189
272
|
// Drained queues are not delivered results: `process.exit` drops unflushed ipc writes (a relayed `css-updated`).
|
|
190
273
|
const flushed = await BuilderChannel.drain();
|
|
191
274
|
this.#logger.debug(
|
|
@@ -264,7 +347,7 @@ class IncrementalBuilder {
|
|
|
264
347
|
roots,
|
|
265
348
|
logger: this.#logger,
|
|
266
349
|
onBatch: async (batch: ChangeBatch) => {
|
|
267
|
-
await this.#
|
|
350
|
+
await this.#enqueueFast("hmr-batch", async () => this.#handleWatchBatch(batch));
|
|
268
351
|
},
|
|
269
352
|
});
|
|
270
353
|
await watcher.start();
|
|
@@ -276,6 +359,7 @@ class IncrementalBuilder {
|
|
|
276
359
|
const rawKinds = new Set(batch.kinds);
|
|
277
360
|
if (rawKinds.size === 0) return;
|
|
278
361
|
const generation = ++this.#generation;
|
|
362
|
+
const trace = { ...batch.trace, batchAt: Date.now() };
|
|
279
363
|
//* Auto-import edits touch only this batch's files, so they rebuild in this same generation.
|
|
280
364
|
const [autoImport, indexSync] = await CodegenLock.run(
|
|
281
365
|
this.#app.workspace.workspaceRoot,
|
|
@@ -301,21 +385,24 @@ class IncrementalBuilder {
|
|
|
301
385
|
);
|
|
302
386
|
for (const error of indexSync.errors) this.#logger.error(error);
|
|
303
387
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
else this.#discovery.invalidate?.(files);
|
|
308
|
-
this.#logger.verbose(
|
|
309
|
-
`client-entry-discovery ${kinds.includes("config") ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`,
|
|
310
|
-
);
|
|
311
|
-
}
|
|
388
|
+
// Invalidated in the slow lane, under the route builds that read it; a registry check reading it beside them caches
|
|
389
|
+
// nothing a walk began before the invalidation (the discovery's epoch).
|
|
390
|
+
const discovery = kinds.includes("code") ? { files, refresh: kinds.includes("config"), generation } : undefined;
|
|
312
391
|
|
|
313
392
|
if (hasSyncErrors) {
|
|
393
|
+
this.#barrelFailed = true;
|
|
314
394
|
this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
|
|
315
395
|
BuilderChannel.emit(event);
|
|
396
|
+
if (discovery)
|
|
397
|
+
void this.#workQueue
|
|
398
|
+
.enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
|
|
399
|
+
.catch(this.#slowLaneFailed("discovery"));
|
|
316
400
|
return;
|
|
317
401
|
}
|
|
318
|
-
|
|
402
|
+
// After a failure too, where nothing changed on disk: the host re-sends a phase still failing to every new tab.
|
|
403
|
+
if (indexSync.changedFiles.length > 0 || this.#barrelFailed)
|
|
404
|
+
this.#sendBuildStatus("barrel", { generation, ok: true, files });
|
|
405
|
+
this.#barrelFailed = false;
|
|
319
406
|
|
|
320
407
|
// Server-only generations skip the client: a fresh pages buildId would rsc-refresh browsers for no visible change.
|
|
321
408
|
const rebuildClient = devPlan.actions.includes("rebuild-client");
|
|
@@ -332,47 +419,364 @@ class IncrementalBuilder {
|
|
|
332
419
|
this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
|
|
333
420
|
}
|
|
334
421
|
|
|
335
|
-
|
|
422
|
+
BuilderChannel.emit(event);
|
|
423
|
+
|
|
336
424
|
if (kinds.includes("code") && rebuildClient) {
|
|
337
|
-
|
|
338
|
-
|
|
425
|
+
// Css rides the pages batch rather than its own debounce: the slow lane folds a burst of saves into one batch.
|
|
426
|
+
const needs: BuildBatchNeed[] = ["pages", "css"];
|
|
427
|
+
if (!this.#csrActive)
|
|
339
428
|
this.#logger.verbose(
|
|
340
429
|
`csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
|
|
341
430
|
);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
431
|
+
//? One after the other, since two compiles side by side slow both. CSR goes first unless its build is being armed:
|
|
432
|
+
//? a CSR patch then waits behind #csrGate, which SSR tabs must not.
|
|
433
|
+
const patchCsr = async () => (this.#csrActive ? await this.#patchCsr(generation, files, trace) : false);
|
|
434
|
+
const patchSsr = async () => await this.#patchSsr(generation, files, trace);
|
|
435
|
+
let csrToWorker: boolean;
|
|
436
|
+
let ssrToWorker: boolean;
|
|
437
|
+
if (this.#csrArming) {
|
|
438
|
+
ssrToWorker = await patchSsr();
|
|
439
|
+
csrToWorker = await patchCsr();
|
|
440
|
+
} else {
|
|
441
|
+
csrToWorker = await patchCsr();
|
|
442
|
+
ssrToWorker = await patchSsr();
|
|
443
|
+
}
|
|
444
|
+
if (csrToWorker) needs.unshift("csr");
|
|
445
|
+
if (ssrToWorker) needs.unshift("ssr");
|
|
446
|
+
const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
|
|
447
|
+
// A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
|
|
448
|
+
if (needs.includes("csr") || needs.includes("ssr")) {
|
|
449
|
+
await this.#workQueue.enqueueBatch(batch);
|
|
450
|
+
if (needs.includes("csr")) this.#patcher?.forget();
|
|
451
|
+
if (needs.includes("ssr")) this.#ssrPatcher?.forget();
|
|
452
|
+
} else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
|
|
453
|
+
return;
|
|
346
454
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
455
|
+
if (discovery)
|
|
456
|
+
void this.#workQueue
|
|
457
|
+
.enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
|
|
458
|
+
.catch(this.#slowLaneFailed("discovery"));
|
|
351
459
|
// Css-only batches keep the debounce: they arrive in bursts while a stylesheet is edited.
|
|
352
|
-
|
|
460
|
+
if (kinds.includes("css")) {
|
|
353
461
|
this.scheduleCssRebuild({ generation, changedFiles: files });
|
|
354
462
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
355
463
|
}
|
|
356
464
|
}
|
|
357
465
|
|
|
466
|
+
// True when the save has to go to a build worker: the patcher handed it back, or threw (the worker reports why).
|
|
467
|
+
async #patchCsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
|
|
468
|
+
const patcher = this.#patcher;
|
|
469
|
+
if (!patcher) return true;
|
|
470
|
+
await this.#csrGate;
|
|
471
|
+
// The arming build this save waited for failed: CSR is off again, and the next request arms it anew.
|
|
472
|
+
if (!this.#csrActive) return false;
|
|
473
|
+
const started = Date.now();
|
|
474
|
+
const marker = path.join(this.#csrBundler.outDir, CSR_DEV_PATCHING_MARKER);
|
|
475
|
+
await Bun.write(marker, String(process.pid));
|
|
476
|
+
try {
|
|
477
|
+
const result = await patcher.update(files, {
|
|
478
|
+
announce: (update) => {
|
|
479
|
+
const now = Date.now();
|
|
480
|
+
BuilderChannel.emit({
|
|
481
|
+
type: "csr-updated",
|
|
482
|
+
data: {
|
|
483
|
+
generation: update.generation,
|
|
484
|
+
mode: "registry",
|
|
485
|
+
reload: update.reload,
|
|
486
|
+
reason: update.reason,
|
|
487
|
+
patchUrl: update.patchUrl,
|
|
488
|
+
changedIds: update.changedIds,
|
|
489
|
+
trace: { ...trace, patchAt: now, sentAt: now },
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
if (result.kind === "delegate") {
|
|
495
|
+
this.#logger.verbose(
|
|
496
|
+
`csr-patch handed to a build worker (${this.#countDelegation("csr", result.reason)}): ${result.reason}`,
|
|
497
|
+
);
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
this.#sendBuildStatus("csr", { generation, ok: true, files });
|
|
501
|
+
this.#logger.verbose(
|
|
502
|
+
result.kind === "unchanged"
|
|
503
|
+
? `csr-patch unchanged (${Date.now() - started}ms)`
|
|
504
|
+
: `csr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
|
|
505
|
+
);
|
|
506
|
+
return false;
|
|
507
|
+
} catch (err) {
|
|
508
|
+
const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
509
|
+
if (IncrementalBuilder.#isCompileError(err)) {
|
|
510
|
+
this.#sendBuildStatus("csr", { generation, ok: false, files, message });
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
this.#logger.verbose(`csr-patch threw; a build worker takes this save: ${message}`);
|
|
514
|
+
patcher.forget();
|
|
515
|
+
return true;
|
|
516
|
+
} finally {
|
|
517
|
+
await rm(marker, { force: true });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// True when the save has to go to a build worker, as for CSR.
|
|
522
|
+
async #patchSsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
|
|
523
|
+
const patcher = this.#ssrPatcher;
|
|
524
|
+
if (!patcher) return true;
|
|
525
|
+
const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
|
|
526
|
+
ServerGraphFile.clientExportsOf(file),
|
|
527
|
+
);
|
|
528
|
+
return await this.#withSsrLock(
|
|
529
|
+
async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
//* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
|
|
534
|
+
//* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
|
|
535
|
+
async #ensureSsrEntries(entries: string[]): Promise<void> {
|
|
536
|
+
if (entries.length === 0) return;
|
|
537
|
+
//? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
|
|
538
|
+
//? registry (boot.json) instead; whatever that build missed is added right after it lands.
|
|
539
|
+
const arming = this.#ssrArming;
|
|
540
|
+
if (arming) {
|
|
541
|
+
void this.#runBeside(
|
|
542
|
+
arming.then(async () => await this.#ensureSsrEntries(entries)).catch(this.#slowLaneFailed("ssr-ensure")),
|
|
543
|
+
);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
await this.#withSsrLock(async () => {
|
|
547
|
+
const patcher = this.#ssrPatcher;
|
|
548
|
+
// The builder's generation, not the request's: its build-status joins the saves' `ssr` ones.
|
|
549
|
+
const generation = this.#generation;
|
|
550
|
+
if (
|
|
551
|
+
patcher &&
|
|
552
|
+
!(await this.#runSsrPatcher(patcher, [], { roots: entries, onlyRoots: true, batchGeneration: generation }))
|
|
553
|
+
)
|
|
554
|
+
return;
|
|
555
|
+
//? Read, not compiled: with the patcher off, the save's own worker batch already built every entry.
|
|
556
|
+
if (!patcher && (await this.#ssrBundler.holds(entries))) return;
|
|
557
|
+
//? Not retried per route build: the save that fixes the error rebuilds it, where each route build retrying held
|
|
558
|
+
//? the SSR lock that save's patch waits on.
|
|
559
|
+
if (this.#ssrBroken) return;
|
|
560
|
+
if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
|
|
561
|
+
this.#retryAfterCrash(this.#ssrCrashedAt, entries);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
await this.#runSsrWorker(generation).catch((err: unknown) => {
|
|
565
|
+
this.#logger.error(
|
|
566
|
+
`ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
//? Once the crash window closes, for the entries skipped in it: later route builds may name none (the first one
|
|
573
|
+
//? announced every entry to the route cache), so without this a tab could wait on boot.json until the next save.
|
|
574
|
+
#retryAfterCrash(crashedAt: number, entries: string[]): void {
|
|
575
|
+
for (const entry of entries) this.#crashRetryEntries.add(entry);
|
|
576
|
+
if (this.#crashRetryTimer) return;
|
|
577
|
+
const timer = setTimeout(
|
|
578
|
+
() => {
|
|
579
|
+
this.#crashRetryTimer = null;
|
|
580
|
+
const retried = [...this.#crashRetryEntries];
|
|
581
|
+
this.#crashRetryEntries.clear();
|
|
582
|
+
if (this.shuttingDown || retried.length === 0) return;
|
|
583
|
+
void this.#runBeside(this.#ensureSsrEntries(retried)).catch(this.#slowLaneFailed("ssr-crash-retry"));
|
|
584
|
+
},
|
|
585
|
+
Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
|
|
586
|
+
);
|
|
587
|
+
timer.unref?.();
|
|
588
|
+
this.#crashRetryTimer = timer;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
//* The boot build of the SSR registry runs beside the slow lane, holding only the SSR lock: the first page's route
|
|
592
|
+
//* build bundles meanwhile instead of queueing behind it (on apps/akan it cost the first page about a second).
|
|
593
|
+
async armSsrRegistry(): Promise<void> {
|
|
594
|
+
//? `AKAN_DEV_SSR_ARM_DELAY_MS` is a test hook: an E2E has a tab reconnect before the boot build has run.
|
|
595
|
+
const delayMs = Number(process.env.AKAN_DEV_SSR_ARM_DELAY_MS);
|
|
596
|
+
if (Number.isInteger(delayMs) && delayMs > 0) await Bun.sleep(delayMs);
|
|
597
|
+
const arm = async () =>
|
|
598
|
+
await this.#withSsrLock(async () => {
|
|
599
|
+
const generation = this.#generation;
|
|
600
|
+
if (this.#ssrPatcher && !(await this.#runSsrPatcher(this.#ssrPatcher, [], { batchGeneration: generation })))
|
|
601
|
+
return;
|
|
602
|
+
await this.#runSsrWorker(generation);
|
|
603
|
+
}).catch((err: unknown) => {
|
|
604
|
+
this.#logger.error(
|
|
605
|
+
`ssr-registry boot build failed; the next save retries it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
606
|
+
);
|
|
607
|
+
});
|
|
608
|
+
const arming = this.#runBeside(
|
|
609
|
+
(async () => {
|
|
610
|
+
await arm();
|
|
611
|
+
//? A boot build whose worker was killed (a small container's OOM) is tried once more before `boot-armed` lets
|
|
612
|
+
//? the next app boot: retried after that, the two biggest workers of a boot would run side by side.
|
|
613
|
+
const crashedAt = this.#ssrCrashedAt;
|
|
614
|
+
if (crashedAt === null) return;
|
|
615
|
+
while (!this.shuttingDown && Date.now() < crashedAt + IncrementalBuilder.#crashRetryMs) await Bun.sleep(250);
|
|
616
|
+
if (!this.shuttingDown) await arm();
|
|
617
|
+
})(),
|
|
618
|
+
);
|
|
619
|
+
this.#ssrArming = arming;
|
|
620
|
+
await arming;
|
|
621
|
+
this.#ssrArming = null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
//? A save the user broke fails the same way in a worker, so it is reported here and the patcher keeps its state: the
|
|
625
|
+
//? modules it could not compile stay pending in the graph and the fixing save compiles them.
|
|
626
|
+
static #isCompileError(error: unknown): boolean {
|
|
627
|
+
return error instanceof AggregateError;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Voided so the fast lane need not wait; unhandled, a rejection would exit the builder and drop the watcher with it.
|
|
631
|
+
#slowLaneFailed(label: string) {
|
|
632
|
+
return (error: unknown) =>
|
|
633
|
+
this.#logger.error(
|
|
634
|
+
`${label} failed in the slow lane: ${ApplicationBuildReporter.formatError(error, this.#app.workspace.workspaceRoot)}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// A reason's own detail (which import) follows its first colon, so saves of one kind count together.
|
|
639
|
+
#countDelegation(registry: "csr" | "ssr", reason: string): string {
|
|
640
|
+
const key = `${registry}:${reason.split(":")[0]}`;
|
|
641
|
+
const count = (this.#delegations.get(key) ?? 0) + 1;
|
|
642
|
+
this.#delegations.set(key, count);
|
|
643
|
+
return `#${count} for this reason`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async #withSsrLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
647
|
+
const run = this.#ssrLock.then(fn, fn);
|
|
648
|
+
this.#ssrLock = run.then(
|
|
649
|
+
() => undefined,
|
|
650
|
+
() => undefined,
|
|
651
|
+
);
|
|
652
|
+
return await run;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
|
|
656
|
+
async #runSsrWorker(generation: number): Promise<void> {
|
|
657
|
+
const result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
|
|
658
|
+
this.#ssrPatcher?.forget();
|
|
659
|
+
this.#noteSsrWorker(result);
|
|
660
|
+
if (result.errors.ssr) throw new Error(result.errors.ssr);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
#noteSsrWorker(result: BuildBatchResult): void {
|
|
664
|
+
const crashed = !!result.crashed && (result.crashedNeeds ?? ["ssr"]).includes("ssr");
|
|
665
|
+
this.#ssrBroken = !!result.errors.ssr && !crashed;
|
|
666
|
+
this.#ssrCrashedAt = crashed ? Date.now() : null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async #runSsrPatcher(
|
|
670
|
+
patcher: CsrDevPatcher,
|
|
671
|
+
files: string[],
|
|
672
|
+
{
|
|
673
|
+
roots,
|
|
674
|
+
onlyRoots,
|
|
675
|
+
trace,
|
|
676
|
+
hold,
|
|
677
|
+
batchGeneration,
|
|
678
|
+
}: { roots?: string[]; onlyRoots?: boolean; trace?: HmrTrace; hold?: boolean; batchGeneration?: number } = {},
|
|
679
|
+
): Promise<boolean> {
|
|
680
|
+
const started = Date.now();
|
|
681
|
+
const marker = path.join(this.#ssrBundler.outDir, CSR_DEV_PATCHING_MARKER);
|
|
682
|
+
await Bun.write(marker, String(process.pid));
|
|
683
|
+
try {
|
|
684
|
+
const result = await patcher.update(files, {
|
|
685
|
+
roots,
|
|
686
|
+
onlyRoots,
|
|
687
|
+
announce: (update) => {
|
|
688
|
+
const now = Date.now();
|
|
689
|
+
BuilderChannel.emit({
|
|
690
|
+
type: "ssr-updated",
|
|
691
|
+
data: {
|
|
692
|
+
generation: update.generation,
|
|
693
|
+
reload: update.reload,
|
|
694
|
+
reason: update.reason,
|
|
695
|
+
patchUrl: update.patchUrl,
|
|
696
|
+
changedIds: update.changedIds,
|
|
697
|
+
...(trace ? { trace: { ...trace, patchAt: now, sentAt: now } } : {}),
|
|
698
|
+
...(hold ? { hold } : {}),
|
|
699
|
+
...(batchGeneration !== undefined ? { batchGeneration } : {}),
|
|
700
|
+
...(update.epoch !== undefined ? { epoch: update.epoch } : {}),
|
|
701
|
+
...(update.first ? { first: true } : {}),
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
if (result.kind === "delegate") {
|
|
707
|
+
this.#logger.verbose(
|
|
708
|
+
`ssr-patch handed to a build worker (${this.#countDelegation("ssr", result.reason)}): ${result.reason}`,
|
|
709
|
+
);
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
//? A route build's check reports only a failure: it compiles the roots the registry lacks and nothing else, so
|
|
713
|
+
//? its ok, of the builder's current generation, would read as the fix of a save that broke something else.
|
|
714
|
+
if (!onlyRoots) {
|
|
715
|
+
this.#ssrBroken = false;
|
|
716
|
+
this.#ssrCrashedAt = null;
|
|
717
|
+
this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: true, files });
|
|
718
|
+
}
|
|
719
|
+
this.#logger.verbose(
|
|
720
|
+
result.kind === "unchanged"
|
|
721
|
+
? `ssr-patch unchanged (${Date.now() - started}ms)`
|
|
722
|
+
: `ssr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
|
|
723
|
+
);
|
|
724
|
+
return false;
|
|
725
|
+
} catch (err) {
|
|
726
|
+
const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
727
|
+
if (IncrementalBuilder.#isCompileError(err)) {
|
|
728
|
+
this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: false, files, message });
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
this.#logger.verbose(`ssr-patch threw; a build worker takes it: ${message}`);
|
|
732
|
+
patcher.forget();
|
|
733
|
+
return true;
|
|
734
|
+
} finally {
|
|
735
|
+
await rm(marker, { force: true });
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async #runQueuedBatch({ discovery, ...work }: BatchJob): Promise<void> {
|
|
740
|
+
if (discovery) await this.#refreshDiscovery(discovery);
|
|
741
|
+
//? The slow lane runs in order, so every batch up to this one has had its discovery job.
|
|
742
|
+
this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
|
|
743
|
+
// A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
|
|
744
|
+
if (work.needs.includes("ssr"))
|
|
745
|
+
await this.#withSsrLock(async () => this.#noteSsrWorker(await this.#runBatch(work)));
|
|
746
|
+
else await this.#runBatch(work);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
|
|
750
|
+
const started = Date.now();
|
|
751
|
+
if (refresh) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
|
|
752
|
+
else this.#discovery.invalidate?.(files);
|
|
753
|
+
this.#discoveredThrough = Math.max(this.#discoveredThrough, generation);
|
|
754
|
+
this.#logger.verbose(`client-entry-discovery ${refresh ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`);
|
|
755
|
+
}
|
|
756
|
+
|
|
358
757
|
async #runBatch(work: BatchWork): Promise<BuildBatchResult> {
|
|
359
758
|
const { generation, needs, changedFiles } = work;
|
|
360
759
|
const started = Date.now();
|
|
361
760
|
const result = await this.#batchRunner.run(await this.#batchRequest(work), (msg) => BuilderChannel.emit(msg));
|
|
362
761
|
if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
|
|
363
762
|
if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
|
|
364
|
-
// A
|
|
763
|
+
// A need the worker died before reporting goes red here instead of looking silently successful.
|
|
365
764
|
if (result.crashed) {
|
|
366
765
|
// `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
|
|
367
|
-
for (const need of needs)
|
|
766
|
+
for (const need of result.crashedNeeds ?? needs)
|
|
368
767
|
if (need !== "base")
|
|
369
|
-
this.#sendBuildStatus(need, {
|
|
768
|
+
this.#sendBuildStatus(need, {
|
|
769
|
+
generation,
|
|
770
|
+
ok: false,
|
|
771
|
+
files: changedFiles,
|
|
772
|
+
message: `${result.errors[need] ?? "the build worker crashed"}; the next save builds it again`,
|
|
773
|
+
});
|
|
370
774
|
}
|
|
371
775
|
if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
372
776
|
return result;
|
|
373
777
|
}
|
|
374
778
|
|
|
375
|
-
async #batchRequest({ generation, needs, changedFiles }: BatchWork): Promise<BuildBatchRequest> {
|
|
779
|
+
async #batchRequest({ generation, needs, changedFiles, trace }: BatchWork): Promise<BuildBatchRequest> {
|
|
376
780
|
return {
|
|
377
781
|
appName: this.#app.name,
|
|
378
782
|
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
@@ -384,6 +788,7 @@ class IncrementalBuilder {
|
|
|
384
788
|
optimizedFonts: this.#optimizedFonts,
|
|
385
789
|
cssAssets: this.#artifact.cssAssets ?? null,
|
|
386
790
|
artifactDir: path.resolve(this.#artifactDir),
|
|
791
|
+
...(trace ? { trace } : {}),
|
|
387
792
|
};
|
|
388
793
|
}
|
|
389
794
|
|
|
@@ -396,7 +801,7 @@ class IncrementalBuilder {
|
|
|
396
801
|
// The backend still serves the last-good bundle after a degraded boot; push fresh pages/css without another edit.
|
|
397
802
|
async announceRecoveredState(changedFiles: string[]): Promise<void> {
|
|
398
803
|
const generation = ++this.#generation;
|
|
399
|
-
await this.#
|
|
804
|
+
await this.#workQueue.enqueue("boot-recovered", async () => {
|
|
400
805
|
await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
|
|
401
806
|
});
|
|
402
807
|
}
|
|
@@ -435,22 +840,55 @@ class IncrementalBuilder {
|
|
|
435
840
|
|
|
436
841
|
// On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
|
|
437
842
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
438
|
-
|
|
843
|
+
//? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
|
|
844
|
+
this.#beginCsrArm();
|
|
845
|
+
this.#csrActive = true;
|
|
846
|
+
const armed = this.#workQueue.enqueue("build-csr", async (): Promise<void> => {
|
|
439
847
|
const started = Date.now();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
848
|
+
let error: string | undefined;
|
|
849
|
+
try {
|
|
850
|
+
// Not relayed: an on-demand CSR build is request/response, and its error travels in the response.
|
|
851
|
+
const result = await this.#batchRunner.run(
|
|
852
|
+
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
853
|
+
);
|
|
854
|
+
error = result.errors.csr;
|
|
855
|
+
} catch (err) {
|
|
856
|
+
error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
857
|
+
}
|
|
445
858
|
if (error) {
|
|
446
859
|
this.#logger.error(`csr-build failed: ${error}`);
|
|
447
860
|
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
448
861
|
return;
|
|
449
862
|
}
|
|
450
|
-
this.#
|
|
863
|
+
this.#csrArmSucceeded = true;
|
|
864
|
+
this.#patcher?.forget();
|
|
451
865
|
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
452
866
|
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
|
|
453
867
|
});
|
|
868
|
+
this.#csrGate = this.#armingCsr(armed);
|
|
869
|
+
await armed;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
//? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
|
|
873
|
+
//? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
|
|
874
|
+
//? patch CSR first while the second still builds.
|
|
875
|
+
#beginCsrArm(): void {
|
|
876
|
+
if (this.#csrArms === 0) {
|
|
877
|
+
this.#csrWasActive = this.#csrActive;
|
|
878
|
+
this.#csrArmSucceeded = false;
|
|
879
|
+
}
|
|
880
|
+
this.#csrArms += 1;
|
|
881
|
+
this.#csrArming = true;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
#armingCsr(armed: Promise<void>): Promise<void> {
|
|
885
|
+
const settle = () => {
|
|
886
|
+
this.#csrArms -= 1;
|
|
887
|
+
if (this.#csrArms > 0) return;
|
|
888
|
+
this.#csrArming = false;
|
|
889
|
+
this.#csrActive = this.#csrWasActive || this.#csrArmSucceeded;
|
|
890
|
+
};
|
|
891
|
+
return armed.then(settle, settle);
|
|
454
892
|
}
|
|
455
893
|
|
|
456
894
|
static #csrArmedByEnv() {
|
|
@@ -482,14 +920,26 @@ class IncrementalBuilder {
|
|
|
482
920
|
// The env flag carries an armed CSR session across builder restarts, so the replacement rebuilds it after ready.
|
|
483
921
|
async rearmCsrFromEnv(): Promise<void> {
|
|
484
922
|
if (!IncrementalBuilder.#csrArmedByEnv()) return;
|
|
923
|
+
this.#beginCsrArm();
|
|
485
924
|
this.#csrActive = true;
|
|
486
|
-
|
|
925
|
+
//? The env keeps CSR armed whatever this build does, as it did before the builder restarted.
|
|
926
|
+
this.#csrArmSucceeded = true;
|
|
927
|
+
const rearmed = this.#workQueue.enqueue("build-csr-rearm", async () => {
|
|
928
|
+
//? Relayed, unlike an on-demand build: a rebuild for a metadata save must reach the CSR tabs as their reload.
|
|
487
929
|
const result = await this.#batchRunner.run(
|
|
488
930
|
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
931
|
+
(msg) => BuilderChannel.emit(msg),
|
|
489
932
|
);
|
|
933
|
+
this.#patcher?.forget();
|
|
490
934
|
if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
|
|
491
935
|
else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
|
|
492
936
|
});
|
|
937
|
+
this.#csrGate = this.#armingCsr(rearmed);
|
|
938
|
+
await rearmed.catch((err: unknown) =>
|
|
939
|
+
this.#logger.error(
|
|
940
|
+
`csr-rearm failed: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
941
|
+
),
|
|
942
|
+
);
|
|
493
943
|
}
|
|
494
944
|
|
|
495
945
|
// A boot compile error must not kill the builder, the dev server's only file watcher: report it, emit builder-ready
|
|
@@ -499,10 +949,11 @@ class IncrementalBuilder {
|
|
|
499
949
|
bootError: unknown,
|
|
500
950
|
logger: Logger,
|
|
501
951
|
runner: BuildBatchRunner,
|
|
952
|
+
initialGeneration: number,
|
|
502
953
|
): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
|
|
503
954
|
const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
|
|
504
955
|
logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
|
|
505
|
-
let generation =
|
|
956
|
+
let generation = initialGeneration;
|
|
506
957
|
const sendFailure = (files: string[], message: string) => {
|
|
507
958
|
BuilderChannel.emit({
|
|
508
959
|
type: "build-status",
|
|
@@ -511,6 +962,8 @@ class IncrementalBuilder {
|
|
|
511
962
|
};
|
|
512
963
|
sendFailure([], firstMessage);
|
|
513
964
|
BuilderChannel.emit({ type: "builder-ready" });
|
|
965
|
+
// Nothing builds until a save fixes the boot, so the next app's boot need not wait for this one.
|
|
966
|
+
BuilderChannel.emit({ type: "boot-armed" });
|
|
514
967
|
return new Promise((resolve, reject) => {
|
|
515
968
|
void (async () => {
|
|
516
969
|
const roots = await new WatchRootResolver(app).resolve();
|
|
@@ -568,8 +1021,11 @@ class IncrementalBuilder {
|
|
|
568
1021
|
BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
|
|
569
1022
|
return;
|
|
570
1023
|
}
|
|
571
|
-
|
|
572
|
-
|
|
1024
|
+
const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
|
|
1025
|
+
// Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
|
|
1026
|
+
void handled.catch((err: unknown) =>
|
|
1027
|
+
logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
1028
|
+
);
|
|
573
1029
|
});
|
|
574
1030
|
// Closes when the host dies (even by SIGKILL): nothing left to drain, and an orphan would rebuild for nobody.
|
|
575
1031
|
process.on("disconnect", () => {
|
|
@@ -578,18 +1034,28 @@ class IncrementalBuilder {
|
|
|
578
1034
|
});
|
|
579
1035
|
let recoveredFiles: string[] | null = null;
|
|
580
1036
|
const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
|
|
1037
|
+
const initialGeneration = IncrementalBuilder.#initialGeneration();
|
|
581
1038
|
try {
|
|
582
|
-
|
|
1039
|
+
const deps = await IncrementalBuilder.#buildBootDeps(app, bootRunner);
|
|
1040
|
+
builder = new IncrementalBuilder({ app, watch, initialGeneration, ...deps });
|
|
583
1041
|
} catch (err) {
|
|
584
1042
|
if (!watch) throw err;
|
|
585
|
-
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
|
|
1043
|
+
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner, initialGeneration);
|
|
586
1044
|
builder = recovered.builder;
|
|
587
1045
|
recoveredFiles = recovered.changedFiles;
|
|
588
1046
|
}
|
|
589
1047
|
await builder.boot();
|
|
590
1048
|
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
591
1049
|
else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
|
|
592
|
-
await builder.rearmCsrFromEnv();
|
|
1050
|
+
await Promise.all([builder.rearmCsrFromEnv(), builder.armSsrRegistry()]);
|
|
1051
|
+
BuilderChannel.emit({ type: "boot-armed" });
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
//? A replacement builder continues past the host's last generation: from 0, the save fixing an error read older than
|
|
1055
|
+
//? the failure the tabs hold, and from that generation itself its arming builds' ok did not read as the fix either.
|
|
1056
|
+
static #initialGeneration(): number {
|
|
1057
|
+
const generation = Number(process.env.AKAN_BUILDER_INITIAL_GENERATION);
|
|
1058
|
+
return Number.isInteger(generation) && generation > 0 ? generation + 1 : 0;
|
|
593
1059
|
}
|
|
594
1060
|
}
|
|
595
1061
|
|