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.
- package/common/hmrPhase.ts +2 -0
- package/package.json +1 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +2 -2
- package/server/akanApp.ts +1 -0
- package/server/akanServer.ts +7 -1
- package/server/artifact/builderRpc.ts +13 -2
- package/server/artifact/devBuildRecovery.ts +12 -0
- package/server/artifact/index.ts +1 -0
- package/server/artifact/ipcTypes.ts +46 -3
- package/server/artifact/routeClientCache.ts +128 -29
- package/server/di/diLifecycle.ts +1 -1
- package/server/hmr/clientScript.ts +148 -86
- package/server/hmr/csrDevManifest.ts +17 -0
- package/server/hmr/csrDevRuntime.ts +238 -18
- package/server/hmr/csrDevShell.ts +90 -10
- package/server/hmr/devArtifactPruner.ts +77 -0
- package/server/hmr/devHmrController.ts +233 -137
- package/server/hmr/ssrDevShim.ts +112 -0
- package/server/hmr/ssrUpdateQueue.ts +111 -0
- package/server/hmr/wsHub.ts +19 -4
- package/server/routing/apiRouter.ts +12 -0
- package/server/rscWorker.tsx +11 -1
- package/server/rscWorkerHost.ts +223 -59
- package/server/systemPageDocument.tsx +6 -0
- package/server/types.tsx +4 -0
- package/server/webRouter.ts +57 -22
- package/types/common/hmrPhase.d.ts +2 -0
- package/types/server/artifact/builderRpc.d.ts +3 -2
- package/types/server/artifact/devBuildRecovery.d.ts +4 -0
- package/types/server/artifact/index.d.ts +1 -0
- package/types/server/artifact/ipcTypes.d.ts +47 -2
- package/types/server/artifact/routeClientCache.d.ts +9 -2
- package/types/server/hmr/csrDevManifest.d.ts +15 -0
- package/types/server/hmr/csrDevRuntime.d.ts +34 -0
- package/types/server/hmr/csrDevShell.d.ts +9 -1
- package/types/server/hmr/devArtifactPruner.d.ts +9 -0
- package/types/server/hmr/devHmrController.d.ts +8 -5
- package/types/server/hmr/ssrDevShim.d.ts +7 -0
- package/types/server/hmr/ssrUpdateQueue.d.ts +23 -0
- package/types/server/hmr/wsHub.d.ts +16 -7
- package/types/server/routing/apiRouter.d.ts +8 -0
- package/types/server/rscWorkerHost.d.ts +14 -2
- package/types/server/systemPageDocument.d.ts +2 -0
- package/types/server/types.d.ts +4 -0
- 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
|
|
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
|
-
|
|
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.#
|
|
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 #
|
|
120
|
+
static #readManifest(artifactDir: string, dirName: string): Partial<CsrDevManifest> | undefined {
|
|
107
121
|
try {
|
|
108
|
-
|
|
109
|
-
fs.readFileSync(path.join(artifactDir,
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
185
|
-
|
|
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
|
|
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}
|
|
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 =
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
+
}
|