@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.26
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 +44 -26
- package/akanApp/akanApp.host.ts +113 -23
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +71 -1
- package/akanApp/devHostPolicy.ts +29 -4
- 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 +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +117 -70
- 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.test.ts +89 -0
- package/frontendBuild/csrDevModuleCompiler.ts +56 -10
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +81 -1
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +119 -18
- package/frontendBuild/csrDevTypes.ts +16 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +192 -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 +85 -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 +35 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
- package/integration/devStability.integration.test.ts +25 -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, scope: msg.routeId });
|
|
106
169
|
return {
|
|
107
170
|
type: "build-route-res",
|
|
108
171
|
id: msg.id,
|
|
@@ -116,48 +179,67 @@ 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", {
|
|
188
|
+
this.#sendBuildStatus("route", {
|
|
189
|
+
generation: this.#generation,
|
|
190
|
+
ok: false,
|
|
191
|
+
files: msg.seeds,
|
|
192
|
+
message: errMsg,
|
|
193
|
+
scope: msg.routeId,
|
|
194
|
+
});
|
|
125
195
|
return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
|
|
126
196
|
}
|
|
127
197
|
}
|
|
128
198
|
#sendBuildStatus(
|
|
129
199
|
phase: BuildPhase,
|
|
130
|
-
{
|
|
200
|
+
{
|
|
201
|
+
generation,
|
|
202
|
+
ok,
|
|
203
|
+
files,
|
|
204
|
+
message,
|
|
205
|
+
scope,
|
|
206
|
+
}: { generation?: number; ok: boolean; files?: string[]; message?: string; scope?: string },
|
|
131
207
|
): void {
|
|
132
208
|
if (typeof generation !== "number") return;
|
|
133
209
|
BuilderChannel.emit({
|
|
134
210
|
type: "build-status",
|
|
135
|
-
data: {
|
|
136
|
-
generation,
|
|
137
|
-
phase,
|
|
138
|
-
ok,
|
|
139
|
-
files: files ?? [],
|
|
140
|
-
message,
|
|
141
|
-
},
|
|
211
|
+
data: { generation, phase, ok, files: files ?? [], message, ...(scope !== undefined ? { scope } : {}) },
|
|
142
212
|
});
|
|
143
213
|
}
|
|
144
|
-
async #
|
|
214
|
+
async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
145
215
|
const started = Date.now();
|
|
146
216
|
this.#inFlight += 1;
|
|
147
|
-
const run = this.#
|
|
148
|
-
this.#
|
|
217
|
+
const run = this.#fastQueue.then(fn, fn);
|
|
218
|
+
this.#fastQueue = run.then(() => undefined).catch(() => undefined);
|
|
149
219
|
try {
|
|
150
220
|
return await run;
|
|
151
221
|
} finally {
|
|
152
222
|
this.#inFlight -= 1;
|
|
153
223
|
this.#workCount += 1;
|
|
154
|
-
this.#logger.verbose(`[
|
|
224
|
+
this.#logger.verbose(`[fast-queue] ${label} finished in ${Date.now() - started}ms`);
|
|
155
225
|
this.#reportMetrics();
|
|
156
226
|
}
|
|
157
227
|
}
|
|
158
228
|
|
|
159
229
|
get #idle(): boolean {
|
|
160
|
-
return this.#inFlight === 0 && this.#cssRebuildTimer === null;
|
|
230
|
+
return this.#inFlight === 0 && this.#workQueue.size === 0 && this.#cssRebuildTimer === null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Callers catch their own errors: the promise handed in never rejects.
|
|
234
|
+
#runBeside(work: Promise<void>): Promise<void> {
|
|
235
|
+
this.#inFlight += 1;
|
|
236
|
+
const tracked: Promise<void> = work.finally(() => {
|
|
237
|
+
this.#inFlight -= 1;
|
|
238
|
+
this.#beside.delete(tracked);
|
|
239
|
+
this.#reportMetrics();
|
|
240
|
+
});
|
|
241
|
+
this.#beside.add(tracked);
|
|
242
|
+
return tracked;
|
|
161
243
|
}
|
|
162
244
|
|
|
163
245
|
// Idle only: the host recycles on these metrics, and a recycle decided mid-work would truncate it or race the drain.
|
|
@@ -184,8 +266,15 @@ class IncrementalBuilder {
|
|
|
184
266
|
this.#cssRebuildTimer = null;
|
|
185
267
|
this.#pendingCssRebuild = null;
|
|
186
268
|
}
|
|
187
|
-
|
|
269
|
+
// The fast lane first: a save it is still handling may queue a batch in the slow one.
|
|
270
|
+
await this.#fastQueue.catch(() => undefined);
|
|
271
|
+
await this.#workQueue.drain();
|
|
188
272
|
await this.#cssRebuildQueue.catch(() => undefined);
|
|
273
|
+
while (this.#beside.size > 0) await Promise.all([...this.#beside]);
|
|
274
|
+
if (this.#delegations.size > 0)
|
|
275
|
+
this.#logger.verbose(
|
|
276
|
+
`registry saves handed to build workers this session: ${[...this.#delegations].map(([reason, count]) => `${reason}=${count}`).join(", ")}`,
|
|
277
|
+
);
|
|
189
278
|
// Drained queues are not delivered results: `process.exit` drops unflushed ipc writes (a relayed `css-updated`).
|
|
190
279
|
const flushed = await BuilderChannel.drain();
|
|
191
280
|
this.#logger.debug(
|
|
@@ -264,7 +353,7 @@ class IncrementalBuilder {
|
|
|
264
353
|
roots,
|
|
265
354
|
logger: this.#logger,
|
|
266
355
|
onBatch: async (batch: ChangeBatch) => {
|
|
267
|
-
await this.#
|
|
356
|
+
await this.#enqueueFast("hmr-batch", async () => this.#handleWatchBatch(batch));
|
|
268
357
|
},
|
|
269
358
|
});
|
|
270
359
|
await watcher.start();
|
|
@@ -276,6 +365,7 @@ class IncrementalBuilder {
|
|
|
276
365
|
const rawKinds = new Set(batch.kinds);
|
|
277
366
|
if (rawKinds.size === 0) return;
|
|
278
367
|
const generation = ++this.#generation;
|
|
368
|
+
const trace = { ...batch.trace, batchAt: Date.now() };
|
|
279
369
|
//* Auto-import edits touch only this batch's files, so they rebuild in this same generation.
|
|
280
370
|
const [autoImport, indexSync] = await CodegenLock.run(
|
|
281
371
|
this.#app.workspace.workspaceRoot,
|
|
@@ -301,21 +391,24 @@ class IncrementalBuilder {
|
|
|
301
391
|
);
|
|
302
392
|
for (const error of indexSync.errors) this.#logger.error(error);
|
|
303
393
|
|
|
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
|
-
}
|
|
394
|
+
// Invalidated in the slow lane, under the route builds that read it; a registry check reading it beside them caches
|
|
395
|
+
// nothing a walk began before the invalidation (the discovery's epoch).
|
|
396
|
+
const discovery = kinds.includes("code") ? { files, refresh: kinds.includes("config"), generation } : undefined;
|
|
312
397
|
|
|
313
398
|
if (hasSyncErrors) {
|
|
399
|
+
this.#barrelFailed = true;
|
|
314
400
|
this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
|
|
315
401
|
BuilderChannel.emit(event);
|
|
402
|
+
if (discovery)
|
|
403
|
+
void this.#workQueue
|
|
404
|
+
.enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
|
|
405
|
+
.catch(this.#slowLaneFailed("discovery"));
|
|
316
406
|
return;
|
|
317
407
|
}
|
|
318
|
-
|
|
408
|
+
// After a failure too, where nothing changed on disk: the host re-sends a phase still failing to every new tab.
|
|
409
|
+
if (indexSync.changedFiles.length > 0 || this.#barrelFailed)
|
|
410
|
+
this.#sendBuildStatus("barrel", { generation, ok: true, files });
|
|
411
|
+
this.#barrelFailed = false;
|
|
319
412
|
|
|
320
413
|
// Server-only generations skip the client: a fresh pages buildId would rsc-refresh browsers for no visible change.
|
|
321
414
|
const rebuildClient = devPlan.actions.includes("rebuild-client");
|
|
@@ -332,47 +425,392 @@ class IncrementalBuilder {
|
|
|
332
425
|
this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
|
|
333
426
|
}
|
|
334
427
|
|
|
335
|
-
|
|
428
|
+
BuilderChannel.emit(event);
|
|
429
|
+
|
|
336
430
|
if (kinds.includes("code") && rebuildClient) {
|
|
337
|
-
|
|
338
|
-
|
|
431
|
+
// Css rides the pages batch rather than its own debounce: the slow lane folds a burst of saves into one batch.
|
|
432
|
+
const needs: BuildBatchNeed[] = ["pages", "css"];
|
|
433
|
+
if (!this.#csrActive)
|
|
339
434
|
this.#logger.verbose(
|
|
340
435
|
`csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
|
|
341
436
|
);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
437
|
+
//? One after the other, since two compiles side by side slow both. CSR goes first unless its build is being armed:
|
|
438
|
+
//? a CSR patch then waits behind #csrGate, which SSR tabs must not.
|
|
439
|
+
const patchCsr = async () => (this.#csrActive ? await this.#patchCsr(generation, files, trace) : false);
|
|
440
|
+
const patchSsr = async () => await this.#patchSsr(generation, files, trace);
|
|
441
|
+
let csrToWorker: boolean;
|
|
442
|
+
let ssrToWorker: boolean;
|
|
443
|
+
if (this.#csrArming) {
|
|
444
|
+
ssrToWorker = await patchSsr();
|
|
445
|
+
csrToWorker = await patchCsr();
|
|
446
|
+
} else {
|
|
447
|
+
csrToWorker = await patchCsr();
|
|
448
|
+
ssrToWorker = await patchSsr();
|
|
449
|
+
}
|
|
450
|
+
if (csrToWorker) needs.unshift("csr");
|
|
451
|
+
if (ssrToWorker) needs.unshift("ssr");
|
|
452
|
+
const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
|
|
453
|
+
// A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
|
|
454
|
+
if (needs.includes("csr") || needs.includes("ssr")) await this.#workQueue.enqueueBatch(batch);
|
|
455
|
+
else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
|
|
456
|
+
return;
|
|
346
457
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
458
|
+
if (discovery)
|
|
459
|
+
void this.#workQueue
|
|
460
|
+
.enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
|
|
461
|
+
.catch(this.#slowLaneFailed("discovery"));
|
|
351
462
|
// Css-only batches keep the debounce: they arrive in bursts while a stylesheet is edited.
|
|
352
|
-
|
|
463
|
+
if (kinds.includes("css")) {
|
|
353
464
|
this.scheduleCssRebuild({ generation, changedFiles: files });
|
|
354
465
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
355
466
|
}
|
|
356
467
|
}
|
|
357
468
|
|
|
469
|
+
// True when the save has to go to a build worker: the patcher handed it back, or threw (the worker reports why).
|
|
470
|
+
async #patchCsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
|
|
471
|
+
const patcher = this.#patcher;
|
|
472
|
+
if (!patcher) return true;
|
|
473
|
+
await this.#csrGate;
|
|
474
|
+
// The arming build this save waited for failed: CSR is off again, and the next request arms it anew.
|
|
475
|
+
if (!this.#csrActive) return false;
|
|
476
|
+
const started = Date.now();
|
|
477
|
+
const marker = path.join(this.#csrBundler.outDir, CSR_DEV_PATCHING_MARKER);
|
|
478
|
+
await Bun.write(marker, String(process.pid));
|
|
479
|
+
try {
|
|
480
|
+
const result = await patcher.update(files, {
|
|
481
|
+
announce: (update) => {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
BuilderChannel.emit({
|
|
484
|
+
type: "csr-updated",
|
|
485
|
+
data: {
|
|
486
|
+
generation: update.generation,
|
|
487
|
+
mode: "registry",
|
|
488
|
+
reload: update.reload,
|
|
489
|
+
reason: update.reason,
|
|
490
|
+
patchUrl: update.patchUrl,
|
|
491
|
+
changedIds: update.changedIds,
|
|
492
|
+
trace: { ...trace, patchAt: now, sentAt: now },
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
if (result.kind === "delegate") {
|
|
498
|
+
this.#logger.verbose(
|
|
499
|
+
`csr-patch handed to a build worker (${this.#countDelegation("csr", result.reason)}): ${result.reason}`,
|
|
500
|
+
);
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
this.#sendBuildStatus("csr", { generation, ok: true, files });
|
|
504
|
+
this.#logger.verbose(
|
|
505
|
+
result.kind === "unchanged"
|
|
506
|
+
? `csr-patch unchanged (${Date.now() - started}ms)`
|
|
507
|
+
: `csr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
|
|
508
|
+
);
|
|
509
|
+
return false;
|
|
510
|
+
} catch (err) {
|
|
511
|
+
const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
512
|
+
if (IncrementalBuilder.#isCompileError(err)) {
|
|
513
|
+
this.#sendBuildStatus("csr", { generation, ok: false, files, message });
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
this.#logger.verbose(`csr-patch threw; a build worker takes this save: ${message}`);
|
|
517
|
+
patcher.forget();
|
|
518
|
+
return true;
|
|
519
|
+
} finally {
|
|
520
|
+
await rm(marker, { force: true });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// True when the save has to go to a build worker, as for CSR.
|
|
525
|
+
async #patchSsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
|
|
526
|
+
const patcher = this.#ssrPatcher;
|
|
527
|
+
if (!patcher) return true;
|
|
528
|
+
const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
|
|
529
|
+
ServerGraphFile.clientExportsOf(file),
|
|
530
|
+
);
|
|
531
|
+
//? Ahead of this save's own discovery job in the slow lane: which client entries a page still names decides which
|
|
532
|
+
//? pending roots this patch keeps.
|
|
533
|
+
this.#discovery.invalidate?.(files);
|
|
534
|
+
return await this.#withSsrLock(
|
|
535
|
+
async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
//* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
|
|
540
|
+
//* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
|
|
541
|
+
async #ensureSsrEntries(entries: string[], { crashRetry = false }: { crashRetry?: boolean } = {}): Promise<void> {
|
|
542
|
+
if (entries.length === 0) return;
|
|
543
|
+
//? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
|
|
544
|
+
//? registry (boot.json) instead; whatever that build missed is added right after it lands.
|
|
545
|
+
const arming = this.#ssrArming;
|
|
546
|
+
if (arming) {
|
|
547
|
+
void this.#runBeside(
|
|
548
|
+
arming
|
|
549
|
+
.then(async () => await this.#ensureSsrEntries(entries, { crashRetry }))
|
|
550
|
+
.catch(this.#slowLaneFailed("ssr-ensure")),
|
|
551
|
+
);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await this.#withSsrLock(async () => {
|
|
555
|
+
const patcher = this.#ssrPatcher;
|
|
556
|
+
// The builder's generation, not the request's: its build-status joins the saves' `ssr` ones.
|
|
557
|
+
const generation = this.#generation;
|
|
558
|
+
if (
|
|
559
|
+
patcher &&
|
|
560
|
+
!(await this.#runSsrPatcher(patcher, [], { roots: entries, onlyRoots: true, batchGeneration: generation }))
|
|
561
|
+
)
|
|
562
|
+
return;
|
|
563
|
+
//? Read, not compiled: with the patcher off, the save's own worker batch already built every entry.
|
|
564
|
+
if (!patcher && (await this.#ssrBundler.holds(entries))) return;
|
|
565
|
+
//? Not retried per route build: the save that fixes the error rebuilds it, where each route build retrying held
|
|
566
|
+
//? the SSR lock that save's patch waits on.
|
|
567
|
+
if (this.#ssrBroken) return;
|
|
568
|
+
if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
|
|
569
|
+
//? Handed over just now with nothing run for them: the retry must find them to hand over again.
|
|
570
|
+
patcher?.releaseHandedOver();
|
|
571
|
+
this.#retryAfterCrash(this.#ssrCrashedAt, entries);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
await this.#runSsrWorker(generation).catch((err: unknown) => {
|
|
575
|
+
this.#logger.error(
|
|
576
|
+
`ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
//? A worker that died took these entries with it and left them pending nowhere: they are tried once more after the
|
|
580
|
+
//? crash window, but a retry that crashes again waits for the next route build or save.
|
|
581
|
+
if (this.#ssrCrashedAt !== null && !crashRetry) this.#retryAfterCrash(this.#ssrCrashedAt, entries);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
//? Once the crash window closes, for the entries skipped in it: later route builds may name none (the first one
|
|
586
|
+
//? announced every entry to the route cache), so without this a tab could wait on boot.json until the next save.
|
|
587
|
+
#retryAfterCrash(crashedAt: number, entries: string[]): void {
|
|
588
|
+
for (const entry of entries) this.#crashRetryEntries.add(entry);
|
|
589
|
+
if (this.#crashRetryTimer) return;
|
|
590
|
+
const timer = setTimeout(
|
|
591
|
+
() => {
|
|
592
|
+
this.#crashRetryTimer = null;
|
|
593
|
+
const retried = [...this.#crashRetryEntries];
|
|
594
|
+
this.#crashRetryEntries.clear();
|
|
595
|
+
if (this.shuttingDown || retried.length === 0) return;
|
|
596
|
+
void this.#runBeside(this.#ensureSsrEntries(retried, { crashRetry: true })).catch(
|
|
597
|
+
this.#slowLaneFailed("ssr-crash-retry"),
|
|
598
|
+
);
|
|
599
|
+
},
|
|
600
|
+
Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
|
|
601
|
+
);
|
|
602
|
+
timer.unref?.();
|
|
603
|
+
this.#crashRetryTimer = timer;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
//* The boot build of the SSR registry runs beside the slow lane, holding only the SSR lock: the first page's route
|
|
607
|
+
//* build bundles meanwhile instead of queueing behind it (on apps/akan it cost the first page about a second).
|
|
608
|
+
async armSsrRegistry(): Promise<void> {
|
|
609
|
+
//? `AKAN_DEV_SSR_ARM_DELAY_MS` is a test hook: an E2E has a tab reconnect before the boot build has run.
|
|
610
|
+
const delayMs = Number(process.env.AKAN_DEV_SSR_ARM_DELAY_MS);
|
|
611
|
+
if (Number.isInteger(delayMs) && delayMs > 0) await Bun.sleep(delayMs);
|
|
612
|
+
const arm = async () =>
|
|
613
|
+
await this.#withSsrLock(async () => {
|
|
614
|
+
const generation = this.#generation;
|
|
615
|
+
if (this.#ssrPatcher && !(await this.#runSsrPatcher(this.#ssrPatcher, [], { batchGeneration: generation })))
|
|
616
|
+
return;
|
|
617
|
+
await this.#runSsrWorker(generation);
|
|
618
|
+
}).catch((err: unknown) => {
|
|
619
|
+
this.#logger.error(
|
|
620
|
+
`ssr-registry boot build failed; the next save retries it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
621
|
+
);
|
|
622
|
+
});
|
|
623
|
+
const arming = this.#runBeside(
|
|
624
|
+
(async () => {
|
|
625
|
+
await arm();
|
|
626
|
+
//? A boot build whose worker was killed (a small container's OOM) is tried once more before `boot-armed` lets
|
|
627
|
+
//? the next app boot: retried after that, the two biggest workers of a boot would run side by side.
|
|
628
|
+
const crashedAt = this.#ssrCrashedAt;
|
|
629
|
+
if (crashedAt === null) return;
|
|
630
|
+
while (!this.shuttingDown && Date.now() < crashedAt + IncrementalBuilder.#crashRetryMs) await Bun.sleep(250);
|
|
631
|
+
if (!this.shuttingDown) await arm();
|
|
632
|
+
})(),
|
|
633
|
+
);
|
|
634
|
+
this.#ssrArming = arming;
|
|
635
|
+
await arming;
|
|
636
|
+
this.#ssrArming = null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
//? A save the user broke fails the same way in a worker, so it is reported here and the patcher keeps its state: the
|
|
640
|
+
//? modules it could not compile stay pending in the graph and the fixing save compiles them.
|
|
641
|
+
static #isCompileError(error: unknown): boolean {
|
|
642
|
+
return error instanceof AggregateError;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Voided so the fast lane need not wait; unhandled, a rejection would exit the builder and drop the watcher with it.
|
|
646
|
+
#slowLaneFailed(label: string) {
|
|
647
|
+
return (error: unknown) =>
|
|
648
|
+
this.#logger.error(
|
|
649
|
+
`${label} failed in the slow lane: ${ApplicationBuildReporter.formatError(error, this.#app.workspace.workspaceRoot)}`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// A reason's own detail (which import) follows its first colon, so saves of one kind count together.
|
|
654
|
+
#countDelegation(registry: "csr" | "ssr", reason: string): string {
|
|
655
|
+
const key = `${registry}:${reason.split(":")[0]}`;
|
|
656
|
+
const count = (this.#delegations.get(key) ?? 0) + 1;
|
|
657
|
+
this.#delegations.set(key, count);
|
|
658
|
+
return `#${count} for this reason`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async #withSsrLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
662
|
+
const run = this.#ssrLock.then(fn, fn);
|
|
663
|
+
this.#ssrLock = run.then(
|
|
664
|
+
() => undefined,
|
|
665
|
+
() => undefined,
|
|
666
|
+
);
|
|
667
|
+
return await run;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
|
|
671
|
+
async #runSsrWorker(generation: number): Promise<void> {
|
|
672
|
+
let result: BuildBatchResult;
|
|
673
|
+
try {
|
|
674
|
+
result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
|
|
675
|
+
} finally {
|
|
676
|
+
this.#ssrPatcher?.forget();
|
|
677
|
+
}
|
|
678
|
+
this.#noteSsrWorker(result);
|
|
679
|
+
if (result.errors.ssr) throw new Error(result.errors.ssr);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#noteSsrWorker(result: BuildBatchResult): void {
|
|
683
|
+
const crashed = !!result.crashed && (result.crashedNeeds ?? ["ssr"]).includes("ssr");
|
|
684
|
+
this.#ssrBroken = !!result.errors.ssr && !crashed;
|
|
685
|
+
this.#ssrCrashedAt = crashed ? Date.now() : null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async #runSsrPatcher(
|
|
689
|
+
patcher: CsrDevPatcher,
|
|
690
|
+
files: string[],
|
|
691
|
+
{
|
|
692
|
+
roots,
|
|
693
|
+
onlyRoots,
|
|
694
|
+
trace,
|
|
695
|
+
hold,
|
|
696
|
+
batchGeneration,
|
|
697
|
+
}: { roots?: string[]; onlyRoots?: boolean; trace?: HmrTrace; hold?: boolean; batchGeneration?: number } = {},
|
|
698
|
+
): Promise<boolean> {
|
|
699
|
+
const started = Date.now();
|
|
700
|
+
const marker = path.join(this.#ssrBundler.outDir, CSR_DEV_PATCHING_MARKER);
|
|
701
|
+
await Bun.write(marker, String(process.pid));
|
|
702
|
+
try {
|
|
703
|
+
const result = await patcher.update(files, {
|
|
704
|
+
roots,
|
|
705
|
+
onlyRoots,
|
|
706
|
+
announce: (update) => {
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
BuilderChannel.emit({
|
|
709
|
+
type: "ssr-updated",
|
|
710
|
+
data: {
|
|
711
|
+
generation: update.generation,
|
|
712
|
+
reload: update.reload,
|
|
713
|
+
reason: update.reason,
|
|
714
|
+
patchUrl: update.patchUrl,
|
|
715
|
+
changedIds: update.changedIds,
|
|
716
|
+
...(trace ? { trace: { ...trace, patchAt: now, sentAt: now } } : {}),
|
|
717
|
+
...(hold ? { hold } : {}),
|
|
718
|
+
...(batchGeneration !== undefined ? { batchGeneration } : {}),
|
|
719
|
+
...(update.epoch !== undefined ? { epoch: update.epoch } : {}),
|
|
720
|
+
...(update.first ? { first: true } : {}),
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
if (result.kind === "delegate") {
|
|
726
|
+
this.#logger.verbose(
|
|
727
|
+
`ssr-patch handed to a build worker (${this.#countDelegation("ssr", result.reason)}): ${result.reason}`,
|
|
728
|
+
);
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
//? A route build's check reports only a failure: it compiles the roots the registry lacks and nothing else, so
|
|
732
|
+
//? its ok, of the builder's current generation, would read as the fix of a save that broke something else.
|
|
733
|
+
if (!onlyRoots) {
|
|
734
|
+
this.#ssrBroken = false;
|
|
735
|
+
this.#ssrCrashedAt = null;
|
|
736
|
+
this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: true, files });
|
|
737
|
+
}
|
|
738
|
+
this.#logger.verbose(
|
|
739
|
+
result.kind === "unchanged"
|
|
740
|
+
? `ssr-patch unchanged (${Date.now() - started}ms)`
|
|
741
|
+
: `ssr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
|
|
742
|
+
);
|
|
743
|
+
return false;
|
|
744
|
+
} catch (err) {
|
|
745
|
+
const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
746
|
+
if (IncrementalBuilder.#isCompileError(err)) {
|
|
747
|
+
this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: false, files, message });
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
this.#logger.verbose(`ssr-patch threw; a build worker takes it: ${message}`);
|
|
751
|
+
patcher.forget();
|
|
752
|
+
return true;
|
|
753
|
+
} finally {
|
|
754
|
+
await rm(marker, { force: true });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async #runQueuedBatch({ discovery, ...work }: BatchJob): Promise<void> {
|
|
759
|
+
if (discovery) await this.#refreshDiscovery(discovery);
|
|
760
|
+
//? The slow lane runs in order, so every batch up to this one has had its discovery job.
|
|
761
|
+
this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
|
|
762
|
+
// A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
|
|
763
|
+
//? Forgotten inside the lock: a route build's check waiting on it would patch the old state over the worker's.
|
|
764
|
+
try {
|
|
765
|
+
if (work.needs.includes("ssr"))
|
|
766
|
+
await this.#withSsrLock(async () => {
|
|
767
|
+
try {
|
|
768
|
+
this.#noteSsrWorker(await this.#runBatch(work));
|
|
769
|
+
} finally {
|
|
770
|
+
this.#ssrPatcher?.forget();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
else await this.#runBatch(work);
|
|
774
|
+
} finally {
|
|
775
|
+
if (work.needs.includes("csr")) this.#patcher?.forget();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
|
|
780
|
+
const started = Date.now();
|
|
781
|
+
if (refresh) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
|
|
782
|
+
else this.#discovery.invalidate?.(files);
|
|
783
|
+
this.#discoveredThrough = Math.max(this.#discoveredThrough, generation);
|
|
784
|
+
this.#logger.verbose(`client-entry-discovery ${refresh ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`);
|
|
785
|
+
}
|
|
786
|
+
|
|
358
787
|
async #runBatch(work: BatchWork): Promise<BuildBatchResult> {
|
|
359
788
|
const { generation, needs, changedFiles } = work;
|
|
360
789
|
const started = Date.now();
|
|
361
790
|
const result = await this.#batchRunner.run(await this.#batchRequest(work), (msg) => BuilderChannel.emit(msg));
|
|
362
791
|
if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
|
|
363
792
|
if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
|
|
364
|
-
// A
|
|
793
|
+
// A need the worker died before reporting goes red here instead of looking silently successful.
|
|
365
794
|
if (result.crashed) {
|
|
795
|
+
const crashedNeeds = result.crashedNeeds ?? needs;
|
|
796
|
+
//? A worker that died wrote no failure for the roots handed to it: they must not wait on one that never comes.
|
|
797
|
+
if (crashedNeeds.includes("csr")) this.#patcher?.releaseHandedOver();
|
|
798
|
+
if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
|
|
366
799
|
// `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
|
|
367
|
-
for (const need of
|
|
800
|
+
for (const need of crashedNeeds)
|
|
368
801
|
if (need !== "base")
|
|
369
|
-
this.#sendBuildStatus(need, {
|
|
802
|
+
this.#sendBuildStatus(need, {
|
|
803
|
+
generation,
|
|
804
|
+
ok: false,
|
|
805
|
+
files: changedFiles,
|
|
806
|
+
message: `${result.errors[need] ?? "the build worker crashed"}; the next save builds it again`,
|
|
807
|
+
});
|
|
370
808
|
}
|
|
371
809
|
if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
372
810
|
return result;
|
|
373
811
|
}
|
|
374
812
|
|
|
375
|
-
async #batchRequest({ generation, needs, changedFiles }: BatchWork): Promise<BuildBatchRequest> {
|
|
813
|
+
async #batchRequest({ generation, needs, changedFiles, trace }: BatchWork): Promise<BuildBatchRequest> {
|
|
376
814
|
return {
|
|
377
815
|
appName: this.#app.name,
|
|
378
816
|
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
@@ -384,6 +822,7 @@ class IncrementalBuilder {
|
|
|
384
822
|
optimizedFonts: this.#optimizedFonts,
|
|
385
823
|
cssAssets: this.#artifact.cssAssets ?? null,
|
|
386
824
|
artifactDir: path.resolve(this.#artifactDir),
|
|
825
|
+
...(trace ? { trace } : {}),
|
|
387
826
|
};
|
|
388
827
|
}
|
|
389
828
|
|
|
@@ -396,7 +835,7 @@ class IncrementalBuilder {
|
|
|
396
835
|
// The backend still serves the last-good bundle after a degraded boot; push fresh pages/css without another edit.
|
|
397
836
|
async announceRecoveredState(changedFiles: string[]): Promise<void> {
|
|
398
837
|
const generation = ++this.#generation;
|
|
399
|
-
await this.#
|
|
838
|
+
await this.#workQueue.enqueue("boot-recovered", async () => {
|
|
400
839
|
await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
|
|
401
840
|
});
|
|
402
841
|
}
|
|
@@ -435,22 +874,55 @@ class IncrementalBuilder {
|
|
|
435
874
|
|
|
436
875
|
// On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
|
|
437
876
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
438
|
-
|
|
877
|
+
//? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
|
|
878
|
+
this.#beginCsrArm();
|
|
879
|
+
this.#csrActive = true;
|
|
880
|
+
const armed = this.#workQueue.enqueue("build-csr", async (): Promise<void> => {
|
|
439
881
|
const started = Date.now();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
882
|
+
let error: string | undefined;
|
|
883
|
+
try {
|
|
884
|
+
// Not relayed: an on-demand CSR build is request/response, and its error travels in the response.
|
|
885
|
+
const result = await this.#batchRunner.run(
|
|
886
|
+
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
887
|
+
);
|
|
888
|
+
error = result.errors.csr;
|
|
889
|
+
} catch (err) {
|
|
890
|
+
error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
891
|
+
}
|
|
445
892
|
if (error) {
|
|
446
893
|
this.#logger.error(`csr-build failed: ${error}`);
|
|
447
894
|
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
448
895
|
return;
|
|
449
896
|
}
|
|
450
|
-
this.#
|
|
897
|
+
this.#csrArmSucceeded = true;
|
|
898
|
+
this.#patcher?.forget();
|
|
451
899
|
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
452
900
|
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
|
|
453
901
|
});
|
|
902
|
+
this.#csrGate = this.#armingCsr(armed);
|
|
903
|
+
await armed;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
//? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
|
|
907
|
+
//? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
|
|
908
|
+
//? patch CSR first while the second still builds.
|
|
909
|
+
#beginCsrArm(): void {
|
|
910
|
+
if (this.#csrArms === 0) {
|
|
911
|
+
this.#csrWasActive = this.#csrActive;
|
|
912
|
+
this.#csrArmSucceeded = false;
|
|
913
|
+
}
|
|
914
|
+
this.#csrArms += 1;
|
|
915
|
+
this.#csrArming = true;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
#armingCsr(armed: Promise<void>): Promise<void> {
|
|
919
|
+
const settle = () => {
|
|
920
|
+
this.#csrArms -= 1;
|
|
921
|
+
if (this.#csrArms > 0) return;
|
|
922
|
+
this.#csrArming = false;
|
|
923
|
+
this.#csrActive = this.#csrWasActive || this.#csrArmSucceeded;
|
|
924
|
+
};
|
|
925
|
+
return armed.then(settle, settle);
|
|
454
926
|
}
|
|
455
927
|
|
|
456
928
|
static #csrArmedByEnv() {
|
|
@@ -482,14 +954,26 @@ class IncrementalBuilder {
|
|
|
482
954
|
// The env flag carries an armed CSR session across builder restarts, so the replacement rebuilds it after ready.
|
|
483
955
|
async rearmCsrFromEnv(): Promise<void> {
|
|
484
956
|
if (!IncrementalBuilder.#csrArmedByEnv()) return;
|
|
957
|
+
this.#beginCsrArm();
|
|
485
958
|
this.#csrActive = true;
|
|
486
|
-
|
|
959
|
+
//? The env keeps CSR armed whatever this build does, as it did before the builder restarted.
|
|
960
|
+
this.#csrArmSucceeded = true;
|
|
961
|
+
const rearmed = this.#workQueue.enqueue("build-csr-rearm", async () => {
|
|
962
|
+
//? Relayed, unlike an on-demand build: a rebuild for a metadata save must reach the CSR tabs as their reload.
|
|
487
963
|
const result = await this.#batchRunner.run(
|
|
488
964
|
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
965
|
+
(msg) => BuilderChannel.emit(msg),
|
|
489
966
|
);
|
|
967
|
+
this.#patcher?.forget();
|
|
490
968
|
if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
|
|
491
969
|
else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
|
|
492
970
|
});
|
|
971
|
+
this.#csrGate = this.#armingCsr(rearmed);
|
|
972
|
+
await rearmed.catch((err: unknown) =>
|
|
973
|
+
this.#logger.error(
|
|
974
|
+
`csr-rearm failed: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
975
|
+
),
|
|
976
|
+
);
|
|
493
977
|
}
|
|
494
978
|
|
|
495
979
|
// A boot compile error must not kill the builder, the dev server's only file watcher: report it, emit builder-ready
|
|
@@ -499,10 +983,11 @@ class IncrementalBuilder {
|
|
|
499
983
|
bootError: unknown,
|
|
500
984
|
logger: Logger,
|
|
501
985
|
runner: BuildBatchRunner,
|
|
986
|
+
initialGeneration: number,
|
|
502
987
|
): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
|
|
503
988
|
const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
|
|
504
989
|
logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
|
|
505
|
-
let generation =
|
|
990
|
+
let generation = initialGeneration;
|
|
506
991
|
const sendFailure = (files: string[], message: string) => {
|
|
507
992
|
BuilderChannel.emit({
|
|
508
993
|
type: "build-status",
|
|
@@ -511,6 +996,8 @@ class IncrementalBuilder {
|
|
|
511
996
|
};
|
|
512
997
|
sendFailure([], firstMessage);
|
|
513
998
|
BuilderChannel.emit({ type: "builder-ready" });
|
|
999
|
+
// Nothing builds until a save fixes the boot, so the next app's boot need not wait for this one.
|
|
1000
|
+
BuilderChannel.emit({ type: "boot-armed" });
|
|
514
1001
|
return new Promise((resolve, reject) => {
|
|
515
1002
|
void (async () => {
|
|
516
1003
|
const roots = await new WatchRootResolver(app).resolve();
|
|
@@ -568,8 +1055,11 @@ class IncrementalBuilder {
|
|
|
568
1055
|
BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
|
|
569
1056
|
return;
|
|
570
1057
|
}
|
|
571
|
-
|
|
572
|
-
|
|
1058
|
+
const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
|
|
1059
|
+
// Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
|
|
1060
|
+
void handled.catch((err: unknown) =>
|
|
1061
|
+
logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
1062
|
+
);
|
|
573
1063
|
});
|
|
574
1064
|
// Closes when the host dies (even by SIGKILL): nothing left to drain, and an orphan would rebuild for nobody.
|
|
575
1065
|
process.on("disconnect", () => {
|
|
@@ -578,18 +1068,28 @@ class IncrementalBuilder {
|
|
|
578
1068
|
});
|
|
579
1069
|
let recoveredFiles: string[] | null = null;
|
|
580
1070
|
const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
|
|
1071
|
+
const initialGeneration = IncrementalBuilder.#initialGeneration();
|
|
581
1072
|
try {
|
|
582
|
-
|
|
1073
|
+
const deps = await IncrementalBuilder.#buildBootDeps(app, bootRunner);
|
|
1074
|
+
builder = new IncrementalBuilder({ app, watch, initialGeneration, ...deps });
|
|
583
1075
|
} catch (err) {
|
|
584
1076
|
if (!watch) throw err;
|
|
585
|
-
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
|
|
1077
|
+
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner, initialGeneration);
|
|
586
1078
|
builder = recovered.builder;
|
|
587
1079
|
recoveredFiles = recovered.changedFiles;
|
|
588
1080
|
}
|
|
589
1081
|
await builder.boot();
|
|
590
1082
|
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
591
1083
|
else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
|
|
592
|
-
await builder.rearmCsrFromEnv();
|
|
1084
|
+
await Promise.all([builder.rearmCsrFromEnv(), builder.armSsrRegistry()]);
|
|
1085
|
+
BuilderChannel.emit({ type: "boot-armed" });
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
//? A replacement builder continues past the host's last generation: from 0, the save fixing an error read older than
|
|
1089
|
+
//? the failure the tabs hold, and from that generation itself its arming builds' ok did not read as the fix either.
|
|
1090
|
+
static #initialGeneration(): number {
|
|
1091
|
+
const generation = Number(process.env.AKAN_BUILDER_INITIAL_GENERATION);
|
|
1092
|
+
return Number.isInteger(generation) && generation > 0 ? generation + 1 : 0;
|
|
593
1093
|
}
|
|
594
1094
|
}
|
|
595
1095
|
|