akanjs 3.0.0-beta.25 → 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.
@@ -5,6 +5,7 @@ declare global {
5
5
  var __AKAN_HMR_PHASE__: AkanHmrPhase | undefined;
6
6
  }
7
7
 
8
+ /** @deprecated Always `null`: the dev client that set the phase applies modules through its registry now. */
8
9
  export function getAkanHmrPhase(): AkanHmrPhase {
9
10
  if (typeof globalThis === "undefined") return null;
10
11
  const phase = globalThis.__AKAN_HMR_PHASE__;
@@ -12,6 +13,7 @@ export function getAkanHmrPhase(): AkanHmrPhase {
12
13
  return null;
13
14
  }
14
15
 
16
+ /** @deprecated Always `false`, for the reason `getAkanHmrPhase` is always `null`. */
15
17
  export function isAkanHmrApplying(): boolean {
16
18
  return getAkanHmrPhase() !== null;
17
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.25",
3
+ "version": "3.0.0-beta.26",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,12 @@
1
+ import type { DevBuildStatus } from "./ipcTypes";
2
+
3
+ export class DevBuildRecovery {
4
+
5
+ static recovers(previous: DevBuildStatus | undefined, status: DevBuildStatus): boolean {
6
+ if (!previous || previous.ok || !status.ok) return false;
7
+ if (status.phase === "route" && status.scope !== undefined && previous.scope !== undefined)
8
+ return status.scope === previous.scope && status.generation >= previous.generation;
9
+ if (status.generation !== previous.generation) return status.generation > previous.generation;
10
+ return status.phase === "backend";
11
+ }
12
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./builderRpc";
2
+ export * from "./devBuildRecovery";
2
3
  export * from "./ipcTypes";
3
4
  export * from "./routeClientCache";
4
5
  export * from "./routeSeedIndexStore";
@@ -52,6 +52,8 @@ export interface DevBuildStatus {
52
52
  ok: boolean;
53
53
  files: string[];
54
54
  message?: string;
55
+ /** The route a route build's status speaks for. */
56
+ scope?: string;
55
57
  }
56
58
 
57
59
  export type BuilderReq = {
@@ -118,7 +118,7 @@ export class DiLifecycle {
118
118
  constructor({ env, modules = [], disableModules = [], disableLibs = [] }: DiLifecycleProps, ...libs: AkanLib[]) {
119
119
  this.#env = env;
120
120
  const databaseMode = DiLifecycle.#databaseMode();
121
- this.logger.info(`Database mode: ${DatabaseModes.describe(databaseMode)}`);
121
+ this.logger.debug(`Database mode: ${DatabaseModes.describe(databaseMode)}`);
122
122
 
123
123
  this.#predefinedAdaptor = { ...getPredefinedAdaptor(databaseMode) };
124
124
  this.#libs = libs;
@@ -52,12 +52,12 @@ export const HMR_CLIENT_SCRIPT = `(function(){
52
52
  function connect(){
53
53
  try { socket = new WebSocket(url); }
54
54
  catch(e){ console.error("[akan-hmr] ws init failed", e); schedule(); return; }
55
- socket.addEventListener("open", function(){ attempts = 0; });
56
55
  socket.addEventListener("message", function(ev){
57
56
  var msg;
58
57
  try { msg = JSON.parse(ev.data); } catch (e){ return; }
59
58
  if (!msg || typeof msg.type !== "string") return;
60
59
  if (msg.type === "hello") {
60
+ attempts = 0;
61
61
  var dropped = Array.isArray(msg.failingPhases) && dropBuildErrorsExcept(msg.failingPhases);
62
62
  if (dropped && systemPage) {
63
63
  reloadForUpdate("the build that failed to render this page recovered");
@@ -387,7 +387,7 @@ export const HMR_CLIENT_SCRIPT = `(function(){
387
387
  var current = buildErrorStates[phase];
388
388
  if (!current) return;
389
389
  var generation = typeof msg.generation === "number" ? msg.generation : 0;
390
- var sameGeneration = phase === "backend" || phase === "scan" || phase === "route";
390
+ var sameGeneration = phase === "backend" || phase === "route";
391
391
  var recovered = sameGeneration ? generation >= current.generation : generation > current.generation;
392
392
  if (!recovered) return;
393
393
  delete buildErrorStates[phase];
@@ -405,6 +405,12 @@ export const HMR_CLIENT_SCRIPT = `(function(){
405
405
  return dropped;
406
406
  }
407
407
 
408
+ function clearRenderError(){
409
+ if (!buildErrorStates.build) return;
410
+ delete buildErrorStates.build;
411
+ showBuildRecovered({});
412
+ }
413
+
408
414
  function showBuildRecovered(msg){
409
415
  if (hasBuildErrors()) {
410
416
  renderBuildErrorOverlay();
@@ -462,9 +468,12 @@ export const HMR_CLIENT_SCRIPT = `(function(){
462
468
  return prefix ? prefix + "\\n" + msg.message : msg.message;
463
469
  }
464
470
 
471
+ var rscRefreshSeq = 0;
472
+ var rscRefreshedSeq = 0;
465
473
  function refreshRsc(msg){
466
474
  var receivedAt = Date.now();
467
- var refresh = function(){ doRefreshRsc(msg, receivedAt); };
475
+ var seq = ++rscRefreshSeq;
476
+ var refresh = function(){ if (seq === rscRefreshSeq) doRefreshRsc(msg, receivedAt, seq); };
468
477
  var settle = function(){
469
478
  return self.__akan && typeof self.__akan.whenSettled === "function" ? self.__akan.whenSettled() : null;
470
479
  };
@@ -477,7 +486,7 @@ export const HMR_CLIENT_SCRIPT = `(function(){
477
486
  settled.then(refresh, refresh);
478
487
  }
479
488
 
480
- function doRefreshRsc(msg, receivedAt){
489
+ function doRefreshRsc(msg, receivedAt, seq){
481
490
  var started = performance.now();
482
491
  var overlayToken = beginHmrOverlay("Refreshing page...");
483
492
  try { self.__AKAN_RSC_CLEAR_CACHE__ && self.__AKAN_RSC_CLEAR_CACHE__(); } catch(e){}
@@ -488,9 +497,13 @@ export const HMR_CLIENT_SCRIPT = `(function(){
488
497
  return;
489
498
  }
490
499
  Promise.resolve(self.__AKAN_RSC_REFRESH__({ buildId: msg.buildId })).then(function(){
491
- lastBuildId = msg.buildId;
500
+ if (!(seq < rscRefreshedSeq)) {
501
+ rscRefreshedSeq = seq;
502
+ lastBuildId = msg.buildId;
503
+ }
492
504
  recordTrace("rsc-refresh", msg, receivedAt, Date.now());
493
505
  endHmrOverlay(overlayToken);
506
+ clearRenderError();
494
507
  console.debug && console.debug("[akan-hmr] RSC refreshed", {
495
508
  buildId: msg.buildId,
496
509
  generation: msg.generation,
@@ -207,21 +207,25 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
207
207
  const entry = script?.dataset.akanCsrEntry;
208
208
  if (!entry) throw new Error("[akan-csr] the app script carries no data-akan-csr-entry");
209
209
  this.#generation = generation;
210
- this.#refresh = this.#load(refresh).exports as RefreshRuntime;
211
-
212
- this.#refresh.injectIntoGlobalHook(host);
213
- this.#run(entry);
210
+ this.#run(entry, () => {
211
+ const runtime = this.#load(refresh).exports as RefreshRuntime;
212
+
213
+ runtime.injectIntoGlobalHook(host);
214
+ this.#refresh = runtime;
215
+ });
214
216
  }
215
217
 
216
218
  startLibrary({ generation, refresh, bootstrap, vendorFile, epoch }: LibraryStart) {
217
219
  this.#library = { vendorFile, epoch };
218
220
  this.#generation = generation;
219
- this.#refresh = this.#load(refresh).exports as RefreshRuntime;
220
- this.#run(bootstrap);
221
+ this.#run(bootstrap, () => {
222
+ this.#refresh = this.#load(refresh).exports as RefreshRuntime;
223
+ });
221
224
  }
222
225
 
223
- #run(entry: string) {
226
+ #run(entry: string, loadRefresh: () => void) {
224
227
  try {
228
+ loadRefresh();
225
229
  this.#load(entry);
226
230
  } catch (error) {
227
231
  this.#startFailed = true;
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import { resolveStaticPath } from "../staticPath";
3
4
  import {
@@ -38,6 +39,8 @@ export class CsrDevShell {
38
39
  readonly #appWaitMs: number;
39
40
  readonly #appPollMs: number;
40
41
  readonly #bootWaitMs: number;
42
+
43
+ readonly #sourceMaps = new Map<string, { stamp: string; body: string }>();
41
44
 
42
45
  constructor(
43
46
  artifactDir: string,
@@ -109,7 +112,8 @@ ${stylesheet} </head>
109
112
  const deadline = Date.now() + this.#appWaitMs;
110
113
  for (;;) {
111
114
  const manifest = await this.readManifest();
112
- if (!manifest || appGenerationOf(manifest) >= generation || Date.now() >= deadline || signal?.aborted) return;
115
+ if (!manifest || manifest.generation < generation || appGenerationOf(manifest) >= generation) return;
116
+ if (Date.now() >= deadline || signal?.aborted) return;
113
117
  await Bun.sleep(this.#appPollMs);
114
118
  }
115
119
  }
@@ -135,9 +139,26 @@ ${stylesheet} </head>
135
139
 
136
140
  async #serveSourceMap(name: string): Promise<Response> {
137
141
  const generatedFile = name.slice(0, -".map".length);
138
- const layoutFile = Bun.file(path.join(this.#dir, `${generatedFile}.layout.json`));
139
- if (!(await layoutFile.exists())) return new Response("Not Found", { status: 404 });
140
- const layout = (await layoutFile.json()) as CsrDevLayout;
142
+ const layoutPath = path.join(this.#dir, `${generatedFile}.layout.json`);
143
+ const stat = await fs.stat(layoutPath).catch(() => null);
144
+ if (!stat) return new Response("Not Found", { status: 404 });
145
+ const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
146
+ const cached = this.#sourceMaps.get(generatedFile);
147
+ const body = cached?.stamp === stamp ? cached.body : await this.#mergeSourceMap(generatedFile, layoutPath);
148
+ if (!body) return new Response("Not Found", { status: 404 });
149
+ this.#sourceMaps.delete(generatedFile);
150
+ this.#sourceMaps.set(generatedFile, { stamp, body });
151
+ for (const oldest of this.#sourceMaps.keys()) {
152
+ if (this.#sourceMaps.size <= 4) break;
153
+ this.#sourceMaps.delete(oldest);
154
+ }
155
+ return new Response(body, { headers: CsrDevShell.#headers("application/json", "no-store") });
156
+ }
157
+
158
+ async #mergeSourceMap(generatedFile: string, layoutPath: string): Promise<string | null> {
159
+ const layoutText = await fs.readFile(layoutPath, "utf8").catch(() => null);
160
+ if (!layoutText) return null;
161
+ const layout = JSON.parse(layoutText) as CsrDevLayout;
141
162
  const sections = await Promise.all(
142
163
  layout.modules.map(async ([id, line]) => {
143
164
  const file = Bun.file(path.join(this.#dir, csrDevModuleFile(id, ".js.map")));
@@ -149,7 +170,7 @@ ${stylesheet} </head>
149
170
  sections.filter((section) => section !== null),
150
171
  layout.lineCount,
151
172
  );
152
- return new Response(JSON.stringify(map), { headers: CsrDevShell.#headers("application/json", "no-store") });
173
+ return JSON.stringify(map);
153
174
  }
154
175
 
155
176
  static #js(body: string | Blob, cacheControl: string): Response {
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Logger } from "akanjs/common";
4
+ import type { CssAsset } from "../types";
5
+
6
+ export class DevArtifactPruner {
7
+
8
+ static readonly #defaultGraceMs = 60_000;
9
+ static readonly #pagesFile = /^pages-[^/\\]+\.js$/;
10
+ readonly #logger = new Logger("DevArtifactPruner");
11
+ readonly #artifactDir: string;
12
+ readonly #graceMs: number;
13
+ #pruning: Promise<void> = Promise.resolve();
14
+
15
+ constructor(artifactDir: string, { graceMs = DevArtifactPruner.#defaultGraceMs }: { graceMs?: number } = {}) {
16
+ this.#artifactDir = path.resolve(artifactDir);
17
+ this.#graceMs = graceMs;
18
+ }
19
+
20
+ prunePages(runningPath: string): Promise<void> {
21
+ return this.#queue(async () => {
22
+ const running = await fs.stat(runningPath).catch(() => null);
23
+ if (!running) return;
24
+ const keep = new Set([path.resolve(runningPath), ...(await this.#baseArtifact()).pages]);
25
+ const serverDir = path.join(this.#artifactDir, "server");
26
+ const before = Math.min(running.mtimeMs, Date.now() - this.#graceMs);
27
+ await this.#removeOlder(serverDir, (name) => DevArtifactPruner.#pagesFile.test(name), keep, before);
28
+ });
29
+ }
30
+
31
+ pruneStyles(cssAssets: Record<string, CssAsset>): Promise<void> {
32
+ return this.#queue(async () => {
33
+ const current = Object.values(cssAssets).map((asset) => path.join(this.#artifactDir, asset.cssRelPath));
34
+ const keep = new Set([...current, ...(await this.#baseArtifact()).styles]);
35
+ const stylesDir = path.join(this.#artifactDir, "styles");
36
+ await this.#removeOlder(stylesDir, (name) => name.endsWith(".css"), keep, Date.now() - this.#graceMs);
37
+ });
38
+ }
39
+
40
+ #queue(prune: () => Promise<void>): Promise<void> {
41
+ this.#pruning = this.#pruning.then(prune).catch((error: unknown) => {
42
+ this.#logger.verbose(`[dev-artifact] prune skipped: ${String(error)}`);
43
+ });
44
+ return this.#pruning;
45
+ }
46
+
47
+ async #removeOlder(dir: string, matches: (name: string) => boolean, keep: Set<string>, before: number) {
48
+ const names = await fs.readdir(dir).catch(() => [] as string[]);
49
+ let removed = 0;
50
+ for (const name of names) {
51
+ const file = path.join(dir, name);
52
+ if (!matches(name) || keep.has(file)) continue;
53
+ const stat = await fs.stat(file).catch(() => null);
54
+ if (!stat || stat.mtimeMs >= before) continue;
55
+
56
+ if (
57
+ await fs.rm(file, { force: true }).then(
58
+ () => true,
59
+ () => false,
60
+ )
61
+ )
62
+ removed += 1;
63
+ }
64
+ if (removed > 0) this.#logger.verbose(`[dev-artifact] removed ${removed} superseded file(s) from ${dir}`);
65
+ }
66
+
67
+ async #baseArtifact(): Promise<{ pages: string[]; styles: string[] }> {
68
+ const text = await fs.readFile(path.join(this.#artifactDir, "base-artifact.json"), "utf8").catch(() => null);
69
+ const artifact = text
70
+ ? (JSON.parse(text) as { pagesBundlePath?: string; cssAssets?: Record<string, CssAsset> })
71
+ : null;
72
+ return {
73
+ pages: artifact?.pagesBundlePath ? [path.resolve(this.#artifactDir, artifact.pagesBundlePath)] : [],
74
+ styles: Object.values(artifact?.cssAssets ?? {}).map((asset) => path.join(this.#artifactDir, asset.cssRelPath)),
75
+ };
76
+ }
77
+ }
@@ -1,9 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Logger } from "akanjs/common";
4
+ import { CrossSiteGuard } from "../../signal/CrossSiteGuard";
4
5
  import {
5
6
  BuilderRpc,
6
7
  type ClientManifest,
8
+ DevBuildRecovery,
7
9
  type DevBuildStatus,
8
10
  type HmrTrace,
9
11
  RouteClientCache,
@@ -13,6 +15,7 @@ import {
13
15
  import type { RscReloadFailure, RscWorker } from "../rscWorkerHost";
14
16
  import type { RenderState } from "../types";
15
17
  import { CSR_DEV_DIRNAME, CSR_DEV_MANIFEST_FILE, type CsrDevManifest, SSR_DEV_DIRNAME } from "./csrDevManifest";
18
+ import { DevArtifactPruner } from "./devArtifactPruner";
16
19
  import { isAkanRuntimeMetadataFile } from "./runtimeMetadataFile";
17
20
  import { type SsrUpdateMessage, SsrUpdateQueue } from "./ssrUpdateQueue";
18
21
  import { type ChangeKind, type HmrMessage, type HmrWsData, HmrWsHub } from "./wsHub";
@@ -49,11 +52,7 @@ export function devBuildStatusToHmrMessage(
49
52
  files: status.files.length,
50
53
  };
51
54
  }
52
- if (!previous || previous.ok) return null;
53
-
54
- const sameGeneration = status.phase === "backend" || status.phase === "scan" || status.phase === "route";
55
- const recovered = sameGeneration ? status.generation >= previous.generation : status.generation > previous.generation;
56
- if (!recovered) return null;
55
+ if (!DevBuildRecovery.recovers(previous, status)) return null;
57
56
  return {
58
57
  type: "build-status",
59
58
  status: "ok",
@@ -85,6 +84,8 @@ export class DevHmrController {
85
84
  readonly #ssrUpdates = new SsrUpdateQueue((message) => this.#sendSsrUpdate(message));
86
85
  readonly #builderRpc: BuilderRpc;
87
86
  readonly routeCache: RouteClientCache;
87
+ #recheckingRoute = false;
88
+ #recheckAgain: DevBuildStatus | null = null;
88
89
  readonly #recentClientEntries = new Set<string>();
89
90
  /** File to the newest save that invalidated its routes already: that save's pages build must not drop them again. */
90
91
  readonly #earlyInvalidated = new Map<string, number>();
@@ -97,10 +98,12 @@ export class DevHmrController {
97
98
  readonly #buildStatusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>();
98
99
  #graphSeeds: string[];
99
100
  #runningBundlePath: string | null;
101
+ readonly #pruner: DevArtifactPruner;
100
102
 
101
103
  constructor({ artifactDir, renderState, rsc, seedIndex, upgradeHmrWs, pagesBundlePath }: DevHmrControllerOptions) {
102
104
  this.#artifactDir = artifactDir;
103
105
  this.#runningBundlePath = pagesBundlePath ? path.resolve(pagesBundlePath) : null;
106
+ this.#pruner = new DevArtifactPruner(artifactDir);
104
107
  this.#renderState = renderState;
105
108
  this.#rsc = rsc;
106
109
  this.#seedIndex = seedIndex;
@@ -155,28 +158,17 @@ export class DevHmrController {
155
158
  }
156
159
 
157
160
  handleWs(req: Request): Response | undefined {
161
+
162
+ try {
163
+ CrossSiteGuard.assertOrigin(req, new URL(req.url), "hmr");
164
+ } catch {
165
+ return new Response("Forbidden", { status: 403 });
166
+ }
158
167
  const client = new URL(req.url).searchParams.get("client") === "csr" ? "csr" : "ssr";
159
168
  if (this.#upgradeHmrWs(req, { kind: "akan-hmr", openedAt: Date.now(), client })) return;
160
169
  return new Response("Failed to upgrade HMR WebSocket", { status: 500 });
161
170
  }
162
171
 
163
- async ensureRoute(url: URL) {
164
- const started = Date.now();
165
- const matched =
166
- RouteSeedIndexStore.match(url.pathname, this.#seedIndex.entries) ??
167
- RouteSeedIndexStore.matchPrefix(url.pathname, this.#seedIndex.entries);
168
- if (matched) await this.routeCache.ensure(matched.entry.routeId, matched.entry.seeds);
169
- this.#logger.verbose(
170
- `[route-cache] ensure pathname=${url.pathname} routeId=${matched?.entry.routeId ?? "(none)"} in ${Date.now() - started}ms`,
171
- );
172
- return this.routeCache.snapshot();
173
- }
174
-
175
- routeIdsForPath(pathname: string): string[] | undefined {
176
- const matched = RouteSeedIndexStore.match(pathname, this.#seedIndex.entries);
177
- return matched ? [matched.entry.routeId] : undefined;
178
- }
179
-
180
172
  #sendSsrUpdate(message: SsrUpdateMessage): void {
181
173
  this.#renderState.ssrGeneration = message.generation;
182
174
  this.#hub.broadcast({ ...message, trace: DevHmrController.#broadcastTrace(message.trace) });
@@ -235,6 +227,7 @@ export class DevHmrController {
235
227
  this.#renderState.cssBytesByUrl = cssBytesByUrl;
236
228
  this.#rsc.updateCssAssets(this.#renderState.cssAssets);
237
229
  this.#hub.broadcast({ type: "css-update", cssAssets: this.#renderState.cssAssets });
230
+ void this.#pruner.pruneStyles(this.#renderState.cssAssets);
238
231
  this.#logger.verbose(
239
232
  `css-update assets=${Object.keys(this.#renderState.cssAssets).length} generation=${css.generation ?? "(unknown)"} files=${css.changedFiles?.length ?? 0} in ${Date.now() - started}ms (ipc)`,
240
233
  );
@@ -266,6 +259,7 @@ export class DevHmrController {
266
259
  const manifest = this.routeCache.snapshot();
267
260
 
268
261
  const running = bundlePath ? path.resolve(bundlePath) : null;
262
+ let tabBuildId = buildId;
269
263
  if (serverTouched === false && !clearAll && running === this.#runningBundlePath) {
270
264
  this.#logger.verbose(
271
265
  `[SSR] pages bundle unchanged for the server; buildId ${this.#renderState.buildId} kept`,
@@ -275,25 +269,29 @@ export class DevHmrController {
275
269
  this.#renderState.buildId = buildId;
276
270
  const reloadStarted = Date.now();
277
271
  try {
278
- this.#runningBundlePath = path.resolve(
279
- await this.#rsc.reload({
280
- clientManifest: manifest.clientManifest,
281
- cssAssets: this.#renderState.cssAssets,
282
- buildId,
283
- pagesBundlePath: bundlePath,
284
- }),
285
- );
272
+ const adopted = await this.#rsc.reload({
273
+ clientManifest: manifest.clientManifest,
274
+ cssAssets: this.#renderState.cssAssets,
275
+ buildId,
276
+ pagesBundlePath: bundlePath,
277
+ });
278
+ this.#adoptBundle(adopted.pagesBundlePath);
279
+
280
+ tabBuildId = adopted.buildId;
286
281
  } catch (error) {
287
282
  const failure = DevHmrController.#reloadFailure(error);
288
283
 
289
284
  if (failure) {
290
285
  this.#renderState.buildId = failure.adopted.buildId;
291
- this.#runningBundlePath = path.resolve(failure.adopted.pagesBundlePath);
286
+ this.#adoptBundle(failure.adopted.pagesBundlePath);
292
287
  } else if (this.#renderState.buildId === buildId) this.#renderState.buildId = previousBuildId;
293
288
 
294
- if (failure?.superseded) this.#ssrUpdates.release(generation);
295
- else this.#failPagesReload(generation, files, error);
296
- return;
289
+ if (failure?.adopted.buildId !== buildId) {
290
+
291
+ if (failure && failure.failed.buildId !== buildId) this.#ssrUpdates.release(generation);
292
+ else this.#failPagesReload(generation, files, error);
293
+ return;
294
+ }
297
295
  }
298
296
  this.#logger.verbose(`[SSR] rsc reload buildId=${buildId} in ${Date.now() - reloadStarted}ms`);
299
297
  }
@@ -301,7 +299,7 @@ export class DevHmrController {
301
299
  const broadcastTrace = DevHmrController.#broadcastTrace(trace);
302
300
  if (shouldReload) this.#ssrUpdates.clear(generation);
303
301
  const released = shouldReload ? { released: 0, reload: false } : this.#ssrUpdates.release(generation);
304
- if (shouldReload) this.#hub.broadcast({ type: "reload", buildId });
302
+ if (shouldReload) this.#hub.broadcast({ type: "reload", buildId: tabBuildId });
305
303
  else if (released.reload)
306
304
  this.#logger.verbose(`[hmr] generation=${generation} released a registry reload; no RSC refresh needed`);
307
305
  else if (serverTouched === false)
@@ -311,7 +309,7 @@ export class DevHmrController {
311
309
  else
312
310
  this.#hub.broadcast({
313
311
  type: "rsc-refresh",
314
- buildId,
312
+ buildId: tabBuildId,
315
313
  generation,
316
314
  changedFiles,
317
315
  routeIds,
@@ -324,6 +322,13 @@ export class DevHmrController {
324
322
  });
325
323
  }
326
324
 
325
+ #adoptBundle(bundlePath: string): void {
326
+ const running = path.resolve(bundlePath);
327
+ if (running === this.#runningBundlePath) return;
328
+ this.#runningBundlePath = running;
329
+ void this.#pruner.prunePages(running);
330
+ }
331
+
327
332
  static #reloadFailure(error: unknown): RscReloadFailure | null {
328
333
  return error instanceof Error && "adopted" in error ? (error as RscReloadFailure) : null;
329
334
  }
@@ -351,6 +356,7 @@ export class DevHmrController {
351
356
  }
352
357
 
353
358
  #recordBuildStatus(status: DevBuildStatus): void {
359
+ this.#recheckFailedRoute(status);
354
360
  const previous = this.#buildStatusByPhase.get(status.phase);
355
361
  const message = devBuildStatusToHmrMessage(status, previous);
356
362
 
@@ -363,6 +369,26 @@ export class DevHmrController {
363
369
  );
364
370
  }
365
371
 
372
+ #recheckFailedRoute(status: DevBuildStatus, { again = false }: { again?: boolean } = {}): void {
373
+ if (!status.ok || (status.phase !== "pages" && status.phase !== "ssr")) return;
374
+ if (this.#recheckingRoute) {
375
+ this.#recheckAgain = status;
376
+ return;
377
+ }
378
+ const failed = this.#buildStatusByPhase.get("route");
379
+ if (!failed || failed.ok || !failed.scope || (!again && failed.generation >= status.generation)) return;
380
+ this.#recheckingRoute = true;
381
+ void this.routeCache
382
+ .ensure(failed.scope, failed.files)
383
+ .catch(() => undefined)
384
+ .finally(() => {
385
+ this.#recheckingRoute = false;
386
+ const next = this.#recheckAgain;
387
+ this.#recheckAgain = null;
388
+ if (next) this.#recheckFailedRoute(next, { again: true });
389
+ });
390
+ }
391
+
366
392
  #createRouteCache() {
367
393
  return new RouteClientCache({
368
394
  buildRoute: async (routeId, { seeds, knownEntries, generation }) => {
@@ -387,17 +413,16 @@ export class DevHmrController {
387
413
  if (delta.newEntries.length === 0 && removedEntries.size === 0) return;
388
414
  this.#rememberClientDeps(routeId, delta.clientDeps, delta.clientDepsByEntry);
389
415
  try {
390
- this.#runningBundlePath = path.resolve(
391
- await this.#rsc.reload({
392
- clientManifest: nextMerged.clientManifest,
393
- cssAssets: this.#renderState.cssAssets,
394
- buildId: this.#renderState.buildId,
395
- }),
396
- );
416
+ const adopted = await this.#rsc.reload({
417
+ clientManifest: nextMerged.clientManifest,
418
+ cssAssets: this.#renderState.cssAssets,
419
+ buildId: this.#renderState.buildId,
420
+ });
421
+ this.#adoptBundle(adopted.pagesBundlePath);
397
422
  } catch (error) {
398
423
 
399
424
  const failure = DevHmrController.#reloadFailure(error);
400
- if (failure) this.#runningBundlePath = path.resolve(failure.adopted.pagesBundlePath);
425
+ if (failure) this.#adoptBundle(failure.adopted.pagesBundlePath);
401
426
  this.#logger.warn(`[SSR] route ${routeId} merged, but the worker did not reload: ${String(error)}`);
402
427
  }
403
428
  this.#logger.verbose(
@@ -52,9 +52,15 @@ export class SsrDevShim {
52
52
  var hello = self.__AKAN_SSR_HELLO_GENERATION__;
53
53
  if (typeof hello === "number") self.__akan.catchUp(hello, c.prefix);
54
54
  }
55
+ function runtime(attempt) {
56
+ return script(c.runtime).catch(function (error) {
57
+ if (attempt >= 3) throw error;
58
+ return wait(500).then(state).then(function () { return runtime(attempt + 1); });
59
+ });
60
+ }
55
61
  function boot() {
56
62
  if (!booting)
57
- booting = self.__AKAN_SSR_BOOT__ = script(c.runtime).then(function () {
63
+ booting = self.__AKAN_SSR_BOOT__ = runtime(0).then(function () {
58
64
  var early = self.__AKAN_SSR_EARLY_UPDATES__ || [];
59
65
  self.__AKAN_SSR_EARLY_UPDATES__ = null;
60
66
  for (var i = 0; i < early.length; i++) self.__akan.hot(early[i]);
@@ -387,7 +387,12 @@ export class RscRenderer {
387
387
  this.#resultCache.clear();
388
388
  this.#patchResultCache.clear();
389
389
  this.#logger.verbose(`reload complete buildId=${msg.buildId} in ${Date.now() - startedAt}ms`);
390
- this.#send({ type: "reloaded", buildId: msg.buildId, reloadId: msg.reloadId });
390
+ this.#send({
391
+ type: "reloaded",
392
+ buildId: msg.buildId,
393
+ reloadId: msg.reloadId,
394
+ pagesBundlePath: nextPagesBundlePath,
395
+ });
391
396
  } catch (error) {
392
397
  this.#logger.error(
393
398
  `reload failed buildId=${msg.buildId}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
@@ -399,6 +404,7 @@ export class RscRenderer {
399
404
  requestId: "__reload__",
400
405
  buildId: msg.buildId,
401
406
  reloadId: msg.reloadId,
407
+ running: { pagesBundlePath: this.#pagesBundlePath, buildId: this.#pagesBundleBuildId },
402
408
  message: error instanceof Error ? error.message : String(error),
403
409
  });
404
410
  }
@@ -297,7 +297,7 @@ export function createRscHostRenderStream(input: {
297
297
  type RscInMsg =
298
298
  | { type: "hello" }
299
299
  | { type: "ready" }
300
- | { type: "reloaded"; buildId: number; reloadId?: number }
300
+ | { type: "reloaded"; buildId: number; reloadId?: number; pagesBundlePath?: string }
301
301
  | { type: "meta"; requestId: string; theme?: AkanTheme; status?: number; trace?: RscTraceMetadata }
302
302
  | { type: "cache-state"; requestId: string; state: RouteCacheRenderState }
303
303
  | { type: "chunk"; requestId: string; data: Uint8Array }
@@ -315,7 +315,14 @@ type RscInMsg =
315
315
  | { type: "log.records"; records: LogRecord[]; dropped?: number }
316
316
  | { type: "page-prompts.result"; requestId: string; result: PagePromptEntry[] }
317
317
  | { type: "page-prompt.result"; requestId: string; result: PagePromptRun }
318
- | { type: "error"; requestId: string; message: string; buildId?: number; reloadId?: number };
318
+ | {
319
+ type: "error";
320
+ requestId: string;
321
+ message: string;
322
+ buildId?: number;
323
+ reloadId?: number;
324
+ running?: RscAdoptedBundle;
325
+ };
319
326
 
320
327
  export interface RscWorkerReloadInput {
321
328
  clientManifest: ClientManifest;
@@ -325,20 +332,22 @@ export interface RscWorkerReloadInput {
325
332
  pagesBundlePath?: string;
326
333
  }
327
334
 
328
- interface RscAdoptedBundle {
335
+ export interface RscAdoptedBundle {
329
336
  pagesBundlePath: string;
330
337
  buildId: number;
331
338
  }
332
339
 
333
340
  interface RscReloadWaiter {
334
- resolve: (pagesBundlePath: string) => void;
341
+ resolve: (adopted: RscAdoptedBundle) => void;
335
342
  reject: (err: RscReloadFailure) => void;
336
343
  }
337
344
 
338
345
  /** A reload the worker did not take: what it runs instead, and whether a later reload failed in this one's place. */
339
346
  export interface RscReloadFailure extends Error {
347
+ /** What the worker runs now. */
340
348
  adopted: RscAdoptedBundle;
341
- superseded: boolean;
349
+ /** The latest state it was asked to take, which a later reload may have moved past the caller's own. */
350
+ failed: RscAdoptedBundle;
342
351
  }
343
352
 
344
353
  type WorkerStatus = "starting" | "ready" | "restarting" | "stopped";
@@ -371,6 +380,9 @@ export class RscWorker {
371
380
  buildId: number;
372
381
  } | null = null;
373
382
  #nextReloadId = 1;
383
+ #answeredReloadId = 0;
384
+
385
+ #droppedWhileBooting = false;
374
386
  #reloadState = 0;
375
387
  #init: RscAdoptedBundle & { reloadState: number } = { pagesBundlePath: "", buildId: 0, reloadState: 0 };
376
388
  /** What the worker runs: a respawn falls back to it when the bundle a reload named failed to import. */
@@ -378,7 +390,7 @@ export class RscWorker {
378
390
 
379
391
  #status: WorkerStatus = "starting";
380
392
  #killed = false;
381
- #queuedSends: Array<() => void> = [];
393
+ #queuedSends: { requestId: string; send: () => void }[] = [];
382
394
  #restartAttempts = 0;
383
395
  #restartCount = 0;
384
396
  #recycleCount = 0;
@@ -391,9 +403,11 @@ export class RscWorker {
391
403
  #hostPendingChunkOverflowCount = 0;
392
404
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
393
405
  #recycleTimer: ReturnType<typeof setTimeout> | null = null;
394
- #rollingRecycle: { oldProc: RscProcess; reason: string } | null = null;
406
+ #rollingRecycle: { oldProc: RscProcess; reason: string; reloadsSinceSpawn: number } | null = null;
407
+ #booting: RscProcess | null = null;
395
408
 
396
409
  readonly #failBeforeReady: boolean;
410
+ static readonly #queuedRespawns = 2;
397
411
 
398
412
  constructor(artifact: BaseBuildArtifact, { failBeforeReady = false }: { failBeforeReady?: boolean } = {}) {
399
413
  this.#failBeforeReady = failBeforeReady;
@@ -462,7 +476,7 @@ export class RscWorker {
462
476
  if (this.#status === "ready") send();
463
477
  else if (this.#status === "stopped")
464
478
  this.#settleCall(requestId, (call) => call.reject(new Error("rsc worker is stopped")));
465
- else this.#queuedSends.push(send);
479
+ else this.#queuedSends.push({ requestId, send });
466
480
  });
467
481
  }
468
482
 
@@ -475,7 +489,10 @@ export class RscWorker {
475
489
  }
476
490
 
477
491
  invalidateRouteResultCache(invalidation?: string | RouteCacheInvalidation): void {
478
- if (this.#status !== "ready") return;
492
+ if (this.#status !== "ready") {
493
+ this.#droppedWhileBooting = true;
494
+ return;
495
+ }
479
496
  try {
480
497
  this.#proc.send(createRscWorkerInvalidateCacheMessage(invalidation));
481
498
  } catch (error) {
@@ -499,6 +516,7 @@ export class RscWorker {
499
516
 
500
517
  setLogLevel(minSev: number | null) {
501
518
  this.#logLevel = minSev;
519
+ if (this.#status !== "ready") this.#droppedWhileBooting = true;
502
520
  this.#sendLogLevel();
503
521
  }
504
522
 
@@ -527,7 +545,7 @@ export class RscWorker {
527
545
  if (this.#killed || this.#status === "stopped" || this.#status === "starting" || this.#status === "restarting") {
528
546
  return false;
529
547
  }
530
- if (this.#pending.size > 0 || this.#queuedSends.length > 0) {
548
+ if (this.#pending.size > 0 || this.#calls.size > 0 || this.#queuedSends.length > 0) {
531
549
  if (!this.#recycleTimer) {
532
550
  const graceMs = MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_RECYCLE_GRACE_MS") ?? 5_000;
533
551
  this.#recycleTimer = setTimeout(() => {
@@ -542,14 +560,17 @@ export class RscWorker {
542
560
  this.#lastRecycleAtMono = performance.now();
543
561
  this.#logger.info(`[rsc] rolling recycle worker reason=${reason} oldPid=${this.#proc.pid}`);
544
562
  this.#status = "restarting";
545
- this.#rollingRecycle = { oldProc: this.#proc, reason };
563
+ this.#rollingRecycle = { oldProc: this.#proc, reason, reloadsSinceSpawn: this.#reloadsSinceSpawn };
546
564
  this.#proc = this.#spawn();
547
565
  return true;
548
566
  }
549
567
 
550
568
  updateCssAssets(cssAssets: Record<string, CssAsset>): void {
551
569
  this.#cssAssets = cssAssets;
552
- if (this.#status !== "ready") return;
570
+ if (this.#status !== "ready") {
571
+ this.#droppedWhileBooting = true;
572
+ return;
573
+ }
553
574
  try {
554
575
  this.#proc.send({ type: "updateCssAssets", cssAssets });
555
576
  } catch {
@@ -557,13 +578,14 @@ export class RscWorker {
557
578
  }
558
579
 
559
580
  /** Resolves with the pages bundle the worker runs once it has taken this reload, or a later one. */
560
- reload(input: RscWorkerReloadInput): Promise<string> {
581
+ reload(input: RscWorkerReloadInput): Promise<RscAdoptedBundle> {
582
+ if (this.#killed) return Promise.reject(new Error("rsc worker is stopped"));
561
583
  this.#reloadState += 1;
562
584
  this.#clientManifest = input.clientManifest;
563
585
  this.#cssAssets = input.cssAssets ?? this.#cssAssets;
564
586
  this.#pagesBundleBuildId = input.buildId;
565
587
  if (input.pagesBundlePath) this.#pagesBundlePath = input.pagesBundlePath;
566
- return new Promise<string>((resolve, reject) => {
588
+ return new Promise<RscAdoptedBundle>((resolve, reject) => {
567
589
 
568
590
  this.#pendingReload = {
569
591
  waiters: [...(this.#pendingReload?.waiters ?? []), { resolve, reject }],
@@ -580,7 +602,9 @@ export class RscWorker {
580
602
  const pending = this.#pendingReload;
581
603
  if (!pending) return;
582
604
 
583
- if (this.#shouldRecycleForReloadAccumulation() && this.restartWhenIdle("pages-reload-accumulation")) return;
605
+ const answering = this.#answeredReloadId < this.#nextReloadId - 1;
606
+ if (!answering && this.#shouldRecycleForReloadAccumulation() && this.restartWhenIdle("pages-reload-accumulation"))
607
+ return;
584
608
  this.#reloadsSinceSpawn += 1;
585
609
  pending.reloadId = this.#nextReloadId++;
586
610
  try {
@@ -604,7 +628,7 @@ export class RscWorker {
604
628
  const pending = this.#pendingReload;
605
629
  if (!pending) return;
606
630
  this.#pendingReload = null;
607
- for (const waiter of pending.waiters) waiter.resolve(adopted.pagesBundlePath);
631
+ for (const waiter of pending.waiters) waiter.resolve(adopted);
608
632
  }
609
633
 
610
634
  #rejectReload(message: string): void {
@@ -613,10 +637,9 @@ export class RscWorker {
613
637
  const pending = this.#pendingReload;
614
638
  if (!pending) return;
615
639
  this.#pendingReload = null;
616
- for (const [idx, waiter] of pending.waiters.entries())
617
- waiter.reject(
618
- Object.assign(new Error(message), { adopted: this.#adopted, superseded: idx < pending.waiters.length - 1 }),
619
- );
640
+ const failed = { pagesBundlePath: pending.pagesBundlePath, buildId: pending.buildId };
641
+ for (const waiter of pending.waiters)
642
+ waiter.reject(Object.assign(new Error(message), { adopted: this.#adopted, failed }));
620
643
  }
621
644
 
622
645
  #spawn(): RscProcess {
@@ -695,6 +718,7 @@ export class RscWorker {
695
718
  switch (message.type) {
696
719
  case "hello":
697
720
 
721
+ this.#booting = proc;
698
722
  this.#init = {
699
723
  pagesBundlePath: this.#pagesBundlePath,
700
724
  buildId: this.#pagesBundleBuildId,
@@ -711,7 +735,10 @@ export class RscWorker {
711
735
  });
712
736
  return;
713
737
  case "ready":
738
+ this.#booting = null;
714
739
  this.#status = "ready";
740
+ this.#answeredReloadId = this.#nextReloadId - 1;
741
+ this.#droppedWhileBooting = false;
715
742
  this.#restartAttempts = 0;
716
743
  this.#resolveReady();
717
744
  this.#finishRollingRecycle();
@@ -725,6 +752,15 @@ export class RscWorker {
725
752
  return;
726
753
  case "reloaded": {
727
754
  const pending = this.#pendingReload;
755
+
756
+ if (proc === this.#proc && message.pagesBundlePath) {
757
+ this.#adopted = { pagesBundlePath: message.pagesBundlePath, buildId: message.buildId };
758
+ this.#answeredReloadId = Math.max(this.#answeredReloadId, message.reloadId ?? 0);
759
+ if (!pending) {
760
+ this.#pagesBundlePath = message.pagesBundlePath;
761
+ this.#pagesBundleBuildId = message.buildId;
762
+ }
763
+ }
728
764
  if (pending && pending.reloadId !== null && pending.reloadId === message.reloadId)
729
765
  this.#resolveReload({ pagesBundlePath: pending.pagesBundlePath, buildId: pending.buildId });
730
766
  return;
@@ -770,23 +806,14 @@ export class RscWorker {
770
806
  this.#rejectReload(String(message.message));
771
807
  this.#rejectReady(new Error(String(message.message)));
772
808
  } else {
773
- this.#logger.error(`[rsc] worker init error on restart: ${message.message}`);
774
-
775
- const covered = !this.#pendingReload || this.#pendingReload.reloadState <= this.#init.reloadState;
776
- if (covered) this.#rejectReload(String(message.message));
777
- const rolling = this.#rollingRecycle;
778
- if (covered && rolling && proc === this.#proc) {
779
-
780
- this.#rollingRecycle = null;
781
- this.#proc = rolling.oldProc;
782
- this.#status = "ready";
783
- this.#flushQueuedSends();
784
- }
809
+ this.#bootFailed(proc, String(message.message));
785
810
  proc.kill();
786
811
  }
787
812
  return;
788
813
  }
789
814
  if (message.requestId === "__reload__") {
815
+ if (proc === this.#proc) this.#answeredReloadId = Math.max(this.#answeredReloadId, message.reloadId ?? 0);
816
+ if (proc === this.#proc && message.running) this.#adopted = message.running;
790
817
  const pending = this.#pendingReload;
791
818
  if (pending && pending.reloadId !== null && pending.reloadId === message.reloadId)
792
819
  this.#rejectReload(String(message.message));
@@ -824,7 +851,7 @@ export class RscWorker {
824
851
  else if (this.#status === "stopped") {
825
852
  this.#resolvePending(requestId, (p) => p.onError("rsc worker is stopped"));
826
853
  } else {
827
- this.#queuedSends.push(send);
854
+ this.#queuedSends.push({ requestId, send });
828
855
  }
829
856
  }
830
857
 
@@ -839,7 +866,7 @@ export class RscWorker {
839
866
  #flushQueuedSends(): void {
840
867
  const queue = this.#queuedSends;
841
868
  this.#queuedSends = [];
842
- for (const send of queue) {
869
+ for (const { send } of queue) {
843
870
  try {
844
871
  send();
845
872
  } catch (err) {
@@ -848,6 +875,46 @@ export class RscWorker {
848
875
  }
849
876
  }
850
877
 
878
+ #failRequests(err: Error, which: (requestId: string) => boolean = () => true): void {
879
+ for (const [requestId, pending] of this.#pending) {
880
+ if (!which(requestId)) continue;
881
+ this.#pending.delete(requestId);
882
+ pending.onError(err.message);
883
+ }
884
+ for (const [requestId, call] of this.#calls) {
885
+ if (!which(requestId)) continue;
886
+ this.#calls.delete(requestId);
887
+ call.reject(err);
888
+ }
889
+ this.#queuedSends = this.#queuedSends.filter((entry) => !which(entry.requestId));
890
+ }
891
+
892
+ #bootFailed(proc: RscProcess, message: string): boolean {
893
+ this.#booting = null;
894
+ this.#logger.error(`[rsc] worker init error on restart: ${message}`);
895
+ const covered = !this.#pendingReload || this.#pendingReload.reloadState <= this.#init.reloadState;
896
+ if (covered) this.#rejectReload(message);
897
+ const rolling = proc === this.#proc ? this.#rollingRecycle : null;
898
+ this.#rollingRecycle = null;
899
+ if (!rolling || !RscWorker.#isAlive(rolling.oldProc)) return false;
900
+ this.#proc = rolling.oldProc;
901
+ this.#status = "ready";
902
+ this.#reloadsSinceSpawn = rolling.reloadsSinceSpawn;
903
+ if (this.#droppedWhileBooting) {
904
+ this.#droppedWhileBooting = false;
905
+ this.updateCssAssets(this.#cssAssets);
906
+ this.#sendLogLevel();
907
+ this.invalidateRouteResultCache();
908
+ }
909
+ this.#flushQueuedSends();
910
+ if (!covered) this.#sendPendingReload();
911
+ return true;
912
+ }
913
+
914
+ static #isAlive(proc: RscProcess): boolean {
915
+ return proc.exitCode === null && proc.signalCode === null && !proc.killed;
916
+ }
917
+
851
918
  #finishRollingRecycle(): void {
852
919
  const recycle = this.#rollingRecycle;
853
920
  if (!recycle) return;
@@ -861,22 +928,41 @@ export class RscWorker {
861
928
 
862
929
  #handleExit(proc: RscProcess, code: number | null): void {
863
930
 
931
+ if (proc === this.#rollingRecycle?.oldProc) {
932
+ this.#rollingRecycle = null;
933
+ return;
934
+ }
935
+
864
936
  if (proc !== this.#proc) return;
865
937
 
938
+ if (proc === this.#booting && !this.#killed && proc.signalCode === null) {
939
+ const message = `rsc worker exited with code ${code} while loading the pages bundle`;
940
+ if (this.#readyResolved && this.#bootFailed(proc, message)) return;
941
+ if (!this.#readyResolved) {
942
+ this.#booting = null;
943
+ this.#status = "stopped";
944
+ this.#failRequests(new Error(message));
945
+ this.#rejectReload(message);
946
+ this.#rejectReady(new Error(message));
947
+ return;
948
+ }
949
+ }
950
+
866
951
  const err = new Error(`rsc worker exited with code ${code}`);
867
- for (const [, p] of this.#pending) p.onError(err.message);
868
- this.#pending.clear();
869
- for (const [, call] of this.#calls) call.reject(err);
870
- this.#calls.clear();
871
- this.#queuedSends = [];
952
+
953
+ const queued = new Set(this.#queuedSends.map((entry) => entry.requestId));
954
+ const keepsQueue = this.#restartAttempts < RscWorker.#queuedRespawns;
955
+ this.#failRequests(err, (requestId) => !keepsQueue || !queued.has(requestId));
872
956
 
873
957
  if (this.#killed) {
874
958
  this.#status = "stopped";
959
+ this.#failRequests(err);
875
960
  this.#rejectReload(err.message);
876
961
  return;
877
962
  }
878
963
  if (this.#failBeforeReady && !this.#readyResolved) {
879
964
  this.#status = "stopped";
965
+ this.#failRequests(err);
880
966
  this.#rejectReload(err.message);
881
967
  this.#rejectReady(err);
882
968
  return;
@@ -913,7 +999,7 @@ export class RscWorker {
913
999
  }
914
1000
 
915
1001
  #maybeRecycleFromMetrics(metrics: AkanMetricsReport): void {
916
- if (this.#pending.size > 0) return;
1002
+ if (this.#pending.size > 0 || this.#calls.size > 0) return;
917
1003
  const maxRssBytes = RscWorker.#getRscMaxRssBytes();
918
1004
  if (maxRssBytes && metrics.rssBytes && metrics.rssBytes >= maxRssBytes) {
919
1005
  this.restartWhenIdle(`rss>${Math.round(maxRssBytes / 1024 / 1024)}MiB`);
@@ -19,6 +19,7 @@ import {
19
19
  RouteClientCache,
20
20
  type RouteSeedIndex,
21
21
  RouteSeedIndexStore,
22
+ type RoutesManifest,
22
23
  RoutesManifestStore,
23
24
  } from "./artifact";
24
25
  import {
@@ -251,6 +252,8 @@ interface WebRouterOptions {
251
252
  rsc: RscWorker;
252
253
  seedIndex: RouteSeedIndex;
253
254
  upgradeHmrWs: (req: Request, data: HmrWsData) => boolean;
255
+ /** The production build's route manifest, which the worker was already handed at boot. */
256
+ prebuilt?: RoutesManifest | null;
254
257
  }
255
258
 
256
259
  interface CachedHtmlResult {
@@ -300,7 +303,8 @@ export class WebRouter {
300
303
  /** What this router actually mounts, already intersected with what the artifact carries. */
301
304
  readonly web: AkanWebConfig;
302
305
  #seedIndex: RouteSeedIndex;
303
- constructor({ artifact, web, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs }: WebRouterOptions) {
306
+ readonly #prebuilt: RoutesManifest | null;
307
+ constructor({ artifact, web, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs, prebuilt = null }: WebRouterOptions) {
304
308
  this.#logger.verbose(`[SSR] loaded ${Object.keys(cssBytesByUrl).length} CSS assets`);
305
309
  this.web = web;
306
310
  if (process.env.NODE_ENV === "production" && !this.#prodMode)
@@ -320,6 +324,7 @@ export class WebRouter {
320
324
  cssBytesByUrl,
321
325
  };
322
326
  this.#seedIndex = seedIndex;
327
+ this.#prebuilt = prebuilt;
323
328
  if (this.#prodMode) {
324
329
  this.#builderRpc = null;
325
330
  this.#routeCache = new RouteClientCache({
@@ -350,16 +355,7 @@ export class WebRouter {
350
355
  }
351
356
 
352
357
  async initializeRoute() {
353
- const prebuilt = this.#prodMode ? await RoutesManifestStore.read(this.#artifactDir) : null;
354
- if (prebuilt) {
355
- this.#routeCache.seed(prebuilt);
356
- await this.#rsc.reload({
357
- clientManifest: this.#mergeRuntimeManifest().clientManifest,
358
- cssAssets: this.renderState.cssAssets,
359
-
360
- buildId: this.#artifact.pagesBundleBuildId,
361
- });
362
- }
358
+ if (this.#prebuilt) this.#routeCache.seed(this.#prebuilt);
363
359
 
364
360
  const clientServePrefix = `/_akan/client`;
365
361
  const clientOutputDir = `${this.#artifactDir}/client`;
@@ -398,7 +394,7 @@ export class WebRouter {
398
394
  <script type="module" src="/csr.js"></script>
399
395
  </body>
400
396
  </html>`;
401
- return new Response(this.#withCsrHmr(htmlText), { headers: WebRouter.#htmlResponseHeaders(200) });
397
+ return new Response(this.#withCsrHmr(htmlText), { headers: this.#htmlResponseHeaders(200) });
402
398
  },
403
399
  ...(this.#csrDevShell
404
400
  ? {
@@ -549,7 +545,7 @@ export class WebRouter {
549
545
  const csrHtml = await this.#resolveCsrHtml(csrOutputDir, url.pathname);
550
546
  if (!csrHtml) return this.#csrUnavailableResponse(url.pathname);
551
547
  const html = await Bun.file(csrHtml).text();
552
- return new Response(this.#withCsrHmr(html), { headers: WebRouter.#htmlResponseHeaders(200) });
548
+ return new Response(this.#withCsrHmr(html), { headers: this.#htmlResponseHeaders(200) });
553
549
  }
554
550
 
555
551
  const csrAssetPath = path.extname(url.pathname) ? resolveStaticPath(csrOutputDir, url.pathname) : null;
@@ -608,7 +604,7 @@ export class WebRouter {
608
604
  const htmlCacheEntry = htmlCacheDecision.entry;
609
605
  const cachedHtml = htmlCacheEntry ? this.#getCachedHtml(htmlCacheEntry.key) : null;
610
606
  if (cachedHtml) {
611
- const cachedHeaders = WebRouter.#htmlResponseHeaders(200);
607
+ const cachedHeaders = this.#htmlResponseHeaders(200);
612
608
  cachedHeaders.set("X-Akan-Cache", "HIT");
613
609
  return new Response(cachedHtml, { headers: cachedHeaders });
614
610
  }
@@ -649,7 +645,7 @@ export class WebRouter {
649
645
  },
650
646
  });
651
647
  const responseStatus = rscResult.status ?? 200;
652
- const responseHeaders = WebRouter.#htmlResponseHeaders(responseStatus);
648
+ const responseHeaders = this.#htmlResponseHeaders(responseStatus);
653
649
  if (req.method === "HEAD") {
654
650
  const headers = new Headers(responseHeaders);
655
651
  if (htmlCacheEntry && responseStatus === 200) headers.set("X-Akan-Cache", "MISS");
@@ -971,9 +967,9 @@ export class WebRouter {
971
967
  return this.renderState.cssAssets[basePath ?? ""]?.cssUrl ?? null;
972
968
  }
973
969
 
974
- static #htmlResponseHeaders(status: number): Headers {
970
+ #htmlResponseHeaders(status: number): Headers {
975
971
  const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
976
- if (status >= 400) headers.set("Cache-Control", "no-store");
972
+ if (status >= 400 || !this.#prodMode) headers.set("Cache-Control", "no-store");
977
973
  return WebRouter.#applySecurityHeaders(headers, { html: true });
978
974
  }
979
975
  #withCsrHmr(html: string): string {
@@ -994,7 +990,7 @@ export class WebRouter {
994
990
  cssHref: this.#getStylesheetHref(req, pathname),
995
991
  });
996
992
  if (!html) return this.#csrUnavailableResponse(pathname);
997
- return new Response(this.#withCsrHmr(html), { headers: WebRouter.#htmlResponseHeaders(200) });
993
+ return new Response(this.#withCsrHmr(html), { headers: this.#htmlResponseHeaders(200) });
998
994
  }
999
995
  async #resolveCsrHtml(csrOutputDir: string, pathname: string): Promise<string | null> {
1000
996
  const resolved = WebRouter.#resolveCsrHtmlPath(csrOutputDir, pathname, this.#artifact);
@@ -1044,7 +1040,16 @@ export class WebRouter {
1044
1040
  const cssBytesByUrl = await WebRouter.#loadCssBytesByUrl(artifact, artifactDir);
1045
1041
  const prodMode = process.env.NODE_ENV === "production" && process.env.AKAN_COMMAND_TYPE !== "start";
1046
1042
 
1047
- const rsc = new RscWorker(artifact, { failBeforeReady: prodMode });
1043
+ const prebuilt = prodMode ? await RoutesManifestStore.read(artifactDir) : null;
1044
+ const rsc = new RscWorker(
1045
+ prebuilt
1046
+ ? {
1047
+ ...artifact,
1048
+ rscRuntimeClientManifest: { ...artifact.rscRuntimeClientManifest, ...prebuilt.clientManifest },
1049
+ }
1050
+ : artifact,
1051
+ { failBeforeReady: prodMode },
1052
+ );
1048
1053
  if (prodMode)
1049
1054
  void rsc.ready.catch((error: unknown) => {
1050
1055
  new Logger("WebRouter").error(`RSC worker failed to load the pages bundle: ${String(error)}`);
@@ -1059,6 +1064,7 @@ export class WebRouter {
1059
1064
  rsc,
1060
1065
  seedIndex,
1061
1066
  upgradeHmrWs,
1067
+ prebuilt,
1062
1068
  });
1063
1069
  }
1064
1070
 
@@ -2,5 +2,7 @@ export type AkanHmrPhase = "refresh-import" | "react-refresh" | null;
2
2
  declare global {
3
3
  var __AKAN_HMR_PHASE__: AkanHmrPhase | undefined;
4
4
  }
5
+ /** @deprecated Always `null`: the dev client that set the phase applies modules through its registry now. */
5
6
  export declare function getAkanHmrPhase(): AkanHmrPhase;
7
+ /** @deprecated Always `false`, for the reason `getAkanHmrPhase` is always `null`. */
6
8
  export declare function isAkanHmrApplying(): boolean;
@@ -0,0 +1,4 @@
1
+ import type { DevBuildStatus } from "./ipcTypes.d.ts";
2
+ export declare class DevBuildRecovery {
3
+ static recovers(previous: DevBuildStatus | undefined, status: DevBuildStatus): boolean;
4
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./builderRpc.d.ts";
2
+ export * from "./devBuildRecovery.d.ts";
2
3
  export * from "./ipcTypes.d.ts";
3
4
  export * from "./routeClientCache.d.ts";
4
5
  export * from "./routeSeedIndexStore.d.ts";
@@ -50,6 +50,8 @@ export interface DevBuildStatus {
50
50
  ok: boolean;
51
51
  files: string[];
52
52
  message?: string;
53
+ /** The route a route build's status speaks for. */
54
+ scope?: string;
53
55
  }
54
56
  export type BuilderReq = {
55
57
  type: "build-route";
@@ -0,0 +1,9 @@
1
+ import type { CssAsset } from "../types.d.ts";
2
+ export declare class DevArtifactPruner {
3
+ #private;
4
+ constructor(artifactDir: string, { graceMs }?: {
5
+ graceMs?: number;
6
+ });
7
+ prunePages(runningPath: string): Promise<void>;
8
+ pruneStyles(cssAssets: Record<string, CssAsset>): Promise<void>;
9
+ }
@@ -30,6 +30,4 @@ export declare class DevHmrController {
30
30
  get builderRpc(): BuilderRpc;
31
31
  dispose(): void;
32
32
  handleWs(req: Request): Response | undefined;
33
- ensureRoute(url: URL): Promise<import("..").MergedManifest>;
34
- routeIdsForPath(pathname: string): string[] | undefined;
35
33
  }
@@ -67,14 +67,16 @@ export interface RscWorkerReloadInput {
67
67
  /** Undefined keeps the latest bundle a reload named (e.g. a client-manifest-only reload after a lazy route build). */
68
68
  pagesBundlePath?: string;
69
69
  }
70
- interface RscAdoptedBundle {
70
+ export interface RscAdoptedBundle {
71
71
  pagesBundlePath: string;
72
72
  buildId: number;
73
73
  }
74
74
  /** A reload the worker did not take: what it runs instead, and whether a later reload failed in this one's place. */
75
75
  export interface RscReloadFailure extends Error {
76
+ /** What the worker runs now. */
76
77
  adopted: RscAdoptedBundle;
77
- superseded: boolean;
78
+ /** The latest state it was asked to take, which a later reload may have moved past the caller's own. */
79
+ failed: RscAdoptedBundle;
78
80
  }
79
81
  export declare class RscWorker {
80
82
  #private;
@@ -96,6 +98,5 @@ export declare class RscWorker {
96
98
  restartWhenIdle(reason: string): boolean;
97
99
  updateCssAssets(cssAssets: Record<string, CssAsset>): void;
98
100
  /** Resolves with the pages bundle the worker runs once it has taken this reload, or a later one. */
99
- reload(input: RscWorkerReloadInput): Promise<string>;
101
+ reload(input: RscWorkerReloadInput): Promise<RscAdoptedBundle>;
100
102
  }
101
- export {};
@@ -3,7 +3,7 @@ import { type AkanI18nConfig, type LogRecord } from "akanjs/common";
3
3
  import { type AkanRequestStore } from "akanjs/fetch";
4
4
  import type { AkanMetricsReport } from "akanjs/service";
5
5
  import type { PagePromptSource } from "../signal/mcp/pagePrompt.d.ts";
6
- import { type BuilderRpc, type RouteSeedIndex } from "./artifact.d.ts";
6
+ import { type BuilderRpc, type RouteSeedIndex, type RoutesManifest } from "./artifact.d.ts";
7
7
  import { type RouteCacheInvalidation, type RouteCacheRenderState } from "./cachePolicy.d.ts";
8
8
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub.d.ts";
9
9
  import { type RscRedirectMethod, type RscRedirectStatus, type RscRenderResult, RscWorker } from "./rscWorkerHost.d.ts";
@@ -49,13 +49,15 @@ interface WebRouterOptions {
49
49
  rsc: RscWorker;
50
50
  seedIndex: RouteSeedIndex;
51
51
  upgradeHmrWs: (req: Request, data: HmrWsData) => boolean;
52
+ /** The production build's route manifest, which the worker was already handed at boot. */
53
+ prebuilt?: RoutesManifest | null;
52
54
  }
53
55
  export declare class WebRouter {
54
56
  #private;
55
57
  renderState: RenderState;
56
58
  /** What this router actually mounts, already intersected with what the artifact carries. */
57
59
  readonly web: AkanWebConfig;
58
- constructor({ artifact, web, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs }: WebRouterOptions);
60
+ constructor({ artifact, web, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs, prebuilt }: WebRouterOptions);
59
61
  initializeRoute(): Promise<{
60
62
  renderEnvRoutes: Bun.Serve.Routes<unknown, string> | Bun.Serve.RoutesWithUpgrade<unknown, string>;
61
63
  hmrHub: HmrWsHub | null;