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
@@ -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
- inspect(): { generation: number; executed: string[]; modules: number };
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.#refresh = this.#load(refresh).exports as RefreshRuntime;
160
-
161
- this.#refresh.injectIntoGlobalHook(host);
162
- this.#load(entry);
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
- if (message.reload) {
197
- this.#reload(message.reason ?? "the dev server asked for a reload");
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.#patchQueue = this.#patchQueue.then(() => this.#loadPatch(url)).then(() => this.#settling);
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 { generation: this.#generation, executed: this.#executed.slice(), modules: this.#cache.size };
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
- this.#reload(`patch ${url} failed to load`);
250
- resolve();
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 Error(`[akan-csr] no module registered as ${id}`);
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) => new Promise((resolve) => resolve(requireFromHere(specifier)));
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) return { reload: `no component boundary above ${id}` };
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(artifactDir: string) {
29
- this.#dir = path.join(artifactDir, CSR_DEV_DIRNAME);
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="${CSR_DEV_ROUTE_PREFIX}runtime.js?v=${CsrDevShell.#runtimeHash}"></script>
56
- <script src="${CSR_DEV_ROUTE_PREFIX}${CsrDevShell.#attr(manifest.vendorFile)}"></script>
57
- <script src="${CSR_DEV_ROUTE_PREFIX}${CSR_DEV_APP_FILE}?g=${manifest.generation}" data-akan-csr-entry="${CsrDevShell.#attr(entry)}"></script>
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 name = new URL(req.url).pathname.slice(CSR_DEV_ROUTE_PREFIX.length);
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 layoutFile = Bun.file(path.join(this.#dir, `${generatedFile}.layout.json`));
80
- if (!(await layoutFile.exists())) return new Response("Not Found", { status: 404 });
81
- const layout = (await layoutFile.json()) as CsrDevLayout;
142
+ const layoutPath = path.join(this.#dir, `${generatedFile}.layout.json`);
143
+ const stat = await fs.stat(layoutPath).catch(() => null);
144
+ if (!stat) return new Response("Not Found", { status: 404 });
145
+ const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
146
+ const cached = this.#sourceMaps.get(generatedFile);
147
+ const body = cached?.stamp === stamp ? cached.body : await this.#mergeSourceMap(generatedFile, layoutPath);
148
+ if (!body) return new Response("Not Found", { status: 404 });
149
+ this.#sourceMaps.delete(generatedFile);
150
+ this.#sourceMaps.set(generatedFile, { stamp, body });
151
+ for (const oldest of this.#sourceMaps.keys()) {
152
+ if (this.#sourceMaps.size <= 4) break;
153
+ this.#sourceMaps.delete(oldest);
154
+ }
155
+ return new Response(body, { headers: CsrDevShell.#headers("application/json", "no-store") });
156
+ }
157
+
158
+ async #mergeSourceMap(generatedFile: string, layoutPath: string): Promise<string | null> {
159
+ const layoutText = await fs.readFile(layoutPath, "utf8").catch(() => null);
160
+ if (!layoutText) return null;
161
+ const layout = JSON.parse(layoutText) as CsrDevLayout;
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 new Response(JSON.stringify(map), { headers: CsrDevShell.#headers("application/json", "no-store") });
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
+ }