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
|
@@ -45,12 +45,43 @@ export interface CsrDevRuntimeApi {
|
|
|
45
45
|
defineHelpers(hash: string, factory: () => Record<string, unknown>): void;
|
|
46
46
|
helpers(hash: string): Record<string, unknown>;
|
|
47
47
|
start(options: { generation: number; refresh: string }): void;
|
|
48
|
+
/** No entry to run: an SSR page requires the modules its RSC payload names, after the bootstrap has run. */
|
|
49
|
+
startLibrary(options: LibraryStart): void;
|
|
50
|
+
/** Registers a module the page already loaded outside the registry (an import map vendor). */
|
|
51
|
+
provide(id: string, namespace: unknown): void;
|
|
52
|
+
require(id: string): unknown;
|
|
53
|
+
has(id: string): boolean;
|
|
54
|
+
/** Settles once a patch defines `id`: the RSC payload can name a module whose patch is still on its way. */
|
|
55
|
+
whenDefined(id: string): Promise<void>;
|
|
56
|
+
/** Settles once every update handed to `hot` so far is applied, its accept callbacks included. */
|
|
57
|
+
whenSettled(): Promise<void>;
|
|
48
58
|
update(generation: number, factories: Record<string, CsrModuleFactory>): void;
|
|
49
59
|
accept(ownerId: string, deps: string[], callback: CsrAcceptCallback): void;
|
|
50
60
|
hot(message: CsrUpdateMessage): void;
|
|
61
|
+
/** Loads every patch after the newest generation handed to `hot` up to `generation`, from `<prefix>patch-<n>.js`. */
|
|
62
|
+
catchUp(generation: number, prefix: string): void;
|
|
51
63
|
toESM(mod: unknown, isNodeMode?: number): unknown;
|
|
52
64
|
reExport(target: object, mod: unknown, secondTarget?: object): object | undefined;
|
|
53
|
-
|
|
65
|
+
/** `target` is the newest generation handed to `hot`, applied or still loading. */
|
|
66
|
+
inspect(): {
|
|
67
|
+
generation: number;
|
|
68
|
+
target: number;
|
|
69
|
+
started: boolean;
|
|
70
|
+
failed: boolean;
|
|
71
|
+
executed: string[];
|
|
72
|
+
modules: number;
|
|
73
|
+
/** What the library app.js was built with; undefined for a CSR page. */
|
|
74
|
+
vendorFile?: string;
|
|
75
|
+
epoch?: number;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LibraryStart {
|
|
80
|
+
generation: number;
|
|
81
|
+
refresh: string;
|
|
82
|
+
bootstrap: string;
|
|
83
|
+
vendorFile?: string;
|
|
84
|
+
epoch?: number;
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
interface CsrScriptElement {
|
|
@@ -63,7 +94,7 @@ interface CsrScriptElement {
|
|
|
63
94
|
|
|
64
95
|
export interface CsrDevRuntimeHost {
|
|
65
96
|
__akan?: CsrDevRuntimeApi;
|
|
66
|
-
__AKAN_CSR_LAST_UPDATE__?: { generation: number; executed: string[] };
|
|
97
|
+
__AKAN_CSR_LAST_UPDATE__?: { generation: number; executed: string[]; appliedAt?: number; refreshedAt?: number };
|
|
67
98
|
document: {
|
|
68
99
|
currentScript: unknown;
|
|
69
100
|
head: { appendChild(node: unknown): unknown };
|
|
@@ -71,6 +102,7 @@ export interface CsrDevRuntimeHost {
|
|
|
71
102
|
};
|
|
72
103
|
location: { reload(): void };
|
|
73
104
|
console: { warn(...args: unknown[]): void };
|
|
105
|
+
fetch?(url: string, init: { method: string; cache: "no-store" }): Promise<{ ok: boolean }>;
|
|
74
106
|
setTimeout(callback: () => void, ms: number): unknown;
|
|
75
107
|
clearTimeout(handle: unknown): void;
|
|
76
108
|
}
|
|
@@ -80,6 +112,10 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
80
112
|
const modulePrefix = "akan-module:";
|
|
81
113
|
const maxInvalidateRounds = 8;
|
|
82
114
|
|
|
115
|
+
const maxCatchUp = 40;
|
|
116
|
+
|
|
117
|
+
class MissingModuleError extends Error {}
|
|
118
|
+
|
|
83
119
|
class HotContext implements CsrHotContext {
|
|
84
120
|
selfAccepted = false;
|
|
85
121
|
invalidated = false;
|
|
@@ -117,6 +153,16 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
117
153
|
|
|
118
154
|
class CsrDevRegistry implements CsrDevRuntimeApi {
|
|
119
155
|
#factories = new Map<string, CsrModuleFactory>();
|
|
156
|
+
#waiters = new Map<string, (() => void)[]>();
|
|
157
|
+
|
|
158
|
+
#roots = new Set<string>();
|
|
159
|
+
#started = false;
|
|
160
|
+
#startFailed = false;
|
|
161
|
+
|
|
162
|
+
#failed = new Set<string>();
|
|
163
|
+
#library: { vendorFile?: string; epoch?: number } = {};
|
|
164
|
+
#target = 0;
|
|
165
|
+
#early: CsrUpdateMessage[] = [];
|
|
120
166
|
#helperFactories = new Map<string, () => Record<string, unknown>>();
|
|
121
167
|
#helperValues = new Map<string, Record<string, unknown>>();
|
|
122
168
|
#cache = new Map<string, ModuleRecord>();
|
|
@@ -128,6 +174,10 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
128
174
|
#patchQueue: Promise<void> = Promise.resolve();
|
|
129
175
|
#settling: Promise<void> = Promise.resolve();
|
|
130
176
|
#reloading = false;
|
|
177
|
+
#markStarted: () => void = () => undefined;
|
|
178
|
+
readonly #whenStarted = new Promise<void>((resolve) => {
|
|
179
|
+
this.#markStarted = resolve;
|
|
180
|
+
});
|
|
131
181
|
|
|
132
182
|
get generation() {
|
|
133
183
|
return this.#generation;
|
|
@@ -135,6 +185,7 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
135
185
|
|
|
136
186
|
define(id: string, factory: CsrModuleFactory) {
|
|
137
187
|
this.#factories.set(id, factory);
|
|
188
|
+
this.#settleWaiters([id]);
|
|
138
189
|
}
|
|
139
190
|
|
|
140
191
|
defineHelpers(hash: string, factory: () => Record<string, unknown>) {
|
|
@@ -156,10 +207,85 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
156
207
|
const entry = script?.dataset.akanCsrEntry;
|
|
157
208
|
if (!entry) throw new Error("[akan-csr] the app script carries no data-akan-csr-entry");
|
|
158
209
|
this.#generation = generation;
|
|
159
|
-
this.#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
210
|
+
this.#run(entry, () => {
|
|
211
|
+
const runtime = this.#load(refresh).exports as RefreshRuntime;
|
|
212
|
+
|
|
213
|
+
runtime.injectIntoGlobalHook(host);
|
|
214
|
+
this.#refresh = runtime;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
startLibrary({ generation, refresh, bootstrap, vendorFile, epoch }: LibraryStart) {
|
|
219
|
+
this.#library = { vendorFile, epoch };
|
|
220
|
+
this.#generation = generation;
|
|
221
|
+
this.#run(bootstrap, () => {
|
|
222
|
+
this.#refresh = this.#load(refresh).exports as RefreshRuntime;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#run(entry: string, loadRefresh: () => void) {
|
|
227
|
+
try {
|
|
228
|
+
loadRefresh();
|
|
229
|
+
this.#load(entry);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
this.#startFailed = true;
|
|
232
|
+
throw error;
|
|
233
|
+
} finally {
|
|
234
|
+
this.#begin();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
provide(id: string, namespace: unknown) {
|
|
239
|
+
if (this.#cache.has(id)) return;
|
|
240
|
+
const view: Record<string, unknown> = {};
|
|
241
|
+
Object.defineProperty(view, "__esModule", { value: true });
|
|
242
|
+
if (namespace != null && (typeof namespace === "object" || typeof namespace === "function"))
|
|
243
|
+
for (const key of Object.keys(namespace))
|
|
244
|
+
Object.defineProperty(view, key, {
|
|
245
|
+
get: () => (namespace as Record<string, unknown>)[key],
|
|
246
|
+
enumerable: true,
|
|
247
|
+
});
|
|
248
|
+
const record = new ModuleRecord(id, undefined);
|
|
249
|
+
record.exports = view;
|
|
250
|
+
this.#cache.set(id, record);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
require(id: string) {
|
|
254
|
+
this.#roots.add(id);
|
|
255
|
+
return this.#surfaced(id, () => this.#load(id)).exports;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#surfaced<T>(id: string, load: () => T): T {
|
|
259
|
+
try {
|
|
260
|
+
const loaded = load();
|
|
261
|
+
this.#failed.delete(id);
|
|
262
|
+
return loaded;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const fixable = error instanceof MissingModuleError || !id.includes("node_modules/");
|
|
265
|
+
if (!id.startsWith("stub:") && fixable) this.#failed.add(id);
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
has(id: string) {
|
|
271
|
+
return this.#cache.has(id) || this.#factories.has(id);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
whenDefined(id: string) {
|
|
275
|
+
if (this.has(id)) return Promise.resolve();
|
|
276
|
+
return new Promise<void>((resolve, reject) => {
|
|
277
|
+
|
|
278
|
+
const timer = host.setTimeout(() => {
|
|
279
|
+
this.#failed.add(id);
|
|
280
|
+
reject(new MissingModuleError(`[akan-csr] no module registered as ${id}, and no update brought one`));
|
|
281
|
+
}, 20_000);
|
|
282
|
+
const waiters = this.#waiters.get(id) ?? [];
|
|
283
|
+
waiters.push(() => {
|
|
284
|
+
host.clearTimeout(timer);
|
|
285
|
+
resolve();
|
|
286
|
+
});
|
|
287
|
+
this.#waiters.set(id, waiters);
|
|
288
|
+
});
|
|
163
289
|
}
|
|
164
290
|
|
|
165
291
|
update(generation: number, factories: Record<string, CsrModuleFactory>) {
|
|
@@ -170,7 +296,14 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
170
296
|
}
|
|
171
297
|
const ids = Object.keys(factories);
|
|
172
298
|
for (const id of ids) this.#factories.set(id, factories[id] as CsrModuleFactory);
|
|
299
|
+
this.#settleWaiters(ids);
|
|
173
300
|
this.#generation = generation;
|
|
301
|
+
|
|
302
|
+
const [broken] = this.#failed;
|
|
303
|
+
if (broken) {
|
|
304
|
+
this.#reload(`${broken} failed to run, and generation ${generation} may fix it`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
174
307
|
this.#executed = [];
|
|
175
308
|
const threw = (error: unknown) =>
|
|
176
309
|
this.#reload(
|
|
@@ -182,7 +315,7 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
182
315
|
} catch (error) {
|
|
183
316
|
threw(error);
|
|
184
317
|
}
|
|
185
|
-
host.__AKAN_CSR_LAST_UPDATE__ = { generation, executed: this.#executed.slice() };
|
|
318
|
+
host.__AKAN_CSR_LAST_UPDATE__ = { generation, executed: this.#executed.slice(), appliedAt: Date.now() };
|
|
186
319
|
if (pending.length > 0) this.#settling = Promise.all(pending).then(() => this.#scheduleRefresh(), threw);
|
|
187
320
|
}
|
|
188
321
|
|
|
@@ -193,22 +326,84 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
193
326
|
}
|
|
194
327
|
|
|
195
328
|
hot(message: CsrUpdateMessage) {
|
|
196
|
-
|
|
197
|
-
|
|
329
|
+
|
|
330
|
+
if (!this.#started) {
|
|
331
|
+
this.#early.push(message);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (this.#startFailed && message.reload && message.generation >= this.#generation) {
|
|
336
|
+
this.#reload(`the app failed to start, and generation ${message.generation} was rebuilt`);
|
|
198
337
|
return;
|
|
199
338
|
}
|
|
200
339
|
if (message.generation <= this.#generation) return;
|
|
340
|
+
if (message.reload || this.#startFailed) {
|
|
341
|
+
this.#reload(
|
|
342
|
+
this.#startFailed
|
|
343
|
+
? `the app failed to start, and generation ${message.generation} arrived`
|
|
344
|
+
: (message.reason ?? "the dev server asked for a reload"),
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (message.generation <= this.#target) return;
|
|
201
350
|
const url = message.url;
|
|
202
351
|
if (!url) {
|
|
203
352
|
this.#reload(`generation ${message.generation} arrived without a patch`);
|
|
204
353
|
return;
|
|
205
354
|
}
|
|
206
|
-
|
|
207
|
-
this.#
|
|
355
|
+
const generation = message.generation;
|
|
356
|
+
this.#target = Math.max(this.#target, generation);
|
|
357
|
+
|
|
358
|
+
this.#patchQueue = this.#patchQueue
|
|
359
|
+
.then(() => (generation > this.#target ? undefined : this.#loadPatch(url)))
|
|
360
|
+
.then(() => this.#settling);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
catchUp(generation: number, prefix: string) {
|
|
364
|
+
if (!this.#started) return;
|
|
365
|
+
const from = Math.max(this.#generation, this.#target) + 1;
|
|
366
|
+
if (generation - from >= maxCatchUp) {
|
|
367
|
+
this.#reload(`missed ${generation - from + 1} updates while disconnected`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
for (let next = from; next <= generation; next += 1)
|
|
371
|
+
this.hot({ generation: next, url: `${prefix}patch-${next}.js` });
|
|
208
372
|
}
|
|
209
373
|
|
|
210
374
|
inspect() {
|
|
211
|
-
return {
|
|
375
|
+
return {
|
|
376
|
+
generation: this.#generation,
|
|
377
|
+
target: Math.max(this.#generation, this.#target),
|
|
378
|
+
started: this.#started,
|
|
379
|
+
failed: this.#startFailed,
|
|
380
|
+
vendorFile: this.#library.vendorFile,
|
|
381
|
+
epoch: this.#library.epoch,
|
|
382
|
+
executed: this.#executed.slice(),
|
|
383
|
+
modules: this.#cache.size,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
whenSettled(): Promise<void> {
|
|
388
|
+
if (!this.#started && this.#early.length > 0) return this.#whenStarted.then(() => this.whenSettled());
|
|
389
|
+
return this.#patchQueue.then(() => this.#settling);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#begin() {
|
|
393
|
+
this.#started = true;
|
|
394
|
+
const early = this.#early;
|
|
395
|
+
this.#early = [];
|
|
396
|
+
for (const message of early) this.hot(message);
|
|
397
|
+
this.#markStarted();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#settleWaiters(ids: string[]) {
|
|
401
|
+
for (const id of ids) {
|
|
402
|
+
const waiters = this.#waiters.get(id);
|
|
403
|
+
if (!waiters) continue;
|
|
404
|
+
this.#waiters.delete(id);
|
|
405
|
+
for (const settle of waiters) settle();
|
|
406
|
+
}
|
|
212
407
|
}
|
|
213
408
|
|
|
214
409
|
readonly toESM = (mod: unknown, _isNodeMode?: number): unknown => {
|
|
@@ -237,7 +432,7 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
237
432
|
return secondTarget;
|
|
238
433
|
};
|
|
239
434
|
|
|
240
|
-
#loadPatch(url: string) {
|
|
435
|
+
#loadPatch(url: string, retried = false): Promise<void> {
|
|
241
436
|
return new Promise<void>((resolve) => {
|
|
242
437
|
const script = host.document.createElement("script");
|
|
243
438
|
script.src = url;
|
|
@@ -246,8 +441,21 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
246
441
|
resolve();
|
|
247
442
|
};
|
|
248
443
|
script.onerror = () => {
|
|
249
|
-
|
|
250
|
-
|
|
444
|
+
script.remove();
|
|
445
|
+
const failed = () => {
|
|
446
|
+
this.#reload(`patch ${url} failed to load`);
|
|
447
|
+
resolve();
|
|
448
|
+
};
|
|
449
|
+
const probe = host.fetch?.(url, { method: "HEAD", cache: "no-store" });
|
|
450
|
+
if (!probe) return failed();
|
|
451
|
+
probe.then(
|
|
452
|
+
(response) => (response.ok && !retried ? resolve(this.#loadPatch(url, true)) : failed()),
|
|
453
|
+
|
|
454
|
+
() => {
|
|
455
|
+
this.#target = this.#generation;
|
|
456
|
+
resolve();
|
|
457
|
+
},
|
|
458
|
+
);
|
|
251
459
|
};
|
|
252
460
|
host.document.head.appendChild(script);
|
|
253
461
|
});
|
|
@@ -272,13 +480,17 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
272
480
|
const cached = this.#cache.get(id);
|
|
273
481
|
if (cached) return cached;
|
|
274
482
|
const factory = this.#factories.get(id);
|
|
275
|
-
if (!factory) throw new
|
|
483
|
+
if (!factory) throw new MissingModuleError(`[akan-csr] no module registered as ${id}`);
|
|
276
484
|
const record = new ModuleRecord(id, this.#hotData.get(id));
|
|
277
485
|
this.#cache.set(id, record);
|
|
278
486
|
const requireFromHere = this.#requireFrom(id);
|
|
279
487
|
const register = (type: unknown, localId: string) => this.#refresh?.register(type, `${id} ${localId}`);
|
|
280
488
|
const signature = () => this.#refresh?.createSignatureFunctionForTransform() ?? ((type: unknown) => type);
|
|
281
|
-
const importFromHere = (specifier: string) =>
|
|
489
|
+
const importFromHere = (specifier: string) =>
|
|
490
|
+
new Promise((resolve) => {
|
|
491
|
+
const target = specifier.startsWith(modulePrefix) ? specifier.slice(modulePrefix.length) : specifier;
|
|
492
|
+
resolve(this.#surfaced(target, () => requireFromHere(specifier)));
|
|
493
|
+
});
|
|
282
494
|
try {
|
|
283
495
|
factory.call(record.exports, requireFromHere, record, record.exports, register, signature, importFromHere);
|
|
284
496
|
} catch (error) {
|
|
@@ -357,6 +569,7 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
357
569
|
this.#refreshTimer = host.setTimeout(() => {
|
|
358
570
|
this.#refreshTimer = null;
|
|
359
571
|
this.#refresh?.performReactRefresh();
|
|
572
|
+
if (host.__AKAN_CSR_LAST_UPDATE__) host.__AKAN_CSR_LAST_UPDATE__.refreshedAt = Date.now();
|
|
360
573
|
}, 16);
|
|
361
574
|
}
|
|
362
575
|
|
|
@@ -377,6 +590,8 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
377
590
|
[...plan.boundaries].map((id) => [id, [...(this.#cache.get(id)?.parents ?? [])]] as const),
|
|
378
591
|
);
|
|
379
592
|
this.#dispose(plan.outdated);
|
|
593
|
+
|
|
594
|
+
if ([...plan.boundaries].some((id) => this.#roots.has(id))) this.#scheduleRefresh();
|
|
380
595
|
const invalidated: string[] = [];
|
|
381
596
|
for (const id of plan.boundaries) {
|
|
382
597
|
const record = this.#load(id);
|
|
@@ -387,6 +602,7 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
387
602
|
pending = [];
|
|
388
603
|
for (const id of invalidated) {
|
|
389
604
|
const parents = [...(this.#cache.get(id)?.parents ?? [])];
|
|
605
|
+
if (parents.length === 0 && this.#roots.has(id)) continue;
|
|
390
606
|
if (parents.length === 0) {
|
|
391
607
|
this.#reload(`${id} changed its exports and nothing above it can take the update`);
|
|
392
608
|
return [];
|
|
@@ -434,7 +650,11 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
|
|
|
434
650
|
boundaries.add(id);
|
|
435
651
|
continue;
|
|
436
652
|
}
|
|
437
|
-
if (record.parents.size === 0)
|
|
653
|
+
if (record.parents.size === 0) {
|
|
654
|
+
if (!this.#roots.has(id)) return { reload: `no component boundary above ${id}` };
|
|
655
|
+
boundaries.add(id);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
438
658
|
for (const parentId of record.parents) {
|
|
439
659
|
if (!this.#cache.get(parentId)?.hot.acceptedDeps.has(id)) {
|
|
440
660
|
queue.push(parentId);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { resolveStaticPath } from "../staticPath";
|
|
3
4
|
import {
|
|
5
|
+
appGenerationOf,
|
|
4
6
|
CSR_DEV_APP_FILE,
|
|
5
7
|
CSR_DEV_DIRNAME,
|
|
6
8
|
CSR_DEV_MANIFEST_FILE,
|
|
@@ -19,14 +21,42 @@ export interface CsrDevShellRenderOptions {
|
|
|
19
21
|
cssHref: string | null;
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
export interface CsrDevShellOptions {
|
|
25
|
+
/** The registry this serves: the CSR one by default, `ssr-dev` for the client code of SSR pages. */
|
|
26
|
+
dirName?: string;
|
|
27
|
+
routePrefix?: string;
|
|
28
|
+
appWaitMs?: number;
|
|
29
|
+
appPollMs?: number;
|
|
30
|
+
bootWaitMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
export class CsrDevShell {
|
|
23
34
|
static readonly #runtimeHash = Bun.hash(CSR_DEV_RUNTIME_SCRIPT).toString(36);
|
|
24
35
|
static readonly #servedFile = /^(app\.js|vendor-[\w-]+\.js|patch-\d+\.js|assets\/[\w.-]+)$/;
|
|
25
36
|
static readonly #sourceMapFile = /^(app\.js|patch-\d+\.js)\.map$/;
|
|
26
37
|
readonly #dir: string;
|
|
38
|
+
readonly #routePrefix: string;
|
|
39
|
+
readonly #appWaitMs: number;
|
|
40
|
+
readonly #appPollMs: number;
|
|
41
|
+
readonly #bootWaitMs: number;
|
|
42
|
+
|
|
43
|
+
readonly #sourceMaps = new Map<string, { stamp: string; body: string }>();
|
|
27
44
|
|
|
28
|
-
constructor(
|
|
29
|
-
|
|
45
|
+
constructor(
|
|
46
|
+
artifactDir: string,
|
|
47
|
+
{
|
|
48
|
+
dirName = CSR_DEV_DIRNAME,
|
|
49
|
+
routePrefix = CSR_DEV_ROUTE_PREFIX,
|
|
50
|
+
appWaitMs = 2_000,
|
|
51
|
+
appPollMs = 20,
|
|
52
|
+
bootWaitMs = 60_000,
|
|
53
|
+
}: CsrDevShellOptions = {},
|
|
54
|
+
) {
|
|
55
|
+
this.#dir = path.join(artifactDir, dirName);
|
|
56
|
+
this.#routePrefix = routePrefix;
|
|
57
|
+
this.#appWaitMs = appWaitMs;
|
|
58
|
+
this.#appPollMs = appPollMs;
|
|
59
|
+
this.#bootWaitMs = bootWaitMs;
|
|
30
60
|
}
|
|
31
61
|
|
|
32
62
|
async readManifest(): Promise<CsrDevManifest | null> {
|
|
@@ -52,19 +82,22 @@ export class CsrDevShell {
|
|
|
52
82
|
${stylesheet} </head>
|
|
53
83
|
<body>
|
|
54
84
|
<div id="root"></div>
|
|
55
|
-
<script src="${
|
|
56
|
-
<script src="${
|
|
57
|
-
<script src="${
|
|
85
|
+
<script src="${this.#routePrefix}runtime.js?v=${CsrDevShell.#runtimeHash}"></script>
|
|
86
|
+
<script src="${this.#routePrefix}${CsrDevShell.#attr(manifest.vendorFile)}"></script>
|
|
87
|
+
<script src="${this.#routePrefix}${CSR_DEV_APP_FILE}?g=${manifest.generation}" data-akan-csr-entry="${CsrDevShell.#attr(entry)}"></script>
|
|
58
88
|
</body>
|
|
59
89
|
</html>
|
|
60
90
|
`;
|
|
61
91
|
}
|
|
62
92
|
|
|
63
93
|
async serve(req: Request): Promise<Response> {
|
|
64
|
-
const
|
|
94
|
+
const url = new URL(req.url);
|
|
95
|
+
const name = url.pathname.slice(this.#routePrefix.length);
|
|
65
96
|
if (name === "runtime.js") return CsrDevShell.#js(CSR_DEV_RUNTIME_SCRIPT, "public, max-age=31536000, immutable");
|
|
97
|
+
if (name === "boot.json") return await this.#serveBoot(url.searchParams.get("wait"), req.signal);
|
|
66
98
|
if (CsrDevShell.#sourceMapFile.test(name)) return await this.#serveSourceMap(name);
|
|
67
99
|
if (!CsrDevShell.#servedFile.test(name)) return new Response("Not Found", { status: 404 });
|
|
100
|
+
if (name === CSR_DEV_APP_FILE) await this.#waitForApp(Number(url.searchParams.get("g")), req.signal);
|
|
68
101
|
const filePath = resolveStaticPath(this.#dir, name);
|
|
69
102
|
const file = filePath ? Bun.file(filePath) : null;
|
|
70
103
|
if (!file || !(await file.exists())) return new Response("Not Found", { status: 404 });
|
|
@@ -74,11 +107,58 @@ ${stylesheet} </head>
|
|
|
74
107
|
return CsrDevShell.#js(file, cacheControl);
|
|
75
108
|
}
|
|
76
109
|
|
|
110
|
+
async #waitForApp(generation: number, signal?: AbortSignal): Promise<void> {
|
|
111
|
+
if (!Number.isInteger(generation) || generation <= 0) return;
|
|
112
|
+
const deadline = Date.now() + this.#appWaitMs;
|
|
113
|
+
for (;;) {
|
|
114
|
+
const manifest = await this.readManifest();
|
|
115
|
+
if (!manifest || manifest.generation < generation || appGenerationOf(manifest) >= generation) return;
|
|
116
|
+
if (Date.now() >= deadline || signal?.aborted) return;
|
|
117
|
+
await Bun.sleep(this.#appPollMs);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async #serveBoot(wait: string | null, signal?: AbortSignal): Promise<Response> {
|
|
122
|
+
const asked = wait === null ? Number.NaN : Number(wait);
|
|
123
|
+
const waitMs = Number.isInteger(asked) ? Math.min(Math.max(asked, 0), this.#bootWaitMs) : this.#bootWaitMs;
|
|
124
|
+
const deadline = Date.now() + waitMs;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const manifest = await this.readManifest();
|
|
127
|
+
if (manifest) {
|
|
128
|
+
const body = JSON.stringify({
|
|
129
|
+
generation: manifest.generation,
|
|
130
|
+
vendorFile: manifest.vendorFile,
|
|
131
|
+
epoch: manifest.epoch ?? null,
|
|
132
|
+
});
|
|
133
|
+
return new Response(body, { headers: CsrDevShell.#headers("application/json", "no-store") });
|
|
134
|
+
}
|
|
135
|
+
if (Date.now() >= deadline || signal?.aborted) return new Response("Service Unavailable", { status: 503 });
|
|
136
|
+
await Bun.sleep(this.#appPollMs * 5);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
77
140
|
async #serveSourceMap(name: string): Promise<Response> {
|
|
78
141
|
const generatedFile = name.slice(0, -".map".length);
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
82
162
|
const sections = await Promise.all(
|
|
83
163
|
layout.modules.map(async ([id, line]) => {
|
|
84
164
|
const file = Bun.file(path.join(this.#dir, csrDevModuleFile(id, ".js.map")));
|
|
@@ -90,7 +170,7 @@ ${stylesheet} </head>
|
|
|
90
170
|
sections.filter((section) => section !== null),
|
|
91
171
|
layout.lineCount,
|
|
92
172
|
);
|
|
93
|
-
return
|
|
173
|
+
return JSON.stringify(map);
|
|
94
174
|
}
|
|
95
175
|
|
|
96
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
|
+
}
|