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.
- package/client/csrTypes.ts +27 -1
- package/client/native.ts +3 -0
- package/client/route/RouteDefinition.ts +41 -1
- package/package.json +16 -6
- package/server/SSR_MEMORY_DIAGNOSIS.md +2 -2
- package/server/akanApp.ts +3 -3
- package/server/akanServer.ts +7 -1
- package/server/artifact/builderRpc.ts +18 -2
- package/server/artifact/ipcTypes.ts +54 -3
- package/server/artifact/routeClientCache.ts +128 -29
- package/server/hmr/clientScript.ts +157 -79
- package/server/hmr/csrDevManifest.ts +42 -0
- package/server/hmr/csrDevRuntime.ts +690 -0
- package/server/hmr/csrDevShell.ts +170 -0
- package/server/hmr/csrDevSourceMap.ts +142 -0
- package/server/hmr/devHmrController.ts +217 -129
- package/server/hmr/runtimeMetadataFile.ts +17 -0
- package/server/hmr/ssrDevShim.ts +106 -0
- package/server/hmr/ssrUpdateQueue.ts +111 -0
- package/server/hmr/wsHub.ts +48 -7
- package/server/routing/apiRouter.ts +14 -0
- package/server/rscWorker.tsx +5 -1
- package/server/rscWorkerHost.ts +122 -44
- package/server/systemPageDocument.tsx +6 -0
- package/server/types.tsx +6 -0
- package/server/vendor/react-dom.ts +1 -0
- package/server/vendor/react-refresh-runtime.ts +13 -1
- package/server/vendor/react.ts +2 -0
- package/server/webRouter.ts +63 -4
- package/store/agent/ScreenTarget.ts +1 -2
- package/store/agent/StoreSurfaceSource.ts +3 -1
- package/store/storeInstance.ts +28 -21
- package/types/client/csrTypes.d.ts +22 -1
- package/types/client/native.d.ts +1 -0
- package/types/client/route/RouteDefinition.d.ts +14 -0
- package/types/server/artifact/builderRpc.d.ts +4 -2
- package/types/server/artifact/ipcTypes.d.ts +56 -2
- package/types/server/artifact/routeClientCache.d.ts +9 -2
- package/types/server/hmr/csrDevManifest.d.ts +33 -0
- package/types/server/hmr/csrDevRuntime.d.ts +105 -0
- package/types/server/hmr/csrDevShell.d.ts +22 -0
- package/types/server/hmr/csrDevSourceMap.d.ts +17 -0
- package/types/server/hmr/devHmrController.d.ts +10 -4
- package/types/server/hmr/runtimeMetadataFile.d.ts +1 -0
- 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 +27 -7
- package/types/server/routing/apiRouter.d.ts +9 -0
- package/types/server/rscWorkerHost.d.ts +13 -2
- package/types/server/systemPageDocument.d.ts +2 -0
- package/types/server/types.d.ts +6 -0
- package/types/server/vendor/react-dom.d.ts +1 -1
- package/types/server/vendor/react-refresh-runtime.d.ts +1 -1
- package/types/server/vendor/react.d.ts +1 -1
- package/types/server/webRouter.d.ts +4 -0
- package/types/store/storeInstance.d.ts +4 -3
- package/types/ui/System/Client.d.ts +5 -3
- package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +17 -0
- package/types/vendor/@akanjs/native/plugins/app-state/src/index.d.ts +6 -0
- package/types/vendor/use-agentic/AgentActivity.d.ts +11 -0
- package/types/vendor/use-agentic/AgentGate.d.ts +10 -0
- package/types/vendor/use-agentic/AgenticSurface.d.ts +15 -5
- package/types/vendor/use-agentic/index.d.ts +2 -0
- package/types/vendor/use-agentic/surfaceContext.d.ts +3 -0
- package/types/webkit/CsrRouteTable.d.ts +32 -0
- package/types/webkit/CsrStack.d.ts +45 -0
- package/types/webkit/RenderLayer.d.ts +2 -2
- package/types/webkit/bootCsr.d.ts +7 -8
- package/types/webkit/index.d.ts +4 -1
- package/types/webkit/nativeNavigation.d.ts +13 -0
- package/types/webkit/useCsrValues.d.ts +6 -4
- package/types/webkit/useFetch.d.ts +3 -1
- package/types/webkit/useHistory.d.ts +5 -1
- package/types/webkit/usePageActivity.d.ts +2 -0
- package/types/webkit/usePageFocusEffect.d.ts +7 -0
- package/types/webkit/usePageLocation.d.ts +13 -0
- package/ui/Data/Dashboard.tsx +2 -2
- package/ui/Data/ListContainer.tsx +2 -2
- package/ui/Layout/BottomInset.tsx +1 -1
- package/ui/Layout/Navbar.tsx +1 -1
- package/ui/Layout/TopInset.tsx +4 -3
- package/ui/System/CSR.tsx +40 -79
- package/ui/System/Client.tsx +28 -13
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +53 -9
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativePlugin.kt +16 -0
- package/vendor/@akanjs/native/plugins/app/android/AppPlugin.kt +20 -0
- package/vendor/@akanjs/native/plugins/app/native-plugin.json +4 -4
- package/vendor/@akanjs/native/plugins/app/src/desktop.ts +3 -0
- package/vendor/@akanjs/native/plugins/app/src/index.ts +14 -2
- package/vendor/@akanjs/native/plugins/app-state/android/AppStatePlugin.kt +38 -2
- package/vendor/@akanjs/native/plugins/app-state/ios/AppStatePlugin.swift +19 -0
- package/vendor/@akanjs/native/plugins/app-state/native-plugin.json +1 -1
- package/vendor/@akanjs/native/plugins/app-state/src/index.ts +6 -1
- package/vendor/use-agentic/AgentActivity.tsx +26 -0
- package/vendor/use-agentic/AgentGate.ts +25 -0
- package/vendor/use-agentic/AgenticSurface.ts +92 -34
- package/vendor/use-agentic/index.ts +2 -0
- package/vendor/use-agentic/surfaceContext.ts +3 -0
- package/webkit/CsrRouteTable.ts +298 -0
- package/webkit/CsrStack.ts +98 -0
- package/webkit/RenderLayer.tsx +30 -8
- package/webkit/bootCsr.tsx +13 -217
- package/webkit/index.ts +4 -1
- package/webkit/nativeNavigation.ts +78 -5
- package/webkit/useCsrValues.ts +118 -22
- package/webkit/useFetch.ts +9 -2
- package/webkit/useHistory.ts +23 -6
- package/webkit/useLocation.ts +6 -3
- package/webkit/usePageActivity.ts +6 -0
- package/webkit/usePageFocusEffect.ts +13 -0
- package/webkit/usePageLocation.ts +17 -0
package/client/csrTypes.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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.
|
|
228
|
-
"react-dom": "19.
|
|
237
|
+
"react": "19.3.0",
|
|
238
|
+
"react-dom": "19.3.0",
|
|
229
239
|
"react-icons": "^5.6.0",
|
|
230
|
-
"react-refresh": "^0.
|
|
231
|
-
"react-server-dom-webpack": "^19.
|
|
240
|
+
"react-refresh": "^0.19.0",
|
|
241
|
+
"react-server-dom-webpack": "^19.3.0",
|
|
232
242
|
"react-spring": "^10.0.4",
|
|
233
|
-
"scheduler": "^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=
|
|
134
|
-
AKAN_BUILDER_MAX_RSS=
|
|
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;
|
package/server/akanServer.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
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(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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({
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.#
|
|
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.#
|
|
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("#");
|