akanjs 3.0.0-beta.25 → 3.0.0-beta.27

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 (39) hide show
  1. package/client/csrTypes.ts +2 -0
  2. package/client/device.ts +9 -9
  3. package/common/Logger.ts +26 -2
  4. package/common/RouteLayering.ts +52 -0
  5. package/common/hmrPhase.ts +2 -0
  6. package/common/index.ts +1 -0
  7. package/package.json +1 -1
  8. package/server/artifact/devBuildRecovery.ts +12 -0
  9. package/server/artifact/index.ts +1 -0
  10. package/server/artifact/ipcTypes.ts +2 -0
  11. package/server/di/diLifecycle.ts +1 -1
  12. package/server/hmr/clientScript.ts +18 -5
  13. package/server/hmr/csrDevRuntime.ts +11 -7
  14. package/server/hmr/csrDevShell.ts +26 -5
  15. package/server/hmr/devArtifactPruner.ts +77 -0
  16. package/server/hmr/devHmrController.ts +69 -44
  17. package/server/hmr/ssrDevShim.ts +7 -1
  18. package/server/routeTreeBuilder.ts +14 -39
  19. package/server/rscWorker.tsx +7 -1
  20. package/server/rscWorkerHost.ts +126 -40
  21. package/server/webRouter.ts +25 -19
  22. package/types/client/csrTypes.d.ts +2 -0
  23. package/types/common/RouteLayering.d.ts +28 -0
  24. package/types/common/hmrPhase.d.ts +2 -0
  25. package/types/common/index.d.ts +1 -0
  26. package/types/server/artifact/devBuildRecovery.d.ts +4 -0
  27. package/types/server/artifact/index.d.ts +1 -0
  28. package/types/server/artifact/ipcTypes.d.ts +2 -0
  29. package/types/server/hmr/devArtifactPruner.d.ts +9 -0
  30. package/types/server/hmr/devHmrController.d.ts +0 -2
  31. package/types/server/rscWorkerHost.d.ts +5 -4
  32. package/types/server/webRouter.d.ts +4 -2
  33. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +3 -1
  34. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeBridge.kt +19 -0
  35. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeShell.swift +12 -2
  36. package/vendor/@akanjs/native/packages/core/src/runtime.ts +26 -5
  37. package/vendor/@akanjs/native/packages/desktop/src/host.ts +5 -3
  38. package/webkit/CsrRouteTable.ts +15 -35
  39. package/webkit/bootCsr.tsx +11 -8
@@ -174,6 +174,8 @@ export interface Route {
174
174
  path: string;
175
175
  renderPage?: RouteRender;
176
176
  renderLayout?: RouteRender;
177
+ /** `renderLayout` is a generated `__root_layout`: a root boundary the page generator found. */
178
+ isRootLayout?: boolean;
177
179
  /** Synthetic layout render from a `_overrides.tsx` at this node; wraps the subtree in a UI-override provider. */
178
180
  renderOverrides?: RouteRender;
179
181
  pageIncludesOwnLayout?: boolean;
package/client/device.ts CHANGED
@@ -15,14 +15,13 @@ let debugSeq = 0;
15
15
 
16
16
  const isFrameDebugEnabled = () => {
17
17
  if (typeof window === "undefined") return false;
18
- const windowWithTarget = window as typeof window & { __AKAN_MOBILE_TARGET__?: unknown };
19
- const search = new URLSearchParams(window.location.search);
20
- return (
21
- Boolean(windowWithTarget.__AKAN_MOBILE_TARGET__) ||
22
- search.has("akanMobileTarget") ||
23
- search.get("akanFrameDebug") === "1" ||
24
- window.localStorage.getItem("akan:debug:frame") === "1"
25
- );
18
+ if (new URLSearchParams(window.location.search).get("akanFrameDebug") === "1") return true;
19
+ try {
20
+ return window.localStorage.getItem("akan:debug:frame") === "1";
21
+ } catch {
22
+
23
+ return false;
24
+ }
26
25
  };
27
26
 
28
27
  export function debugFrame(event: string, payload: DebugPayload = {}) {
@@ -33,7 +32,8 @@ export function debugFrame(event: string, payload: DebugPayload = {}) {
33
32
  now: Math.round(performance.now()),
34
33
  ...payload,
35
34
  };
36
- console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, details, JSON.stringify(details));
35
+
36
+ console.debug(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, details);
37
37
  }
38
38
 
39
39
  interface DeviceInitOption {
package/common/Logger.ts CHANGED
@@ -77,6 +77,15 @@ const colorizeMap: { [key in LogLevel]: (text: string) => string } = {
77
77
  error: clc.red,
78
78
  };
79
79
 
80
+ const consoleMethodMap = {
81
+ trace: "debug",
82
+ verbose: "debug",
83
+ debug: "debug",
84
+ info: "info",
85
+ warn: "warn",
86
+ error: "error",
87
+ } as const satisfies { [key in LogLevel]: "debug" | "info" | "warn" | "error" };
88
+
80
89
  const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
81
90
 
82
91
  const redactedAttrKey = /password|passwd|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key/i;
@@ -332,7 +341,22 @@ export class Logger {
332
341
  if (!Logger.consoleOutput) return;
333
342
  if (typeof window === "undefined")
334
343
  (process[entry.stream] as unknown as NodeJS.WriteStream | undefined)?.write(entry.message);
335
- else console.log(entry.message);
344
+ else Logger.#writeBrowserConsole(entry.record, entry.message);
345
+ }
346
+
347
+ static #writeBrowserConsole(record: LogRecord, rendered: string) {
348
+ const level = record.level ?? "info";
349
+ const context = record.context ? `[${record.context}] ` : "";
350
+ const attrs = record.attrs ? ` ${Logger.formatAttrs(record.attrs)}` : "";
351
+ const text = Logger.#inNativeShell()
352
+ ? `[${record.name}] ${context}${record.message}${attrs}`
353
+ : rendered.replace(/\n+$/, "");
354
+
355
+ console[consoleMethodMap[level]](text);
356
+ }
357
+ static #inNativeShell() {
358
+ const platform = (globalThis as { __AKAN_NATIVE__?: { platform?: string } }).__AKAN_NATIVE__?.platform;
359
+ return platform !== undefined && platform !== "web";
336
360
  }
337
361
  static #write(name: string | undefined, message: string, context: string, logLevel: LogLevel, attrs?: LogAttrs) {
338
362
  const stream = logLevel === "error" ? "stderr" : "stdout";
@@ -367,7 +391,7 @@ export class Logger {
367
391
  if (!Logger.consoleOutput) return;
368
392
  if (typeof window === "undefined" && method !== "console" && (process as unknown as NodeJS.Process | undefined))
369
393
  process[stream].write(msg);
370
- else console[outputStream === "error" ? "error" : "log"](msg.trim());
394
+ else console[outputStream === "error" ? "error" : Logger.#inNativeShell() ? "debug" : "log"](msg.trim());
371
395
  }
372
396
  static {
373
397
  if (envValue("AKAN_PUBLIC_LOG_LEVEL") === "log" && typeof window === "undefined")
@@ -0,0 +1,52 @@
1
+ interface LayeredRoute<Render> {
2
+ /** The tree segment: `/`, `/:lang`, `/(group)`, `/foo`. */
3
+ path: string;
4
+ renderLayout?: Render;
5
+ /** A generated `__root_layout`: one of the root boundaries the page generator found. */
6
+ isRootLayout?: boolean;
7
+ renderOverrides?: Render;
8
+ /** `false` for a named page file, which sits beside its directory's layout rather than under it. */
9
+ pageIncludesOwnLayout?: boolean;
10
+ }
11
+
12
+ export interface RouteLayer<Render> {
13
+ /** The URL pattern down to this node; a route group adds nothing to it. */
14
+ path: string;
15
+ pathSegments: string[];
16
+ isRoot: boolean;
17
+ /** The root layout above or at this node, overrides left out: once there is one, nothing below is a root. */
18
+ rootLayouts: Render[];
19
+ renderRootLayouts: Render[];
20
+ renderLayouts: Render[];
21
+ /** What a page file at this node renders inside: without this node's layout for a named page file. */
22
+ pageRenderRootLayouts: Render[];
23
+ pageRenderLayouts: Render[];
24
+ }
25
+
26
+ export class RouteLayering {
27
+ static readonly #group = /^\/\(.*\)$/;
28
+
29
+ static of<Render>(route: LayeredRoute<Render>, parent: RouteLayer<Render> | null): RouteLayer<Render> {
30
+ const parentSegments = parent?.pathSegments ?? [];
31
+ const parentRootLayouts = parent?.rootLayouts ?? [];
32
+ const parentRootRenders = parent?.renderRootLayouts ?? [];
33
+ const parentLayouts = parent?.renderLayouts ?? [];
34
+ const segment = RouteLayering.#group.test(route.path) ? "" : route.path;
35
+ const path = parentSegments.filter((part) => part !== "/").join("") + segment;
36
+ const isRoot = parentRootLayouts.length === 0 && (route.isRootLayout === true || !route.renderLayout);
37
+ const overrideRenders = route.renderOverrides ? [route.renderOverrides] : [];
38
+
39
+ const nodeRenders = [...overrideRenders, ...(route.renderLayout ? [route.renderLayout] : [])];
40
+ const pageNodeRenders = route.pageIncludesOwnLayout === false ? overrideRenders : nodeRenders;
41
+ return {
42
+ path,
43
+ pathSegments: [...parentSegments, ...(segment ? [segment] : [])],
44
+ isRoot,
45
+ rootLayouts: isRoot && route.renderLayout ? [...parentRootLayouts, route.renderLayout] : parentRootLayouts,
46
+ renderRootLayouts: isRoot ? [...parentRootRenders, ...nodeRenders] : parentRootRenders,
47
+ renderLayouts: isRoot ? parentLayouts : [...parentLayouts, ...nodeRenders],
48
+ pageRenderRootLayouts: isRoot ? [...parentRootRenders, ...pageNodeRenders] : parentRootRenders,
49
+ pageRenderLayouts: isRoot ? parentLayouts : [...parentLayouts, ...pageNodeRenders],
50
+ };
51
+ }
52
+ }
@@ -5,6 +5,7 @@ declare global {
5
5
  var __AKAN_HMR_PHASE__: AkanHmrPhase | undefined;
6
6
  }
7
7
 
8
+ /** @deprecated Always `null`: the dev client that set the phase applies modules through its registry now. */
8
9
  export function getAkanHmrPhase(): AkanHmrPhase {
9
10
  if (typeof globalThis === "undefined") return null;
10
11
  const phase = globalThis.__AKAN_HMR_PHASE__;
@@ -12,6 +13,7 @@ export function getAkanHmrPhase(): AkanHmrPhase {
12
13
  return null;
13
14
  }
14
15
 
16
+ /** @deprecated Always `false`, for the reason `getAkanHmrPhase` is always `null`. */
15
17
  export function isAkanHmrApplying(): boolean {
16
18
  return getAkanHmrPhase() !== null;
17
19
  }
package/common/index.ts CHANGED
@@ -128,6 +128,7 @@ export {
128
128
  export { deepObjectify, objectify, plainFieldsOf } from "./objectify";
129
129
  export { pathGet, pathGetLoose, pathSet, toPathSegments } from "./objectPath";
130
130
  export { formatPhone, isPhoneNumber } from "./phone";
131
+ export { type RouteLayer, RouteLayering } from "./RouteLayering";
131
132
  export { randomPick, randomPicks } from "./randomPick";
132
133
  export { hostFromRequest, isJsonContentType, originFromRequest } from "./requestOrigin";
133
134
  export { RestClient, type RestClientOptions, type RestRequestOptions } from "./restClient";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.25",
3
+ "version": "3.0.0-beta.27",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,12 @@
1
+ import type { DevBuildStatus } from "./ipcTypes";
2
+
3
+ export class DevBuildRecovery {
4
+
5
+ static recovers(previous: DevBuildStatus | undefined, status: DevBuildStatus): boolean {
6
+ if (!previous || previous.ok || !status.ok) return false;
7
+ if (status.phase === "route" && status.scope !== undefined && previous.scope !== undefined)
8
+ return status.scope === previous.scope && status.generation >= previous.generation;
9
+ if (status.generation !== previous.generation) return status.generation > previous.generation;
10
+ return status.phase === "backend";
11
+ }
12
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./builderRpc";
2
+ export * from "./devBuildRecovery";
2
3
  export * from "./ipcTypes";
3
4
  export * from "./routeClientCache";
4
5
  export * from "./routeSeedIndexStore";
@@ -52,6 +52,8 @@ export interface DevBuildStatus {
52
52
  ok: boolean;
53
53
  files: string[];
54
54
  message?: string;
55
+ /** The route a route build's status speaks for. */
56
+ scope?: string;
55
57
  }
56
58
 
57
59
  export type BuilderReq = {
@@ -118,7 +118,7 @@ export class DiLifecycle {
118
118
  constructor({ env, modules = [], disableModules = [], disableLibs = [] }: DiLifecycleProps, ...libs: AkanLib[]) {
119
119
  this.#env = env;
120
120
  const databaseMode = DiLifecycle.#databaseMode();
121
- this.logger.info(`Database mode: ${DatabaseModes.describe(databaseMode)}`);
121
+ this.logger.debug(`Database mode: ${DatabaseModes.describe(databaseMode)}`);
122
122
 
123
123
  this.#predefinedAdaptor = { ...getPredefinedAdaptor(databaseMode) };
124
124
  this.#libs = libs;
@@ -52,12 +52,12 @@ export const HMR_CLIENT_SCRIPT = `(function(){
52
52
  function connect(){
53
53
  try { socket = new WebSocket(url); }
54
54
  catch(e){ console.error("[akan-hmr] ws init failed", e); schedule(); return; }
55
- socket.addEventListener("open", function(){ attempts = 0; });
56
55
  socket.addEventListener("message", function(ev){
57
56
  var msg;
58
57
  try { msg = JSON.parse(ev.data); } catch (e){ return; }
59
58
  if (!msg || typeof msg.type !== "string") return;
60
59
  if (msg.type === "hello") {
60
+ attempts = 0;
61
61
  var dropped = Array.isArray(msg.failingPhases) && dropBuildErrorsExcept(msg.failingPhases);
62
62
  if (dropped && systemPage) {
63
63
  reloadForUpdate("the build that failed to render this page recovered");
@@ -387,7 +387,7 @@ export const HMR_CLIENT_SCRIPT = `(function(){
387
387
  var current = buildErrorStates[phase];
388
388
  if (!current) return;
389
389
  var generation = typeof msg.generation === "number" ? msg.generation : 0;
390
- var sameGeneration = phase === "backend" || phase === "scan" || phase === "route";
390
+ var sameGeneration = phase === "backend" || phase === "route";
391
391
  var recovered = sameGeneration ? generation >= current.generation : generation > current.generation;
392
392
  if (!recovered) return;
393
393
  delete buildErrorStates[phase];
@@ -405,6 +405,12 @@ export const HMR_CLIENT_SCRIPT = `(function(){
405
405
  return dropped;
406
406
  }
407
407
 
408
+ function clearRenderError(){
409
+ if (!buildErrorStates.build) return;
410
+ delete buildErrorStates.build;
411
+ showBuildRecovered({});
412
+ }
413
+
408
414
  function showBuildRecovered(msg){
409
415
  if (hasBuildErrors()) {
410
416
  renderBuildErrorOverlay();
@@ -462,9 +468,12 @@ export const HMR_CLIENT_SCRIPT = `(function(){
462
468
  return prefix ? prefix + "\\n" + msg.message : msg.message;
463
469
  }
464
470
 
471
+ var rscRefreshSeq = 0;
472
+ var rscRefreshedSeq = 0;
465
473
  function refreshRsc(msg){
466
474
  var receivedAt = Date.now();
467
- var refresh = function(){ doRefreshRsc(msg, receivedAt); };
475
+ var seq = ++rscRefreshSeq;
476
+ var refresh = function(){ if (seq === rscRefreshSeq) doRefreshRsc(msg, receivedAt, seq); };
468
477
  var settle = function(){
469
478
  return self.__akan && typeof self.__akan.whenSettled === "function" ? self.__akan.whenSettled() : null;
470
479
  };
@@ -477,7 +486,7 @@ export const HMR_CLIENT_SCRIPT = `(function(){
477
486
  settled.then(refresh, refresh);
478
487
  }
479
488
 
480
- function doRefreshRsc(msg, receivedAt){
489
+ function doRefreshRsc(msg, receivedAt, seq){
481
490
  var started = performance.now();
482
491
  var overlayToken = beginHmrOverlay("Refreshing page...");
483
492
  try { self.__AKAN_RSC_CLEAR_CACHE__ && self.__AKAN_RSC_CLEAR_CACHE__(); } catch(e){}
@@ -488,9 +497,13 @@ export const HMR_CLIENT_SCRIPT = `(function(){
488
497
  return;
489
498
  }
490
499
  Promise.resolve(self.__AKAN_RSC_REFRESH__({ buildId: msg.buildId })).then(function(){
491
- lastBuildId = msg.buildId;
500
+ if (!(seq < rscRefreshedSeq)) {
501
+ rscRefreshedSeq = seq;
502
+ lastBuildId = msg.buildId;
503
+ }
492
504
  recordTrace("rsc-refresh", msg, receivedAt, Date.now());
493
505
  endHmrOverlay(overlayToken);
506
+ clearRenderError();
494
507
  console.debug && console.debug("[akan-hmr] RSC refreshed", {
495
508
  buildId: msg.buildId,
496
509
  generation: msg.generation,
@@ -207,21 +207,25 @@ export const installCsrDevRuntime = (host: CsrDevRuntimeHost): void => {
207
207
  const entry = script?.dataset.akanCsrEntry;
208
208
  if (!entry) throw new Error("[akan-csr] the app script carries no data-akan-csr-entry");
209
209
  this.#generation = generation;
210
- this.#refresh = this.#load(refresh).exports as RefreshRuntime;
211
-
212
- this.#refresh.injectIntoGlobalHook(host);
213
- this.#run(entry);
210
+ this.#run(entry, () => {
211
+ const runtime = this.#load(refresh).exports as RefreshRuntime;
212
+
213
+ runtime.injectIntoGlobalHook(host);
214
+ this.#refresh = runtime;
215
+ });
214
216
  }
215
217
 
216
218
  startLibrary({ generation, refresh, bootstrap, vendorFile, epoch }: LibraryStart) {
217
219
  this.#library = { vendorFile, epoch };
218
220
  this.#generation = generation;
219
- this.#refresh = this.#load(refresh).exports as RefreshRuntime;
220
- this.#run(bootstrap);
221
+ this.#run(bootstrap, () => {
222
+ this.#refresh = this.#load(refresh).exports as RefreshRuntime;
223
+ });
221
224
  }
222
225
 
223
- #run(entry: string) {
226
+ #run(entry: string, loadRefresh: () => void) {
224
227
  try {
228
+ loadRefresh();
225
229
  this.#load(entry);
226
230
  } catch (error) {
227
231
  this.#startFailed = true;
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import { resolveStaticPath } from "../staticPath";
3
4
  import {
@@ -38,6 +39,8 @@ export class CsrDevShell {
38
39
  readonly #appWaitMs: number;
39
40
  readonly #appPollMs: number;
40
41
  readonly #bootWaitMs: number;
42
+
43
+ readonly #sourceMaps = new Map<string, { stamp: string; body: string }>();
41
44
 
42
45
  constructor(
43
46
  artifactDir: string,
@@ -109,7 +112,8 @@ ${stylesheet} </head>
109
112
  const deadline = Date.now() + this.#appWaitMs;
110
113
  for (;;) {
111
114
  const manifest = await this.readManifest();
112
- if (!manifest || appGenerationOf(manifest) >= generation || Date.now() >= deadline || signal?.aborted) return;
115
+ if (!manifest || manifest.generation < generation || appGenerationOf(manifest) >= generation) return;
116
+ if (Date.now() >= deadline || signal?.aborted) return;
113
117
  await Bun.sleep(this.#appPollMs);
114
118
  }
115
119
  }
@@ -135,9 +139,26 @@ ${stylesheet} </head>
135
139
 
136
140
  async #serveSourceMap(name: string): Promise<Response> {
137
141
  const generatedFile = name.slice(0, -".map".length);
138
- const layoutFile = Bun.file(path.join(this.#dir, `${generatedFile}.layout.json`));
139
- if (!(await layoutFile.exists())) return new Response("Not Found", { status: 404 });
140
- const layout = (await layoutFile.json()) as CsrDevLayout;
142
+ const layoutPath = path.join(this.#dir, `${generatedFile}.layout.json`);
143
+ const stat = await fs.stat(layoutPath).catch(() => null);
144
+ if (!stat) return new Response("Not Found", { status: 404 });
145
+ const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
146
+ const cached = this.#sourceMaps.get(generatedFile);
147
+ const body = cached?.stamp === stamp ? cached.body : await this.#mergeSourceMap(generatedFile, layoutPath);
148
+ if (!body) return new Response("Not Found", { status: 404 });
149
+ this.#sourceMaps.delete(generatedFile);
150
+ this.#sourceMaps.set(generatedFile, { stamp, body });
151
+ for (const oldest of this.#sourceMaps.keys()) {
152
+ if (this.#sourceMaps.size <= 4) break;
153
+ this.#sourceMaps.delete(oldest);
154
+ }
155
+ return new Response(body, { headers: CsrDevShell.#headers("application/json", "no-store") });
156
+ }
157
+
158
+ async #mergeSourceMap(generatedFile: string, layoutPath: string): Promise<string | null> {
159
+ const layoutText = await fs.readFile(layoutPath, "utf8").catch(() => null);
160
+ if (!layoutText) return null;
161
+ const layout = JSON.parse(layoutText) as CsrDevLayout;
141
162
  const sections = await Promise.all(
142
163
  layout.modules.map(async ([id, line]) => {
143
164
  const file = Bun.file(path.join(this.#dir, csrDevModuleFile(id, ".js.map")));
@@ -149,7 +170,7 @@ ${stylesheet} </head>
149
170
  sections.filter((section) => section !== null),
150
171
  layout.lineCount,
151
172
  );
152
- return new Response(JSON.stringify(map), { headers: CsrDevShell.#headers("application/json", "no-store") });
173
+ return JSON.stringify(map);
153
174
  }
154
175
 
155
176
  static #js(body: string | Blob, cacheControl: string): Response {
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Logger } from "akanjs/common";
4
+ import type { CssAsset } from "../types";
5
+
6
+ export class DevArtifactPruner {
7
+
8
+ static readonly #defaultGraceMs = 60_000;
9
+ static readonly #pagesFile = /^pages-[^/\\]+\.js$/;
10
+ readonly #logger = new Logger("DevArtifactPruner");
11
+ readonly #artifactDir: string;
12
+ readonly #graceMs: number;
13
+ #pruning: Promise<void> = Promise.resolve();
14
+
15
+ constructor(artifactDir: string, { graceMs = DevArtifactPruner.#defaultGraceMs }: { graceMs?: number } = {}) {
16
+ this.#artifactDir = path.resolve(artifactDir);
17
+ this.#graceMs = graceMs;
18
+ }
19
+
20
+ prunePages(runningPath: string): Promise<void> {
21
+ return this.#queue(async () => {
22
+ const running = await fs.stat(runningPath).catch(() => null);
23
+ if (!running) return;
24
+ const keep = new Set([path.resolve(runningPath), ...(await this.#baseArtifact()).pages]);
25
+ const serverDir = path.join(this.#artifactDir, "server");
26
+ const before = Math.min(running.mtimeMs, Date.now() - this.#graceMs);
27
+ await this.#removeOlder(serverDir, (name) => DevArtifactPruner.#pagesFile.test(name), keep, before);
28
+ });
29
+ }
30
+
31
+ pruneStyles(cssAssets: Record<string, CssAsset>): Promise<void> {
32
+ return this.#queue(async () => {
33
+ const current = Object.values(cssAssets).map((asset) => path.join(this.#artifactDir, asset.cssRelPath));
34
+ const keep = new Set([...current, ...(await this.#baseArtifact()).styles]);
35
+ const stylesDir = path.join(this.#artifactDir, "styles");
36
+ await this.#removeOlder(stylesDir, (name) => name.endsWith(".css"), keep, Date.now() - this.#graceMs);
37
+ });
38
+ }
39
+
40
+ #queue(prune: () => Promise<void>): Promise<void> {
41
+ this.#pruning = this.#pruning.then(prune).catch((error: unknown) => {
42
+ this.#logger.verbose(`[dev-artifact] prune skipped: ${String(error)}`);
43
+ });
44
+ return this.#pruning;
45
+ }
46
+
47
+ async #removeOlder(dir: string, matches: (name: string) => boolean, keep: Set<string>, before: number) {
48
+ const names = await fs.readdir(dir).catch(() => [] as string[]);
49
+ let removed = 0;
50
+ for (const name of names) {
51
+ const file = path.join(dir, name);
52
+ if (!matches(name) || keep.has(file)) continue;
53
+ const stat = await fs.stat(file).catch(() => null);
54
+ if (!stat || stat.mtimeMs >= before) continue;
55
+
56
+ if (
57
+ await fs.rm(file, { force: true }).then(
58
+ () => true,
59
+ () => false,
60
+ )
61
+ )
62
+ removed += 1;
63
+ }
64
+ if (removed > 0) this.#logger.verbose(`[dev-artifact] removed ${removed} superseded file(s) from ${dir}`);
65
+ }
66
+
67
+ async #baseArtifact(): Promise<{ pages: string[]; styles: string[] }> {
68
+ const text = await fs.readFile(path.join(this.#artifactDir, "base-artifact.json"), "utf8").catch(() => null);
69
+ const artifact = text
70
+ ? (JSON.parse(text) as { pagesBundlePath?: string; cssAssets?: Record<string, CssAsset> })
71
+ : null;
72
+ return {
73
+ pages: artifact?.pagesBundlePath ? [path.resolve(this.#artifactDir, artifact.pagesBundlePath)] : [],
74
+ styles: Object.values(artifact?.cssAssets ?? {}).map((asset) => path.join(this.#artifactDir, asset.cssRelPath)),
75
+ };
76
+ }
77
+ }