akanjs 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.
Files changed (45) hide show
  1. package/common/hmrPhase.ts +2 -0
  2. package/package.json +1 -1
  3. package/server/SSR_MEMORY_DIAGNOSIS.md +2 -2
  4. package/server/akanApp.ts +1 -0
  5. package/server/akanServer.ts +7 -1
  6. package/server/artifact/builderRpc.ts +13 -2
  7. package/server/artifact/devBuildRecovery.ts +12 -0
  8. package/server/artifact/index.ts +1 -0
  9. package/server/artifact/ipcTypes.ts +46 -3
  10. package/server/artifact/routeClientCache.ts +128 -29
  11. package/server/di/diLifecycle.ts +1 -1
  12. package/server/hmr/clientScript.ts +148 -86
  13. package/server/hmr/csrDevManifest.ts +17 -0
  14. package/server/hmr/csrDevRuntime.ts +238 -18
  15. package/server/hmr/csrDevShell.ts +90 -10
  16. package/server/hmr/devArtifactPruner.ts +77 -0
  17. package/server/hmr/devHmrController.ts +233 -137
  18. package/server/hmr/ssrDevShim.ts +112 -0
  19. package/server/hmr/ssrUpdateQueue.ts +111 -0
  20. package/server/hmr/wsHub.ts +19 -4
  21. package/server/routing/apiRouter.ts +12 -0
  22. package/server/rscWorker.tsx +11 -1
  23. package/server/rscWorkerHost.ts +223 -59
  24. package/server/systemPageDocument.tsx +6 -0
  25. package/server/types.tsx +4 -0
  26. package/server/webRouter.ts +57 -22
  27. package/types/common/hmrPhase.d.ts +2 -0
  28. package/types/server/artifact/builderRpc.d.ts +3 -2
  29. package/types/server/artifact/devBuildRecovery.d.ts +4 -0
  30. package/types/server/artifact/index.d.ts +1 -0
  31. package/types/server/artifact/ipcTypes.d.ts +47 -2
  32. package/types/server/artifact/routeClientCache.d.ts +9 -2
  33. package/types/server/hmr/csrDevManifest.d.ts +15 -0
  34. package/types/server/hmr/csrDevRuntime.d.ts +34 -0
  35. package/types/server/hmr/csrDevShell.d.ts +9 -1
  36. package/types/server/hmr/devArtifactPruner.d.ts +9 -0
  37. package/types/server/hmr/devHmrController.d.ts +8 -5
  38. package/types/server/hmr/ssrDevShim.d.ts +7 -0
  39. package/types/server/hmr/ssrUpdateQueue.d.ts +23 -0
  40. package/types/server/hmr/wsHub.d.ts +16 -7
  41. package/types/server/routing/apiRouter.d.ts +8 -0
  42. package/types/server/rscWorkerHost.d.ts +14 -2
  43. package/types/server/systemPageDocument.d.ts +2 -0
  44. package/types/server/types.d.ts +4 -0
  45. package/types/server/webRouter.d.ts +8 -2
@@ -1,18 +1,23 @@
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,
10
+ type HmrTrace,
8
11
  RouteClientCache,
9
12
  type RouteSeedIndex,
10
13
  RouteSeedIndexStore,
11
14
  } from "../artifact";
12
- import type { RscWorker } from "../rscWorkerHost";
15
+ import type { RscReloadFailure, RscWorker } from "../rscWorkerHost";
13
16
  import type { RenderState } from "../types";
14
- import { CSR_DEV_DIRNAME, CSR_DEV_MANIFEST_FILE, type CsrDevManifest } from "./csrDevManifest";
17
+ import { CSR_DEV_DIRNAME, CSR_DEV_MANIFEST_FILE, type CsrDevManifest, SSR_DEV_DIRNAME } from "./csrDevManifest";
18
+ import { DevArtifactPruner } from "./devArtifactPruner";
15
19
  import { isAkanRuntimeMetadataFile } from "./runtimeMetadataFile";
20
+ import { type SsrUpdateMessage, SsrUpdateQueue } from "./ssrUpdateQueue";
16
21
  import { type ChangeKind, type HmrMessage, type HmrWsData, HmrWsHub } from "./wsHub";
17
22
 
18
23
  export { isAkanRuntimeMetadataFile };
@@ -47,10 +52,7 @@ export function devBuildStatusToHmrMessage(
47
52
  files: status.files.length,
48
53
  };
49
54
  }
50
- if (!previous || previous.ok) return null;
51
- const recovered =
52
- status.phase === "backend" ? status.generation >= previous.generation : status.generation > previous.generation;
53
- if (!recovered) return null;
55
+ if (!DevBuildRecovery.recovers(previous, status)) return null;
54
56
  return {
55
57
  type: "build-status",
56
58
  status: "ok",
@@ -67,6 +69,8 @@ export interface DevHmrControllerOptions {
67
69
  rsc: RscWorker;
68
70
  seedIndex: RouteSeedIndex;
69
71
  upgradeHmrWs: (req: Request, data: HmrWsData) => boolean;
72
+ /** The pages bundle the RSC worker booted with: the base build's, however many pages builds came after it. */
73
+ pagesBundlePath?: string;
70
74
  }
71
75
 
72
76
  export class DevHmrController {
@@ -76,12 +80,15 @@ export class DevHmrController {
76
80
  readonly #rsc: RscWorker;
77
81
  readonly #seedIndex: RouteSeedIndex;
78
82
  readonly #upgradeHmrWs: (req: Request, data: HmrWsData) => boolean;
79
- readonly #fastRefreshEnabled = process.env.AKAN_REACT_FAST_REFRESH !== "0";
80
83
  readonly #hub = new HmrWsHub();
84
+ readonly #ssrUpdates = new SsrUpdateQueue((message) => this.#sendSsrUpdate(message));
81
85
  readonly #builderRpc: BuilderRpc;
82
86
  readonly routeCache: RouteClientCache;
87
+ #recheckingRoute = false;
88
+ #recheckAgain: DevBuildStatus | null = null;
83
89
  readonly #recentClientEntries = new Set<string>();
84
- readonly #recentClientFiles = new Set<string>();
90
+ /** File to the newest save that invalidated its routes already: that save's pages build must not drop them again. */
91
+ readonly #earlyInvalidated = new Map<string, number>();
85
92
  readonly #clientFileRouteIds = new Map<string, Set<string>>();
86
93
  readonly #clientFileEntries = new Map<string, Set<string>>();
87
94
  readonly #clientEntryRouteIds = new Map<string, Set<string>>();
@@ -90,31 +97,54 @@ export class DevHmrController {
90
97
  readonly #dirtyFiles = new Set<string>();
91
98
  readonly #buildStatusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>();
92
99
  #graphSeeds: string[];
100
+ #runningBundlePath: string | null;
101
+ readonly #pruner: DevArtifactPruner;
93
102
 
94
- constructor({ artifactDir, renderState, rsc, seedIndex, upgradeHmrWs }: DevHmrControllerOptions) {
103
+ constructor({ artifactDir, renderState, rsc, seedIndex, upgradeHmrWs, pagesBundlePath }: DevHmrControllerOptions) {
95
104
  this.#artifactDir = artifactDir;
105
+ this.#runningBundlePath = pagesBundlePath ? path.resolve(pagesBundlePath) : null;
106
+ this.#pruner = new DevArtifactPruner(artifactDir);
96
107
  this.#renderState = renderState;
97
108
  this.#rsc = rsc;
98
109
  this.#seedIndex = seedIndex;
99
110
  this.#graphSeeds = DevHmrController.#graphSeedsOf(seedIndex);
100
111
  this.#upgradeHmrWs = upgradeHmrWs;
101
- this.#renderState.csrGeneration ??= DevHmrController.#readCsrGeneration(artifactDir);
112
+ this.#renderState.csrGeneration ??= DevHmrController.#readManifest(artifactDir, CSR_DEV_DIRNAME)?.generation;
113
+ const ssrManifest = DevHmrController.#readManifest(artifactDir, SSR_DEV_DIRNAME);
114
+ this.#renderState.ssrGeneration ??= ssrManifest?.generation;
115
+ this.#renderState.ssrEpoch ??= ssrManifest?.epoch;
102
116
  this.#builderRpc = this.#createBuilderRpc();
103
117
  this.routeCache = this.#createRouteCache();
104
118
  }
105
119
 
106
- static #readCsrGeneration(artifactDir: string): number | undefined {
120
+ static #readManifest(artifactDir: string, dirName: string): Partial<CsrDevManifest> | undefined {
107
121
  try {
108
- const manifest = JSON.parse(
109
- fs.readFileSync(path.join(artifactDir, CSR_DEV_DIRNAME, CSR_DEV_MANIFEST_FILE), "utf8"),
122
+ return JSON.parse(
123
+ fs.readFileSync(path.join(artifactDir, dirName, CSR_DEV_MANIFEST_FILE), "utf8"),
110
124
  ) as Partial<CsrDevManifest>;
111
- return typeof manifest.generation === "number" ? manifest.generation : undefined;
112
125
  } catch {
113
126
 
114
127
  return undefined;
115
128
  }
116
129
  }
117
130
 
131
+ refreshRegistryState(): void {
132
+ const csr = DevHmrController.#readManifest(this.#artifactDir, CSR_DEV_DIRNAME);
133
+ if (typeof csr?.generation === "number") this.#renderState.csrGeneration = csr.generation;
134
+ const ssr = DevHmrController.#readManifest(this.#artifactDir, SSR_DEV_DIRNAME);
135
+ if (typeof ssr?.generation === "number" && this.#ssrUpdates.size === 0)
136
+ this.#renderState.ssrGeneration = ssr.generation;
137
+ if (typeof ssr?.epoch === "number") this.#renderState.ssrEpoch = ssr.epoch;
138
+ }
139
+
140
+ /** The phases failing now, for a socket that connects after their status went out (a page opened after a failed boot). */
141
+ buildErrorMessages(): Extract<HmrMessage, { type: "build-status" }>[] {
142
+ return [...this.#buildStatusByPhase.values()].flatMap((status) => {
143
+ const message = status.ok ? null : devBuildStatusToHmrMessage(status);
144
+ return message ? [message] : [];
145
+ });
146
+ }
147
+
118
148
  get hub(): HmrWsHub {
119
149
  return this.#hub;
120
150
  }
@@ -128,68 +158,24 @@ export class DevHmrController {
128
158
  }
129
159
 
130
160
  handleWs(req: Request): Response | undefined {
131
- const client = new URL(req.url).searchParams.get("client") === "csr" ? "csr" : "ssr";
132
- if (this.#upgradeHmrWs(req, { kind: "akan-hmr", openedAt: Date.now(), client })) return;
133
- return new Response("Failed to upgrade HMR WebSocket", { status: 500 });
134
- }
135
161
 
136
- async handleClientRefresh(req: Request): Promise<Response> {
137
- const started = Date.now();
138
162
  try {
139
- if (!this.#fastRefreshEnabled) return new Response("Fast Refresh disabled", { status: 404 });
140
- const reqUrl = new URL(req.url);
141
- const clientOrigin = DevHmrController.#clientFacingOrigin(req);
142
- const target = reqUrl.searchParams.get("url");
143
- const targetUrl = target ? new URL(target, clientOrigin) : reqUrl;
144
- if (!DevHmrController.#isTrustedRscTarget(clientOrigin, targetUrl))
145
- return new Response("Bad Request", { status: 400 });
146
- const manifest = await this.ensureRoute(targetUrl);
147
- const chunks = DevHmrController.clientChunkUrls(manifest.clientManifest);
148
- this.#logger.verbose(
149
- `[hmr] client-refresh metadata route=${targetUrl.pathname} chunks=${chunks.length} in ${Date.now() - started}ms`,
150
- );
151
- return new Response(
152
- JSON.stringify({
153
- buildId: this.#renderState.buildId,
154
- generation: manifest.generation,
155
- chunks,
156
- routeIds: this.routeIdsForPath(targetUrl.pathname),
157
- }),
158
- { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" } },
159
- );
160
- } catch (err) {
161
- const message = err instanceof Error ? err.message : String(err);
162
- this.#logger.error(`[hmr] client-refresh metadata failed: ${message}`);
163
- return new Response(message, { status: 500 });
163
+ CrossSiteGuard.assertOrigin(req, new URL(req.url), "hmr");
164
+ } catch {
165
+ return new Response("Forbidden", { status: 403 });
164
166
  }
167
+ const client = new URL(req.url).searchParams.get("client") === "csr" ? "csr" : "ssr";
168
+ if (this.#upgradeHmrWs(req, { kind: "akan-hmr", openedAt: Date.now(), client })) return;
169
+ return new Response("Failed to upgrade HMR WebSocket", { status: 500 });
165
170
  }
166
171
 
167
- async ensureRoute(url: URL) {
168
- const started = Date.now();
169
- const matched =
170
- RouteSeedIndexStore.match(url.pathname, this.#seedIndex.entries) ??
171
- RouteSeedIndexStore.matchPrefix(url.pathname, this.#seedIndex.entries);
172
- if (matched) await this.routeCache.ensure(matched.entry.routeId, matched.entry.seeds);
173
- this.#logger.verbose(
174
- `[route-cache] ensure pathname=${url.pathname} routeId=${matched?.entry.routeId ?? "(none)"} in ${Date.now() - started}ms`,
175
- );
176
- return this.routeCache.snapshot();
177
- }
178
-
179
- routeIdsForPath(pathname: string): string[] | undefined {
180
- const matched = RouteSeedIndexStore.match(pathname, this.#seedIndex.entries);
181
- return matched ? [matched.entry.routeId] : undefined;
172
+ #sendSsrUpdate(message: SsrUpdateMessage): void {
173
+ this.#renderState.ssrGeneration = message.generation;
174
+ this.#hub.broadcast({ ...message, trace: DevHmrController.#broadcastTrace(message.trace) });
182
175
  }
183
176
 
184
- static clientChunkUrls(clientManifest: ClientManifest): string[] {
185
- const urls = new Set<string>();
186
- for (const row of Object.values(clientManifest)) {
187
- urls.add(row.id);
188
- for (const chunk of row.chunks) {
189
- if (chunk.startsWith("/_akan/client/")) urls.add(chunk);
190
- }
191
- }
192
- return [...urls];
177
+ static #broadcastTrace(trace: HmrTrace | undefined): HmrTrace | undefined {
178
+ return trace ? { ...trace, broadcastAt: Date.now() } : undefined;
193
179
  }
194
180
 
195
181
  #createBuilderRpc() {
@@ -199,6 +185,8 @@ export class DevHmrController {
199
185
  },
200
186
  onBuildStatus: (status) => {
201
187
  this.#recordBuildStatus(status);
188
+
189
+ if (status.phase === "pages" && !status.ok) this.#ssrUpdates.release(status.generation);
202
190
  },
203
191
  onCsrUpdated: (update) => {
204
192
  this.#renderState.csrGeneration = update.generation;
@@ -209,11 +197,24 @@ export class DevHmrController {
209
197
  changedIds: update.changedIds,
210
198
  reload: update.reload,
211
199
  reason: update.reason,
200
+ trace: DevHmrController.#broadcastTrace(update.trace),
212
201
  });
213
202
  this.#logger.verbose(
214
203
  `[csr] ${update.mode} generation=${update.generation} ${update.reload ? `reload (${update.reason ?? "no reason"})` : `patch modules=${update.changedIds?.length ?? 0}`}`,
215
204
  );
216
205
  },
206
+ onSsrUpdated: (update) => {
207
+ if (update.epoch !== undefined) this.#renderState.ssrEpoch = update.epoch;
208
+ if (update.first) {
209
+ this.#renderState.ssrGeneration = update.generation;
210
+ this.#logger.verbose(`[ssr] registry built generation=${update.generation}`);
211
+ return;
212
+ }
213
+ this.#ssrUpdates.push(update);
214
+ this.#logger.verbose(
215
+ `[ssr] registry generation=${update.generation} ${update.reload ? `reload (${update.reason ?? "no reason"})` : `patch modules=${update.changedIds?.length ?? 0}`}${this.#ssrUpdates.size > 0 ? ` held=${this.#ssrUpdates.size} until the pages build of batch ${update.batchGeneration ?? "?"}` : ""}`,
216
+ );
217
+ },
217
218
  onCssUpdated: (css) => {
218
219
  const started = Date.now();
219
220
  const cssBytesByUrl = Object.fromEntries(
@@ -226,38 +227,94 @@ export class DevHmrController {
226
227
  this.#renderState.cssBytesByUrl = cssBytesByUrl;
227
228
  this.#rsc.updateCssAssets(this.#renderState.cssAssets);
228
229
  this.#hub.broadcast({ type: "css-update", cssAssets: this.#renderState.cssAssets });
230
+ void this.#pruner.pruneStyles(this.#renderState.cssAssets);
229
231
  this.#logger.verbose(
230
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)`,
231
233
  );
232
234
  },
233
- onPagesUpdated: async ({ bundlePath, buildId, generation, changedFiles }) => {
235
+ onPagesUpdated: async ({ bundlePath, buildId, generation, changedFiles, trace, serverTouched }) => {
234
236
  const started = Date.now();
235
237
  const files = changedFiles ?? [];
236
238
  const routeTreeChanged = await this.#reloadSeedIndex();
237
239
  const runtimeMetadataChanged = files.some(isAkanRuntimeMetadataFile);
238
240
  const clearAll = routeTreeChanged || runtimeMetadataChanged;
239
- const staleClientEntries = clearAll ? new Set<string>() : this.#staleClientEntriesForFiles(files);
241
+ const lateFiles = files.filter((file) => !this.#earlyInvalidated.has(path.resolve(file)));
242
+
243
+ for (const [file, early] of this.#earlyInvalidated)
244
+ if (generation === undefined || early <= generation) this.#earlyInvalidated.delete(file);
245
+ const staleClientEntries = clearAll ? new Set<string>() : this.#staleClientEntriesForFiles(lateFiles);
240
246
  const routeIds = clearAll ? undefined : this.#routeIdsForFiles(files, staleClientEntries);
241
- const fastRefreshCandidate = !clearAll && this.#isFastRefreshCandidate(files);
242
247
  this.#logger.verbose(
243
- `[SSR] pages-updated bundlePath=${bundlePath} buildId=${buildId} generation=${generation ?? "(unknown)"} files=${files.length} routes=${routeIds?.length ?? 0} fastRefresh=${fastRefreshCandidate} staleEntries=${staleClientEntries.size} runtimeMetadata=${runtimeMetadataChanged} routeTree=${routeTreeChanged}`,
248
+ `[SSR] pages-updated bundlePath=${bundlePath} buildId=${buildId} generation=${generation ?? "(unknown)"} files=${files.length} routes=${routeIds?.length ?? 0} serverTouched=${serverTouched ?? "(unknown)"} staleEntries=${staleClientEntries.size} runtimeMetadata=${runtimeMetadataChanged} routeTree=${routeTreeChanged}`,
244
249
  );
245
- const dropped = this.#invalidateRoutes(files, routeIds, staleClientEntries, { forceClear: clearAll });
246
- this.#renderState.buildId = buildId;
250
+ const dropped =
251
+ clearAll || lateFiles.length > 0
252
+ ? this.#invalidateRoutes(
253
+ lateFiles,
254
+ this.#routeIdsForFiles(lateFiles, staleClientEntries),
255
+ staleClientEntries,
256
+ { forceClear: clearAll, batch: generation },
257
+ )
258
+ : [];
247
259
  const manifest = this.routeCache.snapshot();
248
- const reloadStarted = Date.now();
249
- await this.#rsc.reload({
250
- clientManifest: manifest.clientManifest,
251
- cssAssets: this.#renderState.cssAssets,
252
- buildId,
253
- pagesBundlePath: bundlePath,
254
- });
255
- this.#logger.verbose(`[SSR] rsc reload buildId=${buildId} in ${Date.now() - reloadStarted}ms`);
260
+
261
+ const running = bundlePath ? path.resolve(bundlePath) : null;
262
+ let tabBuildId = buildId;
263
+ if (serverTouched === false && !clearAll && running === this.#runningBundlePath) {
264
+ this.#logger.verbose(
265
+ `[SSR] pages bundle unchanged for the server; buildId ${this.#renderState.buildId} kept`,
266
+ );
267
+ } else {
268
+ const previousBuildId = this.#renderState.buildId;
269
+ this.#renderState.buildId = buildId;
270
+ const reloadStarted = Date.now();
271
+ try {
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;
281
+ } catch (error) {
282
+ const failure = DevHmrController.#reloadFailure(error);
283
+
284
+ if (failure) {
285
+ this.#renderState.buildId = failure.adopted.buildId;
286
+ this.#adoptBundle(failure.adopted.pagesBundlePath);
287
+ } else if (this.#renderState.buildId === buildId) this.#renderState.buildId = previousBuildId;
288
+
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
+ }
295
+ }
296
+ this.#logger.verbose(`[SSR] rsc reload buildId=${buildId} in ${Date.now() - reloadStarted}ms`);
297
+ }
256
298
  const shouldReload = clearAll || this.#shouldFullReloadForFiles(files, routeIds);
257
- if (shouldReload) this.#hub.broadcast({ type: "reload", buildId });
258
- else if (fastRefreshCandidate)
259
- this.#hub.broadcast({ type: "client-refresh", buildId, generation, changedFiles, routeIds });
260
- else this.#hub.broadcast({ type: "rsc-refresh", buildId, generation, changedFiles, routeIds });
299
+ const broadcastTrace = DevHmrController.#broadcastTrace(trace);
300
+ if (shouldReload) this.#ssrUpdates.clear(generation);
301
+ const released = shouldReload ? { released: 0, reload: false } : this.#ssrUpdates.release(generation);
302
+ if (shouldReload) this.#hub.broadcast({ type: "reload", buildId: tabBuildId });
303
+ else if (released.reload)
304
+ this.#logger.verbose(`[hmr] generation=${generation} released a registry reload; no RSC refresh needed`);
305
+ else if (serverTouched === false)
306
+ this.#logger.verbose(
307
+ `[hmr] generation=${generation} changed nothing the server renders; the registry patched it`,
308
+ );
309
+ else
310
+ this.#hub.broadcast({
311
+ type: "rsc-refresh",
312
+ buildId: tabBuildId,
313
+ generation,
314
+ changedFiles,
315
+ routeIds,
316
+ trace: broadcastTrace,
317
+ });
261
318
  this.#logger.verbose(
262
319
  `[hmr] backend apply buildId=${buildId} dropped=${dropped.length} routeGeneration=${manifest.generation} in ${Date.now() - started}ms`,
263
320
  );
@@ -265,6 +322,24 @@ export class DevHmrController {
265
322
  });
266
323
  }
267
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
+
332
+ static #reloadFailure(error: unknown): RscReloadFailure | null {
333
+ return error instanceof Error && "adopted" in error ? (error as RscReloadFailure) : null;
334
+ }
335
+
336
+ #failPagesReload(generation: number | undefined, files: string[], error: unknown): void {
337
+ const message = error instanceof Error ? error.message : String(error);
338
+ this.#logger.error(`[SSR] rsc reload failed generation=${generation ?? "(unknown)"}: ${message}`);
339
+ if (generation !== undefined) this.#recordBuildStatus({ generation, phase: "pages", ok: false, files, message });
340
+ this.#ssrUpdates.release(generation);
341
+ }
342
+
268
343
  async #reloadSeedIndex(): Promise<boolean> {
269
344
  const next = await RouteSeedIndexStore.load(this.#artifactDir).catch((err: unknown) => {
270
345
  this.#logger.warn(`[hmr] route seed index unreadable; keeping the boot one: ${String(err)}`);
@@ -281,9 +356,12 @@ export class DevHmrController {
281
356
  }
282
357
 
283
358
  #recordBuildStatus(status: DevBuildStatus): void {
359
+ this.#recheckFailedRoute(status);
284
360
  const previous = this.#buildStatusByPhase.get(status.phase);
285
- this.#buildStatusByPhase.set(status.phase, status);
286
361
  const message = devBuildStatusToHmrMessage(status, previous);
362
+
363
+ if (status.ok && previous && !previous.ok && !message) return;
364
+ this.#buildStatusByPhase.set(status.phase, status);
287
365
  if (!message) return;
288
366
  this.#hub.broadcast(message);
289
367
  this.#logger.verbose(
@@ -291,6 +369,26 @@ export class DevHmrController {
291
369
  );
292
370
  }
293
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
+
294
392
  #createRouteCache() {
295
393
  return new RouteClientCache({
296
394
  buildRoute: async (routeId, { seeds, knownEntries, generation }) => {
@@ -314,11 +412,19 @@ export class DevHmrController {
314
412
  }
315
413
  if (delta.newEntries.length === 0 && removedEntries.size === 0) return;
316
414
  this.#rememberClientDeps(routeId, delta.clientDeps, delta.clientDepsByEntry);
317
- await this.#rsc.reload({
318
- clientManifest: nextMerged.clientManifest,
319
- cssAssets: this.#renderState.cssAssets,
320
- buildId: this.#renderState.buildId,
321
- });
415
+ try {
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);
422
+ } catch (error) {
423
+
424
+ const failure = DevHmrController.#reloadFailure(error);
425
+ if (failure) this.#adoptBundle(failure.adopted.pagesBundlePath);
426
+ this.#logger.warn(`[SSR] route ${routeId} merged, but the worker did not reload: ${String(error)}`);
427
+ }
322
428
  this.#logger.verbose(
323
429
  `[SSR] route manifest merged routeId=${routeId} generation=${generation} entries=+${delta.newEntries.length} deps=${delta.clientDeps.length}`,
324
430
  );
@@ -327,6 +433,7 @@ export class DevHmrController {
327
433
  }
328
434
 
329
435
  #recordInvalidate(files: string[], kinds: Set<Exclude<ChangeKind, "ignore">>, generation?: number) {
436
+ if (kinds.has("code")) this.#invalidateClientEntriesEarly(files, generation ?? 0);
330
437
  for (const k of kinds) this.#dirty.add(k);
331
438
  for (const file of files) this.#dirtyFiles.add(file);
332
439
  if (this.#dirty.has("config")) {
@@ -352,11 +459,35 @@ export class DevHmrController {
352
459
  this.#dirtyFiles.clear();
353
460
  }
354
461
 
462
+ #invalidateClientEntriesEarly(files: string[], generation: number): void {
463
+ const staleClientEntries = this.#staleClientEntriesForFiles(files);
464
+
465
+ if (staleClientEntries.size === 0) {
466
+ this.routeCache.invalidateClientEntries({
467
+ routePredicate: () => false,
468
+ staleEntries: [],
469
+ files,
470
+ batch: generation,
471
+ });
472
+ return;
473
+ }
474
+
475
+ for (const file of files)
476
+ if (staleClientEntries.has(path.resolve(file))) this.#earlyInvalidated.set(path.resolve(file), generation);
477
+ const routeIds = this.#routeIdsForFiles(files, staleClientEntries);
478
+ this.routeCache.invalidateClientEntries({
479
+ routePredicate: (routeId) => !routeIds || routeIds.includes(routeId),
480
+ staleEntries: this.#clientEntryManifestKeys(staleClientEntries),
481
+ files,
482
+ batch: generation,
483
+ });
484
+ }
485
+
355
486
  #invalidateRoutes(
356
487
  files: string[],
357
488
  routeIds: string[] | undefined,
358
489
  staleClientEntries = new Set<string>(),
359
- { forceClear = false }: { forceClear?: boolean } = {},
490
+ { forceClear = false, batch }: { forceClear?: boolean; batch?: number } = {},
360
491
  ): string[] {
361
492
  this.#dirty.clear();
362
493
  this.#dirtyFiles.clear();
@@ -366,10 +497,14 @@ export class DevHmrController {
366
497
  return this.routeCache.invalidateClientEntries({
367
498
  routePredicate: (routeId) => !routeIds || routeIds.includes(routeId),
368
499
  staleEntries: staleKeys,
500
+ files,
501
+ batch,
369
502
  });
370
503
  }
371
- if (!routeIds || this.#shouldClearAllRoutes(files, routeIds)) return this.routeCache.clear();
372
- return this.routeCache.invalidate((routeId) => routeIds.includes(routeId));
504
+
505
+ if (!routeIds || this.#shouldClearAllRoutes(files, routeIds))
506
+ return batch === undefined ? this.routeCache.clear() : this.routeCache.invalidate(() => true, { files, batch });
507
+ return this.routeCache.invalidate((routeId) => routeIds.includes(routeId), { files, batch });
373
508
  }
374
509
 
375
510
  #routeIdsForFiles(files: string[], staleClientEntries = new Set<string>()): string[] | undefined {
@@ -391,31 +526,16 @@ export class DevHmrController {
391
526
  return unique.length > 0 ? unique : undefined;
392
527
  }
393
528
 
394
- #isFastRefreshCandidate(files: string[]): boolean {
395
- if (!this.#fastRefreshEnabled || files.length === 0) return false;
396
- if (manifestClientEntriesForFiles(files, this.routeCache.merged.clientManifest).size > 0) return true;
397
- return files.some((file) => {
398
- const resolved = path.resolve(file);
399
- return this.#recentClientEntries.has(resolved) || this.#recentClientFiles.has(resolved);
400
- });
401
- }
402
-
403
529
  #rememberClientDeps(routeId: string, deps: string[], depsByEntry: Record<string, string[]> = {}) {
404
530
  for (const [entry, entryDeps] of Object.entries(depsByEntry)) {
405
531
  const resolvedEntry = path.resolve(entry);
406
532
  this.#recentClientEntries.add(resolvedEntry);
407
533
  DevHmrController.#addTo(this.#clientEntryRouteIds, resolvedEntry, routeId);
408
534
  for (const dep of entryDeps) {
409
- const resolvedDep = path.resolve(dep);
410
- this.#recentClientFiles.add(resolvedDep);
411
- DevHmrController.#addTo(this.#clientFileEntries, resolvedDep, resolvedEntry);
535
+ DevHmrController.#addTo(this.#clientFileEntries, path.resolve(dep), resolvedEntry);
412
536
  }
413
537
  }
414
- for (const dep of deps) {
415
- const resolved = path.resolve(dep);
416
- this.#recentClientFiles.add(resolved);
417
- DevHmrController.#addTo(this.#clientFileRouteIds, resolved, routeId);
418
- }
538
+ for (const dep of deps) DevHmrController.#addTo(this.#clientFileRouteIds, path.resolve(dep), routeId);
419
539
  }
420
540
 
421
541
  static #addTo(map: Map<string, Set<string>>, key: string, value: string) {
@@ -491,28 +611,4 @@ export class DevHmrController {
491
611
  files.some((file) => path.resolve(file).includes(`${path.sep}page${path.sep}`) && /\.(tsx|ts|jsx|js)$/.test(file))
492
612
  );
493
613
  }
494
-
495
- static #clientFacingOrigin(req: Request): string {
496
- const parsed = new URL(req.url);
497
- const fwdProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
498
- const fwdHost = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
499
- const hostFallback = fwdHost ?? req.headers.get("host");
500
- const protoFallback = fwdProto ?? parsed.protocol.slice(0, -1);
501
- if (hostFallback && protoFallback) {
502
- try {
503
- return new URL(`${protoFallback}://${hostFallback}`).origin;
504
- } catch {
505
- }
506
- }
507
- return parsed.origin;
508
- }
509
-
510
- static #isTrustedRscTarget(clientOrigin: string, targetUrl: URL): boolean {
511
- try {
512
- if (targetUrl.origin === clientOrigin) return true;
513
- return targetUrl.hostname === new URL(clientOrigin).hostname;
514
- } catch {
515
- return false;
516
- }
517
- }
518
614
  }
@@ -0,0 +1,112 @@
1
+ import { type CsrDevManifest, SSR_DEV_CHUNK, SSR_DEV_ID_PREFIX, SSR_DEV_ROUTE_PREFIX } from "./csrDevManifest";
2
+ import { CSR_DEV_RUNTIME_SCRIPT } from "./csrDevRuntime";
3
+
4
+ export class SsrDevShim {
5
+ static readonly runtimeUrl = `${SSR_DEV_ROUTE_PREFIX}runtime.js?v=${Bun.hash(CSR_DEV_RUNTIME_SCRIPT).toString(36)}`;
6
+ static readonly #body = `var baseLoad = self.__webpack_chunk_load__;
7
+ var baseRequire = self.__webpack_require__;
8
+ var booting = null;
9
+ var built = null;
10
+ function wait(ms) {
11
+ return new Promise(function (resolve) { setTimeout(resolve, ms); });
12
+ }
13
+ function script(src) {
14
+ return new Promise(function (resolve, reject) {
15
+ var el = document.createElement("script");
16
+ el.src = src;
17
+ el.async = false;
18
+ el.onload = function () { resolve(); };
19
+ el.onerror = function () { reject(new Error("[akan-ssr-dev] " + src + " failed to load")); };
20
+ document.head.appendChild(el);
21
+ });
22
+ }
23
+ function state() {
24
+ return fetch(c.prefix + "boot.json?wait=10000", { cache: "no-store" }).then(function (res) {
25
+ return res.ok ? res.json() : wait(1000).then(state);
26
+ }, function () {
27
+ return wait(1000).then(state);
28
+ });
29
+ }
30
+ function load(vendors, attempt) {
31
+ self.__AKAN_SSR_EPOCH__ = built.epoch;
32
+ return Promise.all([vendors, script(c.prefix + built.vendorFile)]).then(function () {
33
+ return script(c.prefix + "app.js?g=" + built.generation);
34
+ }).catch(function (error) {
35
+ if (attempt >= 3) throw error;
36
+ return wait(500).then(state).then(function (next) {
37
+ built = next;
38
+ return load(vendors, attempt + 1);
39
+ });
40
+ });
41
+ }
42
+ function reloadOnto(vendorFile, attempt) {
43
+ return state().then(function (current) {
44
+ if (current.vendorFile === vendorFile) {
45
+ self.location.reload();
46
+ return new Promise(function () {});
47
+ }
48
+ if (attempt < 4) return wait(500).then(function () { return reloadOnto(vendorFile, attempt + 1); });
49
+ });
50
+ }
51
+ function catchUp() {
52
+ var hello = self.__AKAN_SSR_HELLO_GENERATION__;
53
+ if (typeof hello === "number") self.__akan.catchUp(hello, c.prefix);
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
+ }
61
+ function boot() {
62
+ if (!booting)
63
+ booting = self.__AKAN_SSR_BOOT__ = runtime(0).then(function () {
64
+ var early = self.__AKAN_SSR_EARLY_UPDATES__ || [];
65
+ self.__AKAN_SSR_EARLY_UPDATES__ = null;
66
+ for (var i = 0; i < early.length; i++) self.__akan.hot(early[i]);
67
+ return c.vendorFile ? c : state();
68
+ }).then(function (current) {
69
+ built = current;
70
+ return load(Promise.all(c.vendors.map(function (specifier) {
71
+ return import(specifier).then(function (ns) { self.__akan.provide("vendor:" + specifier, ns); });
72
+ })), 0);
73
+ }).then(function () {
74
+ var started = self.__akan.inspect();
75
+ if (typeof started.epoch === "number") self.__AKAN_SSR_EPOCH__ = started.epoch;
76
+ var vendorFile = started.vendorFile;
77
+ if (!vendorFile || vendorFile === built.vendorFile) return catchUp();
78
+ if (started.failed) return reloadOnto(vendorFile, 0);
79
+ return script(c.prefix + vendorFile).then(catchUp, function () { return reloadOnto(vendorFile, 0); });
80
+ }).catch(function (error) {
81
+ var failed = built || c;
82
+ self.__AKAN_SSR_BOOT_FAILED__ = { generation: failed.generation || 0, epoch: failed.epoch };
83
+ throw error;
84
+ });
85
+ return booting;
86
+ }
87
+ self.__webpack_chunk_load__ = function (id) { return id === c.chunk ? boot() : baseLoad(id); };
88
+ var require = function (id) {
89
+ if (typeof id !== "string" || id.indexOf(c.idPrefix) !== 0) return baseRequire(id);
90
+ var moduleId = id.slice(c.idPrefix.length);
91
+ var registry = self.__akan;
92
+ if (registry.has(moduleId)) return registry.require(moduleId);
93
+ return registry.whenDefined(moduleId).then(function () { return registry.require(moduleId); });
94
+ };
95
+ require.u = baseRequire.u;
96
+ self.__webpack_require__ = require;`;
97
+
98
+ /** `vendors` are the import map's specifiers: the registry requires them as `vendor:<specifier>`, never compiles them. */
99
+ static script(manifest: CsrDevManifest | null, vendors: string[]): string {
100
+ const config = {
101
+ chunk: SSR_DEV_CHUNK,
102
+ idPrefix: SSR_DEV_ID_PREFIX,
103
+ prefix: SSR_DEV_ROUTE_PREFIX,
104
+ runtime: SsrDevShim.runtimeUrl,
105
+ generation: manifest?.generation ?? 0,
106
+ vendorFile: manifest?.vendorFile ?? null,
107
+ epoch: manifest?.epoch ?? null,
108
+ vendors,
109
+ };
110
+ return `(function (c) {\n ${SsrDevShim.#body}\n})(${JSON.stringify(config)});`;
111
+ }
112
+ }