akanjs 3.0.0-beta.23 → 3.0.0-beta.25

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 (111) hide show
  1. package/client/csrTypes.ts +27 -1
  2. package/client/native.ts +3 -0
  3. package/client/route/RouteDefinition.ts +41 -1
  4. package/package.json +16 -6
  5. package/server/SSR_MEMORY_DIAGNOSIS.md +2 -2
  6. package/server/akanApp.ts +3 -3
  7. package/server/akanServer.ts +7 -1
  8. package/server/artifact/builderRpc.ts +18 -2
  9. package/server/artifact/ipcTypes.ts +54 -3
  10. package/server/artifact/routeClientCache.ts +128 -29
  11. package/server/hmr/clientScript.ts +157 -79
  12. package/server/hmr/csrDevManifest.ts +42 -0
  13. package/server/hmr/csrDevRuntime.ts +690 -0
  14. package/server/hmr/csrDevShell.ts +170 -0
  15. package/server/hmr/csrDevSourceMap.ts +142 -0
  16. package/server/hmr/devHmrController.ts +217 -129
  17. package/server/hmr/runtimeMetadataFile.ts +17 -0
  18. package/server/hmr/ssrDevShim.ts +106 -0
  19. package/server/hmr/ssrUpdateQueue.ts +111 -0
  20. package/server/hmr/wsHub.ts +48 -7
  21. package/server/routing/apiRouter.ts +14 -0
  22. package/server/rscWorker.tsx +5 -1
  23. package/server/rscWorkerHost.ts +122 -44
  24. package/server/systemPageDocument.tsx +6 -0
  25. package/server/types.tsx +6 -0
  26. package/server/vendor/react-dom.ts +1 -0
  27. package/server/vendor/react-refresh-runtime.ts +13 -1
  28. package/server/vendor/react.ts +2 -0
  29. package/server/webRouter.ts +63 -4
  30. package/store/agent/ScreenTarget.ts +1 -2
  31. package/store/agent/StoreSurfaceSource.ts +3 -1
  32. package/store/storeInstance.ts +28 -21
  33. package/types/client/csrTypes.d.ts +22 -1
  34. package/types/client/native.d.ts +1 -0
  35. package/types/client/route/RouteDefinition.d.ts +14 -0
  36. package/types/server/artifact/builderRpc.d.ts +4 -2
  37. package/types/server/artifact/ipcTypes.d.ts +56 -2
  38. package/types/server/artifact/routeClientCache.d.ts +9 -2
  39. package/types/server/hmr/csrDevManifest.d.ts +33 -0
  40. package/types/server/hmr/csrDevRuntime.d.ts +105 -0
  41. package/types/server/hmr/csrDevShell.d.ts +22 -0
  42. package/types/server/hmr/csrDevSourceMap.d.ts +17 -0
  43. package/types/server/hmr/devHmrController.d.ts +10 -4
  44. package/types/server/hmr/runtimeMetadataFile.d.ts +1 -0
  45. package/types/server/hmr/ssrDevShim.d.ts +7 -0
  46. package/types/server/hmr/ssrUpdateQueue.d.ts +23 -0
  47. package/types/server/hmr/wsHub.d.ts +27 -7
  48. package/types/server/routing/apiRouter.d.ts +9 -0
  49. package/types/server/rscWorkerHost.d.ts +13 -2
  50. package/types/server/systemPageDocument.d.ts +2 -0
  51. package/types/server/types.d.ts +6 -0
  52. package/types/server/vendor/react-dom.d.ts +1 -1
  53. package/types/server/vendor/react-refresh-runtime.d.ts +1 -1
  54. package/types/server/vendor/react.d.ts +1 -1
  55. package/types/server/webRouter.d.ts +4 -0
  56. package/types/store/storeInstance.d.ts +4 -3
  57. package/types/ui/System/Client.d.ts +5 -3
  58. package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +17 -0
  59. package/types/vendor/@akanjs/native/plugins/app-state/src/index.d.ts +6 -0
  60. package/types/vendor/use-agentic/AgentActivity.d.ts +11 -0
  61. package/types/vendor/use-agentic/AgentGate.d.ts +10 -0
  62. package/types/vendor/use-agentic/AgenticSurface.d.ts +15 -5
  63. package/types/vendor/use-agentic/index.d.ts +2 -0
  64. package/types/vendor/use-agentic/surfaceContext.d.ts +3 -0
  65. package/types/webkit/CsrRouteTable.d.ts +32 -0
  66. package/types/webkit/CsrStack.d.ts +45 -0
  67. package/types/webkit/RenderLayer.d.ts +2 -2
  68. package/types/webkit/bootCsr.d.ts +7 -8
  69. package/types/webkit/index.d.ts +4 -1
  70. package/types/webkit/nativeNavigation.d.ts +13 -0
  71. package/types/webkit/useCsrValues.d.ts +6 -4
  72. package/types/webkit/useFetch.d.ts +3 -1
  73. package/types/webkit/useHistory.d.ts +5 -1
  74. package/types/webkit/usePageActivity.d.ts +2 -0
  75. package/types/webkit/usePageFocusEffect.d.ts +7 -0
  76. package/types/webkit/usePageLocation.d.ts +13 -0
  77. package/ui/Data/Dashboard.tsx +2 -2
  78. package/ui/Data/ListContainer.tsx +2 -2
  79. package/ui/Layout/BottomInset.tsx +1 -1
  80. package/ui/Layout/Navbar.tsx +1 -1
  81. package/ui/Layout/TopInset.tsx +4 -3
  82. package/ui/System/CSR.tsx +40 -79
  83. package/ui/System/Client.tsx +28 -13
  84. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +53 -9
  85. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativePlugin.kt +16 -0
  86. package/vendor/@akanjs/native/plugins/app/android/AppPlugin.kt +20 -0
  87. package/vendor/@akanjs/native/plugins/app/native-plugin.json +4 -4
  88. package/vendor/@akanjs/native/plugins/app/src/desktop.ts +3 -0
  89. package/vendor/@akanjs/native/plugins/app/src/index.ts +14 -2
  90. package/vendor/@akanjs/native/plugins/app-state/android/AppStatePlugin.kt +38 -2
  91. package/vendor/@akanjs/native/plugins/app-state/ios/AppStatePlugin.swift +19 -0
  92. package/vendor/@akanjs/native/plugins/app-state/native-plugin.json +1 -1
  93. package/vendor/@akanjs/native/plugins/app-state/src/index.ts +6 -1
  94. package/vendor/use-agentic/AgentActivity.tsx +26 -0
  95. package/vendor/use-agentic/AgentGate.ts +25 -0
  96. package/vendor/use-agentic/AgenticSurface.ts +92 -34
  97. package/vendor/use-agentic/index.ts +2 -0
  98. package/vendor/use-agentic/surfaceContext.ts +3 -0
  99. package/webkit/CsrRouteTable.ts +298 -0
  100. package/webkit/CsrStack.ts +98 -0
  101. package/webkit/RenderLayer.tsx +30 -8
  102. package/webkit/bootCsr.tsx +13 -217
  103. package/webkit/index.ts +4 -1
  104. package/webkit/nativeNavigation.ts +78 -5
  105. package/webkit/useCsrValues.ts +118 -22
  106. package/webkit/useFetch.ts +9 -2
  107. package/webkit/useHistory.ts +23 -6
  108. package/webkit/useLocation.ts +6 -3
  109. package/webkit/usePageActivity.ts +6 -0
  110. package/webkit/usePageFocusEffect.ts +13 -0
  111. package/webkit/usePageLocation.ts +17 -0
@@ -103,6 +103,9 @@ export type LayoutErrorRender = (props: LayoutErrorProps) => PromiseOrObject<Rea
103
103
  export interface RouteRender {
104
104
  render: LayoutRender | PageRender;
105
105
  isAsync?: boolean;
106
+ /** CSR: a page's render reads the query; a layout's reads only the params of its own path (`paramNames`). */
107
+ kind?: "page" | "layout";
108
+ paramNames?: string[];
106
109
  Loading?: LayoutLoadingRender | PageLoadingRender;
107
110
  /** Loads the module and fills `Loading` without running `render`/`resolveHead` (the suffix compose path). */
108
111
  resolveLoading?: () => void | Promise<void>;
@@ -221,6 +224,7 @@ export interface Location {
221
224
  searchParams: { [key: string]: string | string[] };
222
225
  pathRoute: PathRoute;
223
226
  hash: string;
227
+ entryId?: string;
224
228
  }
225
229
  export type CsrNavigationPhase = "idle" | "preparing" | "transitioning";
226
230
  export type CsrNavigationKind = "push" | "replace" | "back" | "popForward" | "popBack";
@@ -247,6 +251,7 @@ export interface History {
247
251
  idxMap: Map<string, number>;
248
252
  cachedLocationMap: Map<string, Location>;
249
253
  idx: number;
254
+ dormant?: Set<string>;
250
255
  }
251
256
 
252
257
  export interface RouterProps {
@@ -256,14 +261,24 @@ export interface RouterProps {
256
261
  back: () => void | Promise<void>;
257
262
  }
258
263
 
264
+ export type CsrPageType = "current" | "prev" | "pending" | "cached";
265
+ export interface CsrStackEntry {
266
+ key: string;
267
+ location: Location;
268
+ pageType: CsrPageType;
269
+ zIndex: number;
270
+ }
271
+
259
272
  export interface RouteState {
260
273
  clientWidth: number;
261
274
  clientHeight: number;
262
275
  location: Location;
263
276
  prevLocation: Location | null;
264
277
  pendingLocation: Location | null;
278
+ stackEntries: CsrStackEntry[];
265
279
  navigationIntent: NavigationIntent | null;
266
280
  phase: CsrNavigationPhase;
281
+ isBackgrounded: boolean;
267
282
  history: RefObject<History>;
268
283
  topSafeAreaRef: RefObject<HTMLDivElement | null>;
269
284
  bottomSafeAreaRef: RefObject<HTMLDivElement | null>;
@@ -304,7 +319,8 @@ export const useCsr = () => {
304
319
  };
305
320
 
306
321
  export interface PathContextType {
307
- pageType: "current" | "prev" | "cached" | "pending";
322
+ pageType: CsrPageType;
323
+ pageKey?: string;
308
324
  location: Location;
309
325
  prefix?: string;
310
326
  gestureEnabled: boolean;
@@ -317,6 +333,16 @@ export const usePathCtx = () => {
317
333
  return contextValues;
318
334
  };
319
335
 
336
+ export type PageActivity = "current" | "prev" | "pending" | "hidden";
337
+ export interface PageActivityState {
338
+ activity: PageActivity;
339
+ focused: boolean;
340
+ }
341
+ export const pageActivityContext = sharedContext<PageActivityState>("pageActivity", {
342
+ activity: "current",
343
+ focused: true,
344
+ });
345
+
320
346
  export interface PathRoute {
321
347
  path: string;
322
348
  pathSegments: string[];
package/client/native.ts CHANGED
@@ -35,6 +35,9 @@ export const isNativeApp = () => isNative && (platform === "ios" || platform ===
35
35
 
36
36
  export const nativePlatform = () => (isNativeApp() ? (platform as "ios" | "android") : null);
37
37
 
38
+ export const desktopPlatform = () =>
39
+ isNative && (platform === "macos" || platform === "windows" || platform === "linux") ? platform : null;
40
+
38
41
  /** http(s) in the in-app browser, anything else (mailto:, tel:) in the app the system picks. */
39
42
  export const openExternalUrl = async (url: string) => {
40
43
  if (!isNativeApp()) {
@@ -17,6 +17,8 @@ export type RouteArgsShape = Record<string, unknown>;
17
17
  export type RouteKind = "page" | "layout" | "rootLayout";
18
18
  /** Interned: a definition built inside the pages bundle must be recognised by a loader bundled apart from it. */
19
19
  export const routeDefinitionMarker = Symbol.for("akan.routeDefinition");
20
+ /** Interned like the marker: the declared args ride on the module's render function, read by the CSR render cache. */
21
+ export const routeArgsMarker = Symbol.for("akan.routeArgs");
20
22
 
21
23
  type HeadStage<Args> = Head | ((args: Args) => PromiseOrObject<Head | null | undefined>);
22
24
 
@@ -44,6 +46,11 @@ export abstract class RouteDefinition<
44
46
  #head?: HeadStage<never>;
45
47
  #loading?: (args: never) => PromiseOrObject<ReactNode>;
46
48
  #render?: (args: never) => PromiseOrObject<ReactNode>;
49
+ #renderRoute?: {
50
+ render: (args: never) => PromiseOrObject<ReactNode>;
51
+ argCount: number;
52
+ route: (props: RouteRenderProps) => Promise<ReactNode>;
53
+ };
47
54
 
48
55
  config(config: PageConfig) {
49
56
  this.#config = config;
@@ -122,7 +129,7 @@ export abstract class RouteDefinition<
122
129
  const head = this.#head;
123
130
  const loading = this.#loading;
124
131
  const module: PageModule & LayoutModule = {
125
- default: (async (props: RouteRenderProps) => await render(this.#argsOf(props) as never)) as never,
132
+ default: this.#renderRouteOf(render) as never,
126
133
  ...(this.#config ? { pageConfig: this.#config } : {}),
127
134
  ...(head === undefined
128
135
  ? {}
@@ -143,6 +150,39 @@ export abstract class RouteDefinition<
143
150
  return module;
144
151
  }
145
152
 
153
+ #renderRouteOf(render: (args: never) => PromiseOrObject<ReactNode>) {
154
+ const kept = this.#renderRoute;
155
+ if (kept?.render === render && kept.argCount === this.args.length) return kept.route;
156
+ const route = async (props: RouteRenderProps) => await render(this.#argsOf(props) as never);
157
+ Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
158
+ this.#renderRoute = { render, argCount: this.args.length, route };
159
+ return route;
160
+ }
161
+
162
+ /**
163
+ * What a route's render output depends on: a chain's declared args plus `lang`. A legacy module declares nothing,
164
+ * so it depends on the params of its own path (`paramNames`, every param when unknown) and, for a page, every
165
+ * search value — a layout reads no query.
166
+ */
167
+ static renderArgsKey(
168
+ render: unknown,
169
+ props: { params: Record<string, string>; searchParams?: Record<string, string | string[]> },
170
+ { isPage, paramNames }: { isPage: boolean; paramNames?: string[] },
171
+ ): string {
172
+ const declared = (render as { [routeArgsMarker]?: Pick<RouteArgInfo, "kind" | "name">[] } | null)?.[
173
+ routeArgsMarker
174
+ ];
175
+ const searchParams = props.searchParams ?? {};
176
+ if (!declared) {
177
+ const params = paramNames ? paramNames.map((name) => props.params[name]) : props.params;
178
+ return JSON.stringify([params, isPage ? searchParams : null]);
179
+ }
180
+ return JSON.stringify([
181
+ props.params.lang,
182
+ ...declared.map(({ kind, name }) => (kind === "param" ? props.params[name] : searchParams[name])),
183
+ ]);
184
+ }
185
+
146
186
  #argsOf(props: RouteRenderProps): RouteBaseArgs & Args & Extra {
147
187
  const { lang = parseAkanI18nEnv().defaultLocale } = props.params;
148
188
  const resolved = this.resolveArgs({ params: props.params, searchParams: props.searchParams ?? {} });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.23",
3
+ "version": "3.0.0-beta.25",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -200,6 +200,16 @@
200
200
  "types": "./types/server/logging/logTailClient.d.ts",
201
201
  "import": "./server/logging/logTailClient.ts",
202
202
  "default": "./server/logging/logTailClient.ts"
203
+ },
204
+ "./server/hmr/csrDevManifest": {
205
+ "types": "./types/server/hmr/csrDevManifest.d.ts",
206
+ "import": "./server/hmr/csrDevManifest.ts",
207
+ "default": "./server/hmr/csrDevManifest.ts"
208
+ },
209
+ "./server/hmr/runtimeMetadataFile": {
210
+ "types": "./types/server/hmr/runtimeMetadataFile.d.ts",
211
+ "import": "./server/hmr/runtimeMetadataFile.ts",
212
+ "default": "./server/hmr/runtimeMetadataFile.ts"
203
213
  }
204
214
  },
205
215
  "dependencies": {
@@ -224,13 +234,13 @@
224
234
  "ioredis": "^5.10.1",
225
235
  "postgres": "^3.4.9",
226
236
  "protobufjs": "^8.4.0",
227
- "react": "19.2.7",
228
- "react-dom": "19.2.7",
237
+ "react": "19.3.0",
238
+ "react-dom": "19.3.0",
229
239
  "react-icons": "^5.6.0",
230
- "react-refresh": "^0.18.0",
231
- "react-server-dom-webpack": "^19.2.7",
240
+ "react-refresh": "^0.19.0",
241
+ "react-server-dom-webpack": "^19.3.0",
232
242
  "react-spring": "^10.0.4",
233
- "scheduler": "^0.27.0",
243
+ "scheduler": "^0.28.0",
234
244
  "tailwind-scrollbar": "^4.0.2",
235
245
  "typescript": "^6.0.3"
236
246
  },
@@ -130,8 +130,8 @@ that no GC reclaims, so the process only returns that memory by exiting. `AkanAp
130
130
  recycles it — gracefully, after its queues drain and once it has stayed quiet — past a ceiling:
131
131
 
132
132
  ```sh
133
- AKAN_BUILDER_MAX_RSS_MB=1200 # 0 leaves the builder unbounded; default is 1200 in dev
134
- AKAN_BUILDER_MAX_RSS=1200mb # same ceiling with a unit suffix
133
+ AKAN_BUILDER_MAX_RSS_MB=2048 # 0 leaves the builder unbounded; default is 2048 in dev
134
+ AKAN_BUILDER_MAX_RSS=2gb # same ceiling with a unit suffix
135
135
  ```
136
136
 
137
137
  Both resolve through `MemoryLimit.resolveMaxRssBytes`, so `AKAN_MEMORY_LIMIT` or a cgroup limit also
package/server/akanApp.ts CHANGED
@@ -762,9 +762,7 @@ export class AkanApp {
762
762
  return await this.#proxyResponse(req, upstreamRes);
763
763
  } catch (error) {
764
764
  if (AkanApp.#isUpstreamOpenFailure(error)) {
765
- this.logger.error(
766
- `Child ${child.idx}/${child.role} upstream is unreachable (${socketPath}); restarting`,
767
- );
765
+ this.logger.error(`Child ${child.idx}/${child.role} upstream is unreachable (${socketPath}); restarting`);
768
766
  this.#scheduleChildRestart(child, child.proc, "upstream-open-failed");
769
767
  return AkanApp.#unavailableResponse(req, "Federation child upstream is unreachable; restarting");
770
768
  }
@@ -1055,6 +1053,8 @@ export class AkanApp {
1055
1053
  case "invalidate":
1056
1054
  case "css-updated":
1057
1055
  case "pages-updated":
1056
+ case "csr-updated":
1057
+ case "ssr-updated":
1058
1058
  case "build-status":
1059
1059
  this.#fanoutToFederation(message);
1060
1060
  return;
@@ -380,7 +380,13 @@ export class AkanServer {
380
380
  registry: this.#di.registry,
381
381
  live: this.#di.live,
382
382
  hmrHub,
383
- hmrState: webRouter ? { state: webRouter.renderState } : null,
383
+ hmrState: webRouter
384
+ ? {
385
+ state: webRouter.renderState,
386
+ refresh: () => webRouter.refreshHmrState(),
387
+ errors: () => webRouter.hmrBuildErrors(),
388
+ }
389
+ : null,
384
390
  logger: this.logger,
385
391
  onDrain: () => this.#binaryPubsub.flush(),
386
392
  }),
@@ -8,9 +8,11 @@ import type {
8
8
  BuilderRes,
9
9
  BuildRouteClientResult,
10
10
  BuildRouteResultPayload,
11
+ CsrUpdatedPayload,
11
12
  CssPayload,
12
13
  DevBuildStatus,
13
14
  PagesBundlePayload,
15
+ SsrUpdatedPayload,
14
16
  } from "./ipcTypes";
15
17
 
16
18
  export interface BuilderRpcEventHandlers {
@@ -18,7 +20,9 @@ export interface BuilderRpcEventHandlers {
18
20
  onInvalidate?: (event: { kinds: ("code" | "css" | "config")[]; files: string[]; generation?: number }) => void;
19
21
  onCssUpdated?: (css: CssPayload) => void;
20
22
  /** A fresh `pages-*.js` bundle: re-import it in the running worker (`RscWorker.reload`) rather than respawning. */
21
- onPagesUpdated?: (bundle: PagesBundlePayload) => void;
23
+ onPagesUpdated?: (bundle: PagesBundlePayload) => void | Promise<void>;
24
+ onCsrUpdated?: (update: CsrUpdatedPayload) => void;
25
+ onSsrUpdated?: (update: SsrUpdatedPayload) => void;
22
26
  onBuildStatus?: (status: DevBuildStatus) => void;
23
27
  }
24
28
 
@@ -57,7 +61,18 @@ export class BuilderRpc {
57
61
  handlers.onCssUpdated?.(ev.data);
58
62
  return;
59
63
  case "pages-updated":
60
- handlers.onPagesUpdated?.(ev.data);
64
+
65
+ void Promise.resolve(handlers.onPagesUpdated?.(ev.data)).catch((error: unknown) =>
66
+ this.#logger.error(
67
+ `[builder] pages-updated failed: ${error instanceof Error ? error.message : String(error)}`,
68
+ ),
69
+ );
70
+ return;
71
+ case "csr-updated":
72
+ handlers.onCsrUpdated?.(ev.data);
73
+ return;
74
+ case "ssr-updated":
75
+ handlers.onSsrUpdated?.(ev.data);
61
76
  return;
62
77
  case "build-status":
63
78
  handlers.onBuildStatus?.(ev.data);
@@ -85,6 +100,7 @@ export class BuilderRpc {
85
100
  discoveredEntries: payload.discoveredEntries,
86
101
  clientDeps: payload.clientDeps,
87
102
  clientDepsByEntry: payload.clientDepsByEntry,
103
+ ...(payload.seenGeneration !== undefined ? { seenGeneration: payload.seenGeneration } : {}),
88
104
  };
89
105
  }
90
106
 
@@ -9,6 +9,8 @@ export interface BuildRouteResultPayload {
9
9
  clientDepsByEntry?: Record<string, string[]>;
10
10
  routeId?: string;
11
11
  generation?: number;
12
+ /** The newest save batch whose files its client-entry discovery had taken in when it started. */
13
+ seenGeneration?: number;
12
14
  }
13
15
 
14
16
  /** Re-announces a recycled builder's artifact (not an edit); the host drops it when the hashed output did not move. */
@@ -42,7 +44,7 @@ export interface DevChangePlan {
42
44
  reasonByFile: Record<string, string[]>;
43
45
  }
44
46
 
45
- export type BuildPhase = "scan" | "barrel" | "csr" | "pages" | "css" | "route" | "backend";
47
+ export type BuildPhase = "scan" | "barrel" | "csr" | "ssr" | "pages" | "css" | "route" | "backend";
46
48
 
47
49
  export interface DevBuildStatus {
48
50
  generation: number;
@@ -76,24 +78,67 @@ export type BuilderCsrRes =
76
78
  /** Drain and exit (the only way bundler memory returns to the OS); draining, not killing, keeps a rebuild whole. */
77
79
  export type BuilderControl = { type: "builder-shutdown"; reason: string };
78
80
 
81
+ /** Epoch-ms marks a save picks up on its way to the page, so a slow update says which hop it waited in. */
82
+ export interface HmrTrace {
83
+ eventAt?: number;
84
+ flushAt?: number;
85
+ batchAt?: number;
86
+ spawnAt?: number;
87
+ workerStartAt?: number;
88
+ workerAt?: number;
89
+ patchAt?: number;
90
+ sentAt?: number;
91
+ broadcastAt?: number;
92
+ }
93
+
79
94
  export interface PagesBundlePayload {
80
95
  bundlePath: string;
81
96
  buildId: number;
82
97
  generation?: number;
83
98
  changedFiles?: string[];
84
99
  reason?: BuilderStateReason;
100
+ trace?: HmrTrace;
101
+ /** False when no changed file is one the server renders (a `"use client"` edit that kept its export names). */
102
+ serverTouched?: boolean;
85
103
  }
86
104
 
87
105
  /** `Bun.build` keeps native arenas `Bun.gc(true)` never frees (macOS returns none when idle): hence recycling. */
88
106
  export interface BuilderMetrics {
89
107
  /** A peak sampled when the queues drain; stale within seconds on Linux, so the host re-reads RSS from the OS. */
90
108
  rssBytes: number;
91
- /** The builder's newest generation; 0 until it has processed a watch batch since spawning. */
109
+ /** The builder's newest generation; a replacement builder continues from the last one its host saw. */
92
110
  generation: number;
93
111
  /** Work items completed since this builder spawned, so a host can require real work before recycling. */
94
112
  workCount: number;
95
113
  }
96
114
 
115
+ export interface CsrUpdatedPayload {
116
+ generation: number;
117
+ mode: "registry" | "artifact";
118
+ reload: boolean;
119
+ reason?: string;
120
+ patchUrl?: string;
121
+ changedIds?: string[];
122
+ trace?: HmrTrace;
123
+ }
124
+
125
+ /** A new generation of the SSR dev registry, where SSR pages load their client code from: a patch, or a reload. */
126
+ export interface SsrUpdatedPayload {
127
+ generation: number;
128
+ reload: boolean;
129
+ reason?: string;
130
+ patchUrl?: string;
131
+ changedIds?: string[];
132
+ trace?: HmrTrace;
133
+ /** The save also changed what the server renders: the tabs get this patch with that batch's RSC refresh. */
134
+ hold?: boolean;
135
+ /** The watch batch this patch came from, which the `pages-updated` releasing it names as its generation. */
136
+ batchGeneration?: number;
137
+ epoch?: number;
138
+ /** The registry's first build: no tab holds a module of it, so there is nothing to send them. */
139
+ first?: boolean;
140
+ }
141
+
97
142
  export type BuilderEvent =
98
143
 
99
144
  | { type: "builder-ready"; buildId?: string }
@@ -107,8 +152,12 @@ export type BuilderEvent =
107
152
  }
108
153
  | { type: "css-updated"; data: CssPayload }
109
154
  | { type: "pages-updated"; data: PagesBundlePayload }
155
+ | { type: "csr-updated"; data: CsrUpdatedPayload }
156
+ | { type: "ssr-updated"; data: SsrUpdatedPayload }
110
157
  | { type: "build-status"; data: DevBuildStatus }
111
- | { type: "builder-metrics"; data: BuilderMetrics };
158
+ | { type: "builder-metrics"; data: BuilderMetrics }
159
+ /** The boot builds settled (the SSR registry's, and CSR's when the env arms it): their workers are a boot's peak. */
160
+ | { type: "boot-armed" };
112
161
 
113
162
  export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
114
163
 
@@ -128,4 +177,6 @@ export interface BuildRouteClientResult {
128
177
  discoveredEntries?: string[];
129
178
  clientDeps: string[];
130
179
  clientDepsByEntry?: Record<string, string[]>;
180
+ /** The newest save batch whose files its client-entry discovery had taken in when it started. */
181
+ seenGeneration?: number;
131
182
  }
@@ -30,9 +30,24 @@ type PendingBuild = { generation: number; promise: Promise<BuildRouteClientResul
30
30
  export interface InvalidateClientEntriesOptions {
31
31
  routePredicate: (routeId: string) => boolean;
32
32
  staleEntries: Iterable<string>;
33
+ /** The saved files behind it: a build still running that bundled one of them is built again. */
34
+ files?: Iterable<string>;
35
+ /** The save batch behind it: a route build whose discovery had taken that batch in is neither dropped nor rebuilt. */
36
+ batch?: number;
37
+ }
38
+ interface Invalidation {
39
+ /** The generation it moved the cache to: only a build started before it is checked against it. */
40
+ generation: number;
41
+ cleared: boolean;
42
+ routePredicate: (routeId: string) => boolean;
43
+ /** Its stale entries and saved files, normalized. */
44
+ touched: Set<string>;
45
+ batch?: number;
33
46
  }
34
47
 
35
48
  export class RouteClientCache {
49
+ static readonly #minEnsureAttempts = 3;
50
+ static readonly #ensureBudgetMs = 15_000;
36
51
  readonly #logger = new Logger("RouteClientCache");
37
52
  readonly #built = new Map<string, BuildRouteClientResult>();
38
53
  readonly #building = new Map<string, PendingBuild>();
@@ -40,6 +55,7 @@ export class RouteClientCache {
40
55
  readonly #buildRoute: RouteBuildFn;
41
56
  readonly #onMerge?: OnMergeFn;
42
57
  #revision = 0;
58
+ #invalidations: Invalidation[] = [];
43
59
 
44
60
  constructor({ buildRoute, onMerge }: RouteClientCacheOptions) {
45
61
  this.#buildRoute = buildRoute;
@@ -76,21 +92,33 @@ export class RouteClientCache {
76
92
  }
77
93
 
78
94
  async ensure(routeId: string, seeds: string[]): Promise<MergedManifest> {
79
- if (this.#built.has(routeId)) return this.merged;
80
- const existing = this.#building.get(routeId);
81
- if (existing && existing.generation === this.merged.generation) {
82
- await existing.promise;
83
- return this.merged;
84
- }
85
- const generation = this.merged.generation;
86
- const promise = this.#runBuild(routeId, seeds, generation);
87
- this.#building.set(routeId, { generation, promise });
88
- try {
89
- await promise;
90
- } finally {
91
- const current = this.#building.get(routeId);
92
- if (current?.promise === promise) this.#building.delete(routeId);
95
+ const started = Date.now();
96
+ const deadline = started + RouteClientCache.#ensureBudgetMs;
97
+ const within = (attempt: number) => attempt < RouteClientCache.#minEnsureAttempts || Date.now() < deadline;
98
+ let attempt = 0;
99
+ for (; within(attempt) && !this.#built.has(routeId); attempt += 1) {
100
+ const existing = this.#building.get(routeId);
101
+ if (existing) {
102
+
103
+ if (existing.generation === this.merged.generation) await existing.promise;
104
+ else await existing.promise.catch(() => undefined);
105
+ continue;
106
+ }
107
+ const generation = this.merged.generation;
108
+ const promise = this.#runBuild(routeId, seeds, generation);
109
+ this.#building.set(routeId, { generation, promise });
110
+ try {
111
+ await promise;
112
+ } finally {
113
+ const current = this.#building.get(routeId);
114
+ if (current?.promise === promise) this.#building.delete(routeId);
115
+ this.#prune();
116
+ }
93
117
  }
118
+ if (attempt > 1)
119
+ this.#logger.verbose(
120
+ `[route-cache] ensure routeId=${routeId} took ${attempt} attempts in ${Date.now() - started}ms${this.#built.has(routeId) ? "" : "; rendering without it"}`,
121
+ );
94
122
  return this.merged;
95
123
  }
96
124
 
@@ -113,7 +141,7 @@ export class RouteClientCache {
113
141
  const knownEntries = new Set(this.merged.knownEntries);
114
142
  this.#logger.verbose(`[route-cache] build start routeId=${routeId} generation=${generation} seeds=${seeds.length}`);
115
143
  const delta = await this.#buildRoute(routeId, { seeds, knownEntries, generation });
116
- if (this.merged.generation !== generation) {
144
+ if (this.merged.generation !== generation && this.#overtaken(routeId, generation, delta)) {
117
145
  this.#logger.verbose(
118
146
  `[route-cache] stale build ignored routeId=${routeId} generation=${generation} current=${this.merged.generation}`,
119
147
  );
@@ -142,39 +170,106 @@ export class RouteClientCache {
142
170
  return dropped;
143
171
  }
144
172
 
145
- invalidate(predicate: (routeId: string) => boolean): string[] {
146
- const dropped = this.#dropBuilt(predicate);
147
- if (dropped.length > 0) {
148
- this.#rebuildKnownEntriesPreservingManifest(this.merged.generation + 1);
149
- this.#building.clear();
150
- this.#logger.verbose(`[route-cache] invalidated ${dropped.length} routes: ${dropped.join(", ")}`);
151
- }
173
+ invalidate(
174
+ predicate: (routeId: string) => boolean,
175
+ { files = [], batch }: { files?: Iterable<string>; batch?: number } = {},
176
+ ): string[] {
177
+ const spared = this.#sparedBy(batch);
178
+ const routePredicate = (routeId: string) => predicate(routeId) && !spared.has(routeId);
179
+ const dropped = this.#dropBuilt(routePredicate);
180
+ const touched = RouteClientCache.#normalizeAll(files);
181
+ if (dropped.length === 0 && !this.#mayOvertake(routePredicate, touched)) return dropped;
182
+ this.#rebuildKnownEntriesPreservingManifest(this.merged.generation + 1);
183
+ this.#record({ routePredicate, touched, batch });
184
+ this.#logger.verbose(`[route-cache] invalidated ${dropped.length} routes: ${dropped.join(", ")}`);
152
185
  return dropped;
153
186
  }
154
187
 
155
- invalidateClientEntries({ routePredicate, staleEntries }: InvalidateClientEntriesOptions): string[] {
156
- const normalizedStaleEntries = new Set([...staleEntries].map((entry) => RouteClientCache.#normalizePath(entry)));
157
- const dropped = this.#dropBuilt(routePredicate);
158
- if (dropped.length === 0 && normalizedStaleEntries.size === 0) return dropped;
188
+ invalidateClientEntries({
189
+ routePredicate,
190
+ staleEntries,
191
+ files = [],
192
+ batch,
193
+ }: InvalidateClientEntriesOptions): string[] {
194
+ const spared = this.#sparedBy(batch);
195
+
196
+ const fresh = [...spared.values()].flatMap((delta) => [...RouteClientCache.#reachedOf(delta)]);
197
+ const normalizedStaleEntries = new Set(
198
+ [...RouteClientCache.#normalizeAll(staleEntries)].filter(
199
+ (entry) => !fresh.some((file) => file === entry || file.endsWith(`/${entry}`)),
200
+ ),
201
+ );
202
+ const touched = new Set([...normalizedStaleEntries, ...RouteClientCache.#normalizeAll(files)]);
203
+ const predicate = (routeId: string) => routePredicate(routeId) && !spared.has(routeId);
204
+ const dropped = this.#dropBuilt(predicate);
205
+ if (dropped.length === 0 && normalizedStaleEntries.size === 0 && !this.#mayOvertake(predicate, touched))
206
+ return dropped;
159
207
 
160
208
  this.#rebuildKnownEntriesPreservingManifest(this.merged.generation + 1, normalizedStaleEntries);
161
- this.#building.clear();
209
+ this.#record({ routePredicate: predicate, touched, batch });
162
210
  this.#logger.verbose(
163
- `[route-cache] client invalidated routes=${dropped.join(",") || "(none)"} entries=${normalizedStaleEntries.size}`,
211
+ `[route-cache] client invalidated routes=${dropped.join(",") || "(none)"} entries=${normalizedStaleEntries.size}${spared.size > 0 ? ` kept=${[...spared.keys()].join(",")}` : ""}`,
164
212
  );
165
213
  return dropped;
166
214
  }
167
215
 
216
+ #sparedBy(batch: number | undefined): Map<string, BuildRouteClientResult> {
217
+ if (batch === undefined) return new Map();
218
+ return new Map([...this.#built].filter(([, delta]) => (delta.seenGeneration ?? -1) >= batch));
219
+ }
220
+
221
+ static #reachedOf(delta: BuildRouteClientResult): Set<string> {
222
+ return RouteClientCache.#normalizeAll([
223
+ ...(delta.discoveredEntries ?? []),
224
+ ...delta.newEntries,
225
+ ...delta.clientDeps,
226
+ ...Object.values(delta.clientDepsByEntry ?? {}).flat(),
227
+ ]);
228
+ }
229
+
168
230
  clear(): string[] {
169
231
  const dropped = this.#dropBuilt(() => true);
170
232
  const nextGeneration = this.merged.generation + 1;
171
233
  this.merged = this.#getEmptyMerged(nextGeneration);
172
234
  this.#revision += 1;
173
- this.#building.clear();
235
+ this.#record({ cleared: true, routePredicate: () => true, touched: new Set() });
174
236
  this.#logger.verbose(`[route-cache] cleared generation=${nextGeneration} dropped=${dropped.length}`);
175
237
  return dropped;
176
238
  }
177
239
 
240
+ #mayOvertake(predicate: (routeId: string) => boolean, touched: Set<string>): boolean {
241
+ return this.#building.size > 0 && (touched.size > 0 || [...this.#building.keys()].some(predicate));
242
+ }
243
+
244
+ #record({
245
+ cleared = false,
246
+ routePredicate,
247
+ touched,
248
+ batch,
249
+ }: Omit<Invalidation, "generation" | "cleared"> & { cleared?: boolean }) {
250
+ this.#invalidations.push({ generation: this.merged.generation, cleared, routePredicate, touched, batch });
251
+ this.#prune();
252
+ }
253
+
254
+ #prune(): void {
255
+ let oldest = Number.POSITIVE_INFINITY;
256
+ for (const pending of this.#building.values()) oldest = Math.min(oldest, pending.generation);
257
+ this.#invalidations = this.#invalidations.filter((invalidation) => invalidation.generation > oldest);
258
+ }
259
+
260
+ #overtaken(routeId: string, since: number, delta: BuildRouteClientResult): boolean {
261
+ const reached = RouteClientCache.#reachedOf(delta);
262
+ return this.#invalidations.some(
263
+ (invalidation) =>
264
+ invalidation.generation > since &&
265
+ !(invalidation.batch !== undefined && (delta.seenGeneration ?? -1) >= invalidation.batch) &&
266
+ (invalidation.cleared ||
267
+ invalidation.routePredicate(routeId) ||
268
+ (!delta.discoveredEntries && invalidation.touched.size > 0) ||
269
+ [...invalidation.touched].some((file) => reached.has(file))),
270
+ );
271
+ }
272
+
178
273
  #rebuildKnownEntriesPreservingManifest(generation: number, staleEntries: Set<string> = new Set()): void {
179
274
  const staleUrls = new Set<string>();
180
275
  for (const [key, row] of Object.entries(this.merged.clientManifest)) {
@@ -210,6 +305,10 @@ export class RouteClientCache {
210
305
  return filePath.split("\\").join("/");
211
306
  }
212
307
 
308
+ static #normalizeAll(filePaths: Iterable<string>): Set<string> {
309
+ return new Set([...filePaths].map((filePath) => RouteClientCache.#normalizePath(filePath)));
310
+ }
311
+
213
312
  static #manifestKeyMatchesEntries(key: string, entries: Set<string>): boolean {
214
313
  if (entries.size === 0) return false;
215
314
  const hashIdx = key.lastIndexOf("#");