akanjs 2.4.1-rc.2 → 2.4.1-rc.3
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/device.ts +1 -1
- package/client/router.ts +12 -3
- package/package.json +6 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +16 -0
- package/server/akanApp.ts +2 -2
- package/server/artifact/ipcTypes.ts +36 -2
- package/server/memoryLimit.ts +77 -0
- package/server/rscWorkerHost.ts +12 -48
- package/types/server/artifact/ipcTypes.d.ts +37 -1
- package/types/server/memoryLimit.d.ts +26 -0
- package/types/ui/System/frameCssVars.d.ts +1 -1
- package/types/webkit/useFrameRuntime.d.ts +1 -1
- package/ui/Layout/TopInset.tsx +10 -13
- package/ui/ServerPortal.tsx +1 -1
- package/ui/System/CSR.tsx +30 -34
- package/ui/System/Client.tsx +4 -5
- package/ui/System/SSR.tsx +70 -70
- package/ui/System/frameCssVars.ts +1 -1
- package/webkit/useCsrValues.ts +58 -27
- package/webkit/useFrameRuntime.ts +37 -34
package/client/device.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import type { RefObject } from "react";
|
|
3
|
-
import { debugFrame } from "./frameDebug";
|
|
4
3
|
import type {
|
|
5
4
|
CapacitorDeviceInfo,
|
|
6
5
|
CapacitorHapticsModule,
|
|
7
6
|
CapacitorKeyboardInfo,
|
|
8
7
|
CapacitorKeyboardModule,
|
|
9
8
|
} from "./capacitor";
|
|
9
|
+
import { debugFrame } from "./frameDebug";
|
|
10
10
|
|
|
11
11
|
type DeviceInfo = CapacitorDeviceInfo;
|
|
12
12
|
type Keyboard = CapacitorKeyboardModule["Keyboard"];
|
package/client/router.ts
CHANGED
|
@@ -112,7 +112,10 @@ const normalizeRouteManifestPath = (href: string, prefix = "") => {
|
|
|
112
112
|
return segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
|
113
113
|
};
|
|
114
114
|
|
|
115
|
-
export const normalizeDeepLinkHref = (
|
|
115
|
+
export const normalizeDeepLinkHref = (
|
|
116
|
+
href: string,
|
|
117
|
+
origin = globalThis.window?.location?.origin ?? "http://localhost",
|
|
118
|
+
) => {
|
|
116
119
|
const url = new URL(href, origin);
|
|
117
120
|
if (url.protocol === "http:" || url.protocol === "https:") return `${url.pathname}${url.search}${url.hash}`;
|
|
118
121
|
const hostPath = url.hostname ? `/${url.hostname}` : "";
|
|
@@ -268,7 +271,11 @@ class Router {
|
|
|
268
271
|
const state = (history.state ?? {}) as Record<string, unknown>;
|
|
269
272
|
const akanState = (state.__akanRouter ?? {}) as { idx?: number };
|
|
270
273
|
this.#historyIdx = Number.isInteger(akanState.idx) ? (akanState.idx as number) : this.#historyIdx;
|
|
271
|
-
history.replaceState(
|
|
274
|
+
history.replaceState(
|
|
275
|
+
{ ...state, __akanRouter: { ...akanState, idx: this.#historyIdx } },
|
|
276
|
+
"",
|
|
277
|
+
globalThis.window.location.href,
|
|
278
|
+
);
|
|
272
279
|
if (!globalThis.window.addEventListener) return;
|
|
273
280
|
globalThis.window.addEventListener("popstate", (event) => {
|
|
274
281
|
const nextState = ((event.state as Record<string, unknown> | null)?.__akanRouter ?? {}) as { idx?: number };
|
|
@@ -336,7 +343,9 @@ class Router {
|
|
|
336
343
|
}
|
|
337
344
|
const stack = this.#routePaths.size > 0 ? this.#getExistingSegmentStack(path) : [path];
|
|
338
345
|
const baseStack =
|
|
339
|
-
stack.length > 1 || this.#indexPath === path || stack.includes(this.#indexPath)
|
|
346
|
+
stack.length > 1 || this.#indexPath === path || stack.includes(this.#indexPath)
|
|
347
|
+
? stack
|
|
348
|
+
: [this.#indexPath, ...stack];
|
|
340
349
|
const dedupedStack = baseStack.filter((candidate, index) => baseStack.indexOf(candidate) === index);
|
|
341
350
|
const target = `${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
|
|
342
351
|
return dedupedStack.map((candidate, index) => (index === dedupedStack.length - 1 ? target : candidate));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "2.4.1-rc.
|
|
3
|
+
"version": "2.4.1-rc.3",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -165,6 +165,11 @@
|
|
|
165
165
|
"types": "./types/server/rscWorker.d.ts",
|
|
166
166
|
"import": "./server/rscWorker.tsx",
|
|
167
167
|
"default": "./server/rscWorker.tsx"
|
|
168
|
+
},
|
|
169
|
+
"./server/memoryLimit": {
|
|
170
|
+
"types": "./types/server/memoryLimit.d.ts",
|
|
171
|
+
"import": "./server/memoryLimit.ts",
|
|
172
|
+
"default": "./server/memoryLimit.ts"
|
|
168
173
|
}
|
|
169
174
|
},
|
|
170
175
|
"dependencies": {
|
|
@@ -105,3 +105,19 @@ AKAN_RSC_WORKER_RECYCLE_GRACE_MS=5000
|
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
Validate that recycle happens only when `rscPendingRenderCount` is `0`, and that the new `rscWorkerPid` becomes ready before traffic continues.
|
|
108
|
+
|
|
109
|
+
## Dev Builder Recycle
|
|
110
|
+
|
|
111
|
+
The dev builder has the same problem for a different reason: `Bun.build` retains native bundler arenas
|
|
112
|
+
that no GC reclaims, so the process only returns that memory by exiting. `AkanAppHost` therefore
|
|
113
|
+
recycles it — gracefully, after its queues drain and once it has stayed quiet — past a ceiling:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
AKAN_BUILDER_MAX_RSS_MB=1200 # 0 leaves the builder unbounded; default is 1200 in dev
|
|
117
|
+
AKAN_BUILDER_MAX_RSS=1200mb # same ceiling with a unit suffix
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Both resolve through `MemoryLimit.resolveMaxRssBytes`, so `AKAN_MEMORY_LIMIT` or a cgroup limit also
|
|
121
|
+
applies (the builder takes 35% of it, the RSC worker 55%). Look for `recycling builder pid=…` followed
|
|
122
|
+
by `exiting for recycle` and `announced boot state after recycle`; the last line is what re-points a
|
|
123
|
+
running backend at the replacement's artifact.
|
package/server/akanApp.ts
CHANGED
|
@@ -1197,7 +1197,7 @@ export class AkanApp {
|
|
|
1197
1197
|
const reader = stream.getReader();
|
|
1198
1198
|
const decoder = new TextDecoder();
|
|
1199
1199
|
try {
|
|
1200
|
-
|
|
1200
|
+
for (;;) {
|
|
1201
1201
|
const { done, value } = await reader.read();
|
|
1202
1202
|
if (done) break;
|
|
1203
1203
|
const text = decoder.decode(value, { stream: true });
|
|
@@ -1213,7 +1213,7 @@ export class AkanApp {
|
|
|
1213
1213
|
|
|
1214
1214
|
#writeChildOutput(idx: number, role: AkanChildRole, type: "stdout" | "stderr", bufferKey: string, text: string) {
|
|
1215
1215
|
let buffered = `${this.#childOutputBuffers.get(bufferKey) ?? ""}${text}`;
|
|
1216
|
-
|
|
1216
|
+
for (;;) {
|
|
1217
1217
|
const newlineIdx = buffered.indexOf("\n");
|
|
1218
1218
|
if (newlineIdx === -1) break;
|
|
1219
1219
|
const line = buffered.slice(0, newlineIdx + 1);
|
|
@@ -9,11 +9,21 @@ export interface BuildRouteResultPayload {
|
|
|
9
9
|
generation?: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Marks a frontend payload that re-announces a freshly booted artifact rather than reporting an edit.
|
|
14
|
+
* A recycled builder rebuilds every artifact from scratch, but a running backend read
|
|
15
|
+
* `base-artifact.json` once at boot and never re-reads it, so the new state has to be pushed. The
|
|
16
|
+
* host drops such a payload when the hashed output did not actually move, which keeps a clean
|
|
17
|
+
* recycle invisible to connected browsers.
|
|
18
|
+
*/
|
|
19
|
+
export type BuilderStateReason = "builder-recycle";
|
|
20
|
+
|
|
12
21
|
export interface CssPayload {
|
|
13
22
|
cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>;
|
|
14
23
|
cssBase64ByUrl: Record<string, string>;
|
|
15
24
|
generation?: number;
|
|
16
25
|
changedFiles?: string[];
|
|
26
|
+
reason?: BuilderStateReason;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
export type DevChangeRole = "server" | "client" | "shared" | "barrel" | "config" | "css";
|
|
@@ -71,11 +81,34 @@ export type BuilderCsrRes =
|
|
|
71
81
|
| { type: "build-csr-res"; id: number; ok: true }
|
|
72
82
|
| { type: "build-csr-res"; id: number; ok: false; error: string };
|
|
73
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Asks the builder to finish its queued work and exit, which is the only way bundler memory is
|
|
86
|
+
* returned to the OS (see `BuilderMetrics`). The host's existing restart path brings up a
|
|
87
|
+
* replacement, so a graceful drain — rather than a kill — keeps a rebuild in flight from being
|
|
88
|
+
* truncated.
|
|
89
|
+
*/
|
|
90
|
+
export type BuilderControl = { type: "builder-shutdown"; reason: string };
|
|
91
|
+
|
|
74
92
|
export interface PagesBundlePayload {
|
|
75
93
|
bundlePath: string;
|
|
76
94
|
buildId: number;
|
|
77
95
|
generation?: number;
|
|
78
96
|
changedFiles?: string[];
|
|
97
|
+
reason?: BuilderStateReason;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `Bun.build` retains native bundler arenas the process never returns — `Bun.gc(true)` reclaims
|
|
102
|
+
* nothing and the JS heap stays flat while RSS climbs — so the builder's memory only comes back when
|
|
103
|
+
* it exits. It reports its own RSS here whenever its queues drain, and the dev host recycles it past
|
|
104
|
+
* a ceiling to turn unbounded growth into a bounded sawtooth.
|
|
105
|
+
*/
|
|
106
|
+
export interface BuilderMetrics {
|
|
107
|
+
rssBytes: number;
|
|
108
|
+
/** The builder's newest generation; 0 until it has processed a watch batch since spawning. */
|
|
109
|
+
generation: number;
|
|
110
|
+
/** Work items completed since this builder spawned, so a host can require real work before recycling. */
|
|
111
|
+
workCount: number;
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
export type BuilderEvent =
|
|
@@ -90,6 +123,7 @@ export type BuilderEvent =
|
|
|
90
123
|
}
|
|
91
124
|
| { type: "css-updated"; data: CssPayload }
|
|
92
125
|
| { type: "pages-updated"; data: PagesBundlePayload }
|
|
93
|
-
| { type: "build-status"; data: DevBuildStatus }
|
|
126
|
+
| { type: "build-status"; data: DevBuildStatus }
|
|
127
|
+
| { type: "builder-metrics"; data: BuilderMetrics };
|
|
94
128
|
|
|
95
|
-
export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderEvent;
|
|
129
|
+
export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a process's RSS ceiling comes from, shared by every host that recycles a child to bound it.
|
|
5
|
+
*
|
|
6
|
+
* A budget can be declared explicitly, discovered from the container, or absent altogether, so each
|
|
7
|
+
* host asks for a fraction of whatever limit it can find and supplies its own fallback for the last
|
|
8
|
+
* case. Kept as its own module rather than living on a host class because both `akanjs/server` and
|
|
9
|
+
* the dev host in `@akanjs/devkit` need it, and neither should import the other's stack to get it.
|
|
10
|
+
*/
|
|
11
|
+
export class MemoryLimit {
|
|
12
|
+
static readonly #cgroupLimitFiles = ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"];
|
|
13
|
+
/** Host-level cgroup files report effectively-unlimited sentinels; anything this large means "no limit". */
|
|
14
|
+
static readonly #unlimitedSentinelBytes = 1024 ** 5;
|
|
15
|
+
|
|
16
|
+
static parsePositiveIntEnv(name: string): number | null {
|
|
17
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
18
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Reads a byte count with an optional unit suffix, e.g. `512mb`, `2GiB`, `1073741824`. */
|
|
22
|
+
static parseBytesEnv(name: string): number | null {
|
|
23
|
+
const value = process.env[name];
|
|
24
|
+
if (!value) return null;
|
|
25
|
+
const match = /^(\d+)(b|kb|kib|mb|mib|gb|gib)?$/i.exec(value.trim());
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const amount = Number.parseInt(match[1] ?? "", 10);
|
|
28
|
+
const unit = (match[2] ?? "b").toLowerCase();
|
|
29
|
+
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
30
|
+
if (unit === "gb" || unit === "gib") return amount * 1024 * 1024 * 1024;
|
|
31
|
+
if (unit === "mb" || unit === "mib") return amount * 1024 * 1024;
|
|
32
|
+
if (unit === "kb" || unit === "kib") return amount * 1024;
|
|
33
|
+
return amount;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static readCgroupBytes(): number | null {
|
|
37
|
+
for (const filePath of MemoryLimit.#cgroupLimitFiles) {
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.existsSync(filePath)) continue;
|
|
40
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
41
|
+
if (!raw || raw === "max") continue;
|
|
42
|
+
const parsed = Number.parseInt(raw, 10);
|
|
43
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed < MemoryLimit.#unlimitedSentinelBytes) return parsed;
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a ceiling from, in order: an explicit MiB env, an explicit byte env, a fraction of the
|
|
52
|
+
* container's memory limit, and finally the caller's fallback (`null` to leave the process
|
|
53
|
+
* unbounded).
|
|
54
|
+
*/
|
|
55
|
+
static resolveMaxRssBytes({
|
|
56
|
+
megabytesEnv,
|
|
57
|
+
bytesEnv,
|
|
58
|
+
limitFraction,
|
|
59
|
+
fallbackBytes,
|
|
60
|
+
}: {
|
|
61
|
+
megabytesEnv: string;
|
|
62
|
+
bytesEnv: string;
|
|
63
|
+
limitFraction: number;
|
|
64
|
+
fallbackBytes: number | null;
|
|
65
|
+
}): number | null {
|
|
66
|
+
const explicitMb = MemoryLimit.parsePositiveIntEnv(megabytesEnv);
|
|
67
|
+
if (explicitMb) return explicitMb * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
const explicitBytes = MemoryLimit.parseBytesEnv(bytesEnv);
|
|
70
|
+
if (explicitBytes) return explicitBytes;
|
|
71
|
+
|
|
72
|
+
const memoryLimitBytes = MemoryLimit.parseBytesEnv("AKAN_MEMORY_LIMIT") ?? MemoryLimit.readCgroupBytes();
|
|
73
|
+
if (memoryLimitBytes) return Math.floor(memoryLimitBytes * limitFraction);
|
|
74
|
+
|
|
75
|
+
return fallbackBytes;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/server/rscWorkerHost.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { AkanTheme } from "akanjs/fetch";
|
|
|
6
6
|
import type { AkanMetricsReport } from "akanjs/service";
|
|
7
7
|
import type { ClientManifest } from "./artifact";
|
|
8
8
|
import type { RouteCacheInvalidation, RouteCacheRenderState } from "./cachePolicy";
|
|
9
|
+
import { MemoryLimit } from "./memoryLimit";
|
|
9
10
|
import type { RscTraceMetadata, SsrLateRedirect } from "./ssrTypes";
|
|
10
11
|
import type { BaseBuildArtifact, CssAsset } from "./types";
|
|
11
12
|
|
|
@@ -813,7 +814,7 @@ export class RscWorker {
|
|
|
813
814
|
this.restartWhenIdle(`rss>${Math.round(maxRssBytes / 1024 / 1024)}MiB`);
|
|
814
815
|
return;
|
|
815
816
|
}
|
|
816
|
-
const maxRenderCount =
|
|
817
|
+
const maxRenderCount = MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RENDER_COUNT");
|
|
817
818
|
if (maxRenderCount && (metrics.rscRenderCount ?? 0) >= maxRenderCount) {
|
|
818
819
|
this.restartWhenIdle(`renderCount>${maxRenderCount}`);
|
|
819
820
|
return;
|
|
@@ -824,46 +825,12 @@ export class RscWorker {
|
|
|
824
825
|
}
|
|
825
826
|
}
|
|
826
827
|
|
|
827
|
-
static #parsePositiveIntEnv(name: string): number | null {
|
|
828
|
-
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
829
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
828
|
static #isProductionRuntime(): boolean {
|
|
833
829
|
return process.env.NODE_ENV === "production";
|
|
834
830
|
}
|
|
835
831
|
|
|
836
|
-
static #parseBytesEnv(name: string): number | null {
|
|
837
|
-
const value = process.env[name];
|
|
838
|
-
if (!value) return null;
|
|
839
|
-
const match = /^(\d+)(b|kb|kib|mb|mib|gb|gib)?$/i.exec(value.trim());
|
|
840
|
-
if (!match) return null;
|
|
841
|
-
const amount = Number.parseInt(match[1] ?? "", 10);
|
|
842
|
-
const unit = (match[2] ?? "b").toLowerCase();
|
|
843
|
-
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
844
|
-
if (unit === "gb" || unit === "gib") return amount * 1024 * 1024 * 1024;
|
|
845
|
-
if (unit === "mb" || unit === "mib") return amount * 1024 * 1024;
|
|
846
|
-
if (unit === "kb" || unit === "kib") return amount * 1024;
|
|
847
|
-
return amount;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
static #readCgroupMemoryLimitBytes(): number | null {
|
|
851
|
-
for (const filePath of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
|
|
852
|
-
try {
|
|
853
|
-
if (!fs.existsSync(filePath)) continue;
|
|
854
|
-
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
855
|
-
if (!raw || raw === "max") continue;
|
|
856
|
-
const parsed = Number.parseInt(raw, 10);
|
|
857
|
-
|
|
858
|
-
if (Number.isFinite(parsed) && parsed > 0 && parsed < 1024 ** 5) return parsed;
|
|
859
|
-
} catch {
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
return null;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
832
|
static #getRscRecycleGraceMs(): number {
|
|
866
|
-
return
|
|
833
|
+
return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_RECYCLE_GRACE_MS") ?? 5_000;
|
|
867
834
|
}
|
|
868
835
|
|
|
869
836
|
/**
|
|
@@ -872,28 +839,25 @@ export class RscWorker {
|
|
|
872
839
|
*/
|
|
873
840
|
static #getRscMaxReloads(): number | null {
|
|
874
841
|
if (process.env.AKAN_RSC_WORKER_MAX_RELOADS !== undefined)
|
|
875
|
-
return
|
|
842
|
+
return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RELOADS");
|
|
876
843
|
return RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxReloads;
|
|
877
844
|
}
|
|
878
845
|
|
|
879
846
|
static #getRscMinRecycleIntervalMs(): number {
|
|
880
|
-
return
|
|
847
|
+
return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS") ?? 1_000;
|
|
881
848
|
}
|
|
882
849
|
|
|
883
850
|
static #getRscMaxRssBytes(): number | null {
|
|
884
|
-
|
|
885
|
-
|
|
851
|
+
return MemoryLimit.resolveMaxRssBytes({
|
|
852
|
+
megabytesEnv: "AKAN_RSC_WORKER_MAX_RSS_MB",
|
|
853
|
+
bytesEnv: "AKAN_RSC_WORKER_MAX_RSS",
|
|
854
|
+
limitFraction: 0.55,
|
|
886
855
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const memoryLimitBytes = RscWorker.#parseBytesEnv("AKAN_MEMORY_LIMIT") ?? RscWorker.#readCgroupMemoryLimitBytes();
|
|
891
|
-
if (memoryLimitBytes) return Math.floor(memoryLimitBytes * 0.55);
|
|
892
|
-
|
|
893
|
-
return RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxRssBytes;
|
|
856
|
+
fallbackBytes: RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxRssBytes,
|
|
857
|
+
});
|
|
894
858
|
}
|
|
895
859
|
|
|
896
860
|
static #getRscMaxRouteModules(): number | null {
|
|
897
|
-
return
|
|
861
|
+
return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_ROUTE_MODULES");
|
|
898
862
|
}
|
|
899
863
|
}
|
|
@@ -18,6 +18,14 @@ export interface BuildRouteResultPayload {
|
|
|
18
18
|
routeId?: string;
|
|
19
19
|
generation?: number;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Marks a frontend payload that re-announces a freshly booted artifact rather than reporting an edit.
|
|
23
|
+
* A recycled builder rebuilds every artifact from scratch, but a running backend read
|
|
24
|
+
* `base-artifact.json` once at boot and never re-reads it, so the new state has to be pushed. The
|
|
25
|
+
* host drops such a payload when the hashed output did not actually move, which keeps a clean
|
|
26
|
+
* recycle invisible to connected browsers.
|
|
27
|
+
*/
|
|
28
|
+
export type BuilderStateReason = "builder-recycle";
|
|
21
29
|
export interface CssPayload {
|
|
22
30
|
cssAssets: Record<string, {
|
|
23
31
|
cssUrl: string;
|
|
@@ -26,6 +34,7 @@ export interface CssPayload {
|
|
|
26
34
|
cssBase64ByUrl: Record<string, string>;
|
|
27
35
|
generation?: number;
|
|
28
36
|
changedFiles?: string[];
|
|
37
|
+
reason?: BuilderStateReason;
|
|
29
38
|
}
|
|
30
39
|
export type DevChangeRole = "server" | "client" | "shared" | "barrel" | "config" | "css";
|
|
31
40
|
export type DevChangeAction = "restart-backend" | "restart-builder" | "rebuild-client" | "rebuild-css" | "sync-generated" | "restart-dev-host" | "report-error";
|
|
@@ -85,11 +94,35 @@ export type BuilderCsrRes = {
|
|
|
85
94
|
ok: false;
|
|
86
95
|
error: string;
|
|
87
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* Asks the builder to finish its queued work and exit, which is the only way bundler memory is
|
|
99
|
+
* returned to the OS (see `BuilderMetrics`). The host's existing restart path brings up a
|
|
100
|
+
* replacement, so a graceful drain — rather than a kill — keeps a rebuild in flight from being
|
|
101
|
+
* truncated.
|
|
102
|
+
*/
|
|
103
|
+
export type BuilderControl = {
|
|
104
|
+
type: "builder-shutdown";
|
|
105
|
+
reason: string;
|
|
106
|
+
};
|
|
88
107
|
export interface PagesBundlePayload {
|
|
89
108
|
bundlePath: string;
|
|
90
109
|
buildId: number;
|
|
91
110
|
generation?: number;
|
|
92
111
|
changedFiles?: string[];
|
|
112
|
+
reason?: BuilderStateReason;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `Bun.build` retains native bundler arenas the process never returns — `Bun.gc(true)` reclaims
|
|
116
|
+
* nothing and the JS heap stays flat while RSS climbs — so the builder's memory only comes back when
|
|
117
|
+
* it exits. It reports its own RSS here whenever its queues drain, and the dev host recycles it past
|
|
118
|
+
* a ceiling to turn unbounded growth into a bounded sawtooth.
|
|
119
|
+
*/
|
|
120
|
+
export interface BuilderMetrics {
|
|
121
|
+
rssBytes: number;
|
|
122
|
+
/** The builder's newest generation; 0 until it has processed a watch batch since spawning. */
|
|
123
|
+
generation: number;
|
|
124
|
+
/** Work items completed since this builder spawned, so a host can require real work before recycling. */
|
|
125
|
+
workCount: number;
|
|
93
126
|
}
|
|
94
127
|
export type BuilderEvent = {
|
|
95
128
|
type: "builder-ready";
|
|
@@ -112,5 +145,8 @@ export type BuilderEvent = {
|
|
|
112
145
|
} | {
|
|
113
146
|
type: "build-status";
|
|
114
147
|
data: DevBuildStatus;
|
|
148
|
+
} | {
|
|
149
|
+
type: "builder-metrics";
|
|
150
|
+
data: BuilderMetrics;
|
|
115
151
|
};
|
|
116
|
-
export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderEvent;
|
|
152
|
+
export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a process's RSS ceiling comes from, shared by every host that recycles a child to bound it.
|
|
3
|
+
*
|
|
4
|
+
* A budget can be declared explicitly, discovered from the container, or absent altogether, so each
|
|
5
|
+
* host asks for a fraction of whatever limit it can find and supplies its own fallback for the last
|
|
6
|
+
* case. Kept as its own module rather than living on a host class because both `akanjs/server` and
|
|
7
|
+
* the dev host in `@akanjs/devkit` need it, and neither should import the other's stack to get it.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MemoryLimit {
|
|
10
|
+
#private;
|
|
11
|
+
static parsePositiveIntEnv(name: string): number | null;
|
|
12
|
+
/** Reads a byte count with an optional unit suffix, e.g. `512mb`, `2GiB`, `1073741824`. */
|
|
13
|
+
static parseBytesEnv(name: string): number | null;
|
|
14
|
+
static readCgroupBytes(): number | null;
|
|
15
|
+
/**
|
|
16
|
+
* Resolves a ceiling from, in order: an explicit MiB env, an explicit byte env, a fraction of the
|
|
17
|
+
* container's memory limit, and finally the caller's fallback (`null` to leave the process
|
|
18
|
+
* unbounded).
|
|
19
|
+
*/
|
|
20
|
+
static resolveMaxRssBytes({ megabytesEnv, bytesEnv, limitFraction, fallbackBytes, }: {
|
|
21
|
+
megabytesEnv: string;
|
|
22
|
+
bytesEnv: string;
|
|
23
|
+
limitFraction: number;
|
|
24
|
+
fallbackBytes: number | null;
|
|
25
|
+
}): number | null;
|
|
26
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CSSProperties } from "react";
|
|
2
1
|
import type { PageState } from "akanjs/client";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
3
|
export type AkanFrameCssVarName = "--akan-top-safe-area" | "--akan-bottom-safe-area" | "--akan-top-inset" | "--akan-bottom-inset" | "--akan-page-padding-top" | "--akan-page-padding-bottom";
|
|
4
4
|
export type AkanFrameCssVars = CSSProperties & Record<AkanFrameCssVarName, string>;
|
|
5
5
|
export declare function getFrameCssVars(pageState: PageState): AkanFrameCssVars;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FrameLayoutState, type FramePlatformProfile, type
|
|
1
|
+
import { type FrameLayoutState, type FramePlatformProfile, type FrameSlotRegistration, type FrameSnapshot, type KeyboardFrameState, type Location, type NavigationIntent, type PageState, type PathRoute, type TransitionPlan, type TransitionType } from "akanjs/client";
|
|
2
2
|
export type FrameSlotMap = Record<string, Record<string, FrameSlotRegistration>>;
|
|
3
3
|
export type FrameSlotBucket = "active" | "pending";
|
|
4
4
|
export type FrameSlotMapByBucket = Record<FrameSlotBucket, FrameSlotMap>;
|
package/ui/Layout/TopInset.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { getEnv } from "akanjs/base";
|
|
3
|
-
import { clsx,
|
|
3
|
+
import { clsx, DEFAULT_TOP_INSET, debugFrame, usePathCtx } from "akanjs/client";
|
|
4
4
|
import { type ReactNode, useLayoutEffect } from "react";
|
|
5
5
|
|
|
6
6
|
import { Portal } from "../Portal";
|
|
@@ -22,18 +22,15 @@ export const TopInset = ({ className, children, estimatedHeight = DEFAULT_TOP_IN
|
|
|
22
22
|
debugFrame("topInset.mount", { path, estimatedHeight });
|
|
23
23
|
return () => debugFrame("topInset.unmount", { path, estimatedHeight });
|
|
24
24
|
}, [path, estimatedHeight]);
|
|
25
|
-
useLayoutEffect(
|
|
26
|
-
()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
},
|
|
35
|
-
[registerFrameSlot, estimatedHeight, path],
|
|
36
|
-
);
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
if (!path) return;
|
|
27
|
+
return registerFrameSlot({
|
|
28
|
+
type: "topInset",
|
|
29
|
+
scope: "page",
|
|
30
|
+
source: "topInset",
|
|
31
|
+
estimatedHeight,
|
|
32
|
+
});
|
|
33
|
+
}, [registerFrameSlot, estimatedHeight, path]);
|
|
37
34
|
|
|
38
35
|
return (
|
|
39
36
|
<Portal id={`topInsetContent${suffix}`}>
|
package/ui/ServerPortal.tsx
CHANGED
package/ui/System/CSR.tsx
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { getEnv } from "akanjs/base";
|
|
4
4
|
import {
|
|
5
5
|
clsx,
|
|
6
|
-
debugFrame,
|
|
7
6
|
Device,
|
|
7
|
+
debugFrame,
|
|
8
8
|
getPathInfo,
|
|
9
9
|
type PathRoute,
|
|
10
10
|
type ReactFont,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { st } from "akanjs/store";
|
|
17
17
|
import { animated } from "akanjs/ui";
|
|
18
18
|
import { useFetch } from "akanjs/webkit";
|
|
19
|
-
import { createElement, memo, type
|
|
19
|
+
import { type ComponentProps, createElement, memo, type ReactNode, type RefObject, useEffect, useRef } from "react";
|
|
20
20
|
import { createPortal } from "react-dom";
|
|
21
21
|
|
|
22
22
|
import { FontFace } from "../FontFace";
|
|
@@ -369,12 +369,14 @@ const CSRFrameSlotTargets = ({ slot }: { slot: FrameSlotTarget }) => {
|
|
|
369
369
|
(pageType === "prev" && (slot === "bottomInset" || slot === "keyboardInset")),
|
|
370
370
|
"pointer-events-none absolute opacity-0": pageType === "pending",
|
|
371
371
|
})}
|
|
372
|
-
style={
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
372
|
+
style={
|
|
373
|
+
{
|
|
374
|
+
...getFrameCssVars(pathRoute.pageState),
|
|
375
|
+
...(style ?? {}),
|
|
376
|
+
zIndex: pageType === "pending" ? -1 : zIndex,
|
|
377
|
+
...(pageType === "pending" ? { opacity: 0 } : {}),
|
|
378
|
+
} as ComponentProps<typeof animated.div>["style"]
|
|
379
|
+
}
|
|
378
380
|
/>
|
|
379
381
|
);
|
|
380
382
|
})}
|
|
@@ -447,14 +449,7 @@ const CSRPageContainer = ({ pathRoute, prefix, layoutStyle }: CSRPageContainerPr
|
|
|
447
449
|
if (!pageType) return null;
|
|
448
450
|
const pageContainers = document.getElementById("pageContainers");
|
|
449
451
|
if (!pageContainers) return null;
|
|
450
|
-
const {
|
|
451
|
-
location,
|
|
452
|
-
page,
|
|
453
|
-
pageContentRef,
|
|
454
|
-
pageClassName,
|
|
455
|
-
pageBind,
|
|
456
|
-
zIndex,
|
|
457
|
-
} =
|
|
452
|
+
const { location, page, pageContentRef, pageClassName, pageBind, zIndex } =
|
|
458
453
|
pageType === "current"
|
|
459
454
|
? {
|
|
460
455
|
location: currentLocation,
|
|
@@ -473,23 +468,23 @@ const CSRPageContainer = ({ pathRoute, prefix, layoutStyle }: CSRPageContainerPr
|
|
|
473
468
|
pageBind: () => ({}),
|
|
474
469
|
zIndex: history.current.idxMap.get(prevLocation?.pathname ?? "") ?? 0,
|
|
475
470
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
471
|
+
: pageType === "pending"
|
|
472
|
+
? {
|
|
473
|
+
location: pendingLocation,
|
|
474
|
+
page: null,
|
|
475
|
+
pageContentRef: null,
|
|
476
|
+
pageClassName: "",
|
|
477
|
+
pageBind: () => ({}),
|
|
478
|
+
zIndex: history.current.idx + 1,
|
|
479
|
+
}
|
|
480
|
+
: {
|
|
481
|
+
location: history.current.cachedLocationMap.get(pathRoute.path),
|
|
482
|
+
page: null,
|
|
483
|
+
pageContentRef: null,
|
|
484
|
+
pageClassName: "",
|
|
485
|
+
pageBind: () => ({}),
|
|
486
|
+
zIndex: 0,
|
|
487
|
+
};
|
|
493
488
|
if (!location) return null;
|
|
494
489
|
return (
|
|
495
490
|
<>
|
|
@@ -514,7 +509,8 @@ const CSRPageContainer = ({ pathRoute, prefix, layoutStyle }: CSRPageContainerPr
|
|
|
514
509
|
bind={pageBind}
|
|
515
510
|
className={clsx("akan-page-content relative isolate w-full overflow-x-hidden bg-base-100 shadow-inner", {
|
|
516
511
|
"relative isolate overflow-x-hidden bg-base-100 shadow-inner": pageType === "current",
|
|
517
|
-
"pointer-events-none isolate h-screen w-screen overflow-hidden":
|
|
512
|
+
"pointer-events-none isolate h-screen w-screen overflow-hidden":
|
|
513
|
+
pageType === "prev" || pageType === "pending",
|
|
518
514
|
[pageClassName]: pathRoute.pageState.gesture,
|
|
519
515
|
})}
|
|
520
516
|
style={page?.contentStyle}
|
package/ui/System/Client.tsx
CHANGED
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
getPathInfo,
|
|
11
11
|
initAuth,
|
|
12
12
|
type Location,
|
|
13
|
-
type PageState,
|
|
14
13
|
navigateRsc,
|
|
14
|
+
type PageState,
|
|
15
15
|
type PathRoute,
|
|
16
16
|
pathContext,
|
|
17
17
|
router,
|
|
@@ -36,11 +36,10 @@ import {
|
|
|
36
36
|
useRef,
|
|
37
37
|
useState,
|
|
38
38
|
} from "react";
|
|
39
|
-
|
|
39
|
+
import { getFrameCssVars } from "./frameCssVars";
|
|
40
40
|
import { Gtag } from "./Gtag";
|
|
41
41
|
import { Messages } from "./Messages";
|
|
42
42
|
import { Reconnect } from "./Reconnect";
|
|
43
|
-
import { getFrameCssVars } from "./frameCssVars";
|
|
44
43
|
|
|
45
44
|
declare global {
|
|
46
45
|
var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
|
|
@@ -415,8 +414,8 @@ export const ClientSsrBridge = ({ lang, prefix = "", initialPageState }: ClientS
|
|
|
415
414
|
}, 1000);
|
|
416
415
|
};
|
|
417
416
|
const handleSyncNavigation = (event: Event) => {
|
|
418
|
-
const { href, kind = "push" } =
|
|
419
|
-
.detail ?? {};
|
|
417
|
+
const { href, kind = "push" } =
|
|
418
|
+
(event as CustomEvent<{ href?: string; kind?: "push" | "replace" | "back" | "pop" }>).detail ?? {};
|
|
420
419
|
if (!href) return;
|
|
421
420
|
const target = new URL(href, window.location.origin);
|
|
422
421
|
const targetHref = `${target.pathname}${target.search}${target.hash}`;
|
package/ui/System/SSR.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type WebAppManifest,
|
|
12
12
|
} from "akanjs/client";
|
|
13
13
|
import { getRequestFrameState, setRequestTheme } from "akanjs/fetch";
|
|
14
|
-
import { Children,
|
|
14
|
+
import { Children, type CSSProperties, Fragment, type ReactNode, Suspense } from "react";
|
|
15
15
|
import { FontCss } from "../fontCss";
|
|
16
16
|
import { Load } from "../Load";
|
|
17
17
|
import { createServerPortalStore, ServerPortalOutlet, setActiveServerPortalStore } from "../ServerPortal";
|
|
@@ -187,81 +187,81 @@ const SSRWrapper = ({
|
|
|
187
187
|
initialHash={hash}
|
|
188
188
|
initialPageState={pageState}
|
|
189
189
|
>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
</div>
|
|
190
|
+
<div
|
|
191
|
+
key="top-safe-area"
|
|
192
|
+
id="topSafeArea"
|
|
193
|
+
className={clsx("fixed inset-x-0 top-0 bg-base-100")}
|
|
194
|
+
style={topSafeAreaStyle}
|
|
195
|
+
/>
|
|
196
|
+
<div key="page-containers" id="pageContainers" className={clsx("isolate")}>
|
|
197
|
+
<div id="pageContainer">
|
|
198
|
+
<div
|
|
199
|
+
id="pageContent"
|
|
200
|
+
style={pageContentStyle}
|
|
201
|
+
className={clsx("relative isolate", {
|
|
202
|
+
"w-full": layoutStyle === "web",
|
|
203
|
+
"left-1/2 h-screen w-[600px] -translate-x-1/2": layoutStyle === "mobile",
|
|
204
|
+
})}
|
|
205
|
+
>
|
|
206
|
+
{Children.toArray(children)}
|
|
208
207
|
</div>
|
|
209
208
|
</div>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
209
|
+
</div>
|
|
210
|
+
<div
|
|
211
|
+
key="top-inset"
|
|
212
|
+
id="topInsetContainer"
|
|
213
|
+
className={clsx("fixed inset-x-0 top-0 isolate bg-base-100", {
|
|
214
|
+
"left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
|
|
215
|
+
"w-full": layoutStyle === "web",
|
|
216
|
+
})}
|
|
217
|
+
style={topInsetStyle}
|
|
218
|
+
>
|
|
219
|
+
<div id="topInsetContent" className={clsx("relative isolate size-full")}>
|
|
220
|
+
<ServerPortalOutlet id="topInsetContent" />
|
|
222
221
|
</div>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
222
|
+
</div>
|
|
223
|
+
<div
|
|
224
|
+
key="top-left-action"
|
|
225
|
+
id="topLeftActionContainer"
|
|
226
|
+
className="absolute top-0 left-0 isolate flex aspect-1 items-center justify-center"
|
|
227
|
+
style={topInsetStyle}
|
|
228
|
+
>
|
|
229
|
+
<div id="topLeftActionContent" className="isolate flex size-full items-center justify-center">
|
|
230
|
+
<ServerPortalOutlet id="topLeftActionContent" />
|
|
232
231
|
</div>
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
232
|
+
</div>
|
|
233
|
+
<div
|
|
234
|
+
key="bottom-inset"
|
|
235
|
+
id="bottomInsetContainer"
|
|
236
|
+
className={clsx("pointer-events-none fixed inset-x-0 bottom-0 isolate overflow-hidden", {
|
|
237
|
+
"left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
|
|
238
|
+
"w-full": layoutStyle === "web",
|
|
239
|
+
})}
|
|
240
|
+
style={bottomInsetStyle}
|
|
241
|
+
>
|
|
242
|
+
<div id="bottomInsetContent" className="pointer-events-none isolate size-full">
|
|
243
|
+
<ServerPortalOutlet id="bottomInsetContent" />
|
|
245
244
|
</div>
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
245
|
+
</div>
|
|
246
|
+
<div
|
|
247
|
+
key="keyboard-inset"
|
|
248
|
+
id="keyboardInsetContainer"
|
|
249
|
+
className={clsx("pointer-events-none fixed inset-x-0 bottom-0 isolate overflow-hidden", {
|
|
250
|
+
"left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
|
|
251
|
+
"w-full": layoutStyle === "web",
|
|
252
|
+
})}
|
|
253
|
+
style={bottomInsetStyle}
|
|
254
|
+
>
|
|
255
|
+
<div id="keyboardInsetContent" className="pointer-events-none isolate size-full">
|
|
256
|
+
<ServerPortalOutlet id="keyboardInsetContent" />
|
|
258
257
|
</div>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
258
|
+
</div>
|
|
259
|
+
<div
|
|
260
|
+
key="bottom-safe-area"
|
|
261
|
+
id="bottomSafeArea"
|
|
262
|
+
className="fixed inset-x-0 bg-base-100"
|
|
263
|
+
style={bottomSafeAreaStyle}
|
|
264
|
+
/>
|
|
265
265
|
</ClientPathWrapper>
|
|
266
266
|
</div>
|
|
267
267
|
</>
|
package/webkit/useCsrValues.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { useDrag } from "@use-gesture/react";
|
|
|
4
4
|
import {
|
|
5
5
|
type CsrContextType,
|
|
6
6
|
type CsrTransitionStyles,
|
|
7
|
-
|
|
7
|
+
router as clientRouter,
|
|
8
8
|
Device,
|
|
9
|
+
debugFrame,
|
|
9
10
|
defaultPageState,
|
|
10
11
|
getPathInfo,
|
|
11
12
|
type LocationState,
|
|
@@ -17,12 +18,11 @@ import {
|
|
|
17
18
|
type RouteOptions,
|
|
18
19
|
type RouterInstance,
|
|
19
20
|
type RouteState,
|
|
20
|
-
router as clientRouter,
|
|
21
21
|
type TransitionType,
|
|
22
22
|
type UseCsrTransition,
|
|
23
23
|
} from "akanjs/client";
|
|
24
|
-
import { parseAkanI18nEnv, parseBasePaths } from "akanjs/common";
|
|
25
24
|
import { loadCapacitorApp } from "akanjs/client/capacitor";
|
|
25
|
+
import { parseAkanI18nEnv, parseBasePaths } from "akanjs/common";
|
|
26
26
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
27
27
|
import {
|
|
28
28
|
createFrameSnapshot,
|
|
@@ -30,13 +30,13 @@ import {
|
|
|
30
30
|
FRAME_Z_INDEX,
|
|
31
31
|
getFramePlatformProfile,
|
|
32
32
|
getFrameSlotsForSnapshot,
|
|
33
|
-
resolveKeyboardAccessoryHeight,
|
|
34
|
-
resolveKeyboardLayout,
|
|
35
33
|
hasKeyboardStickySlot,
|
|
36
34
|
isPendingFrameReady,
|
|
37
|
-
prepareForFrameTransition,
|
|
38
35
|
PENDING_FRAME_READY_TIMEOUT_MS,
|
|
36
|
+
prepareForFrameTransition,
|
|
39
37
|
resolveFramePageStateMap,
|
|
38
|
+
resolveKeyboardAccessoryHeight,
|
|
39
|
+
resolveKeyboardLayout,
|
|
40
40
|
resolveLocationWithFrameState,
|
|
41
41
|
resolvePathRoutesWithFrameState,
|
|
42
42
|
useFrameRuntimeResync,
|
|
@@ -72,7 +72,12 @@ const getVelocityAwareDuration = (distance: number, velocity: number, fallback:
|
|
|
72
72
|
return clamp(Math.round(Math.abs(distance) / absVelocity), STACK_SETTLE_MIN_DURATION, STACK_SETTLE_MAX_DURATION);
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
const getSyncRouteHref = (location: {
|
|
75
|
+
const getSyncRouteHref = (location: {
|
|
76
|
+
pathname: string;
|
|
77
|
+
search: string;
|
|
78
|
+
hash: string;
|
|
79
|
+
params?: { [key: string]: string };
|
|
80
|
+
}) => {
|
|
76
81
|
const segments = location.pathname.split("/").filter(Boolean);
|
|
77
82
|
const lang = location.params?.lang ?? parseAkanI18nEnv().locales.find((locale) => locale === segments[0]) ?? "";
|
|
78
83
|
const configuredBasePaths = new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
|
|
@@ -91,7 +96,9 @@ const getKeyboardAwarePageHeight = ({ frameLayout }: RouteState) => frameLayout.
|
|
|
91
96
|
const getKeyboardAwareBottomPadding = ({ frameLayout }: RouteState, pageState: PathRoute["pageState"]) =>
|
|
92
97
|
Math.max(
|
|
93
98
|
pageState.bottomSafeArea,
|
|
94
|
-
pageState.bottomInset +
|
|
99
|
+
pageState.bottomInset +
|
|
100
|
+
pageState.bottomSafeArea -
|
|
101
|
+
(frameLayout.keyboard.sticky ? frameLayout.keyboardAccessory.height : 0),
|
|
95
102
|
);
|
|
96
103
|
|
|
97
104
|
const useNoneTrans = (routeState: RouteState): UseCsrTransition => {
|
|
@@ -822,7 +829,14 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
822
829
|
],
|
|
823
830
|
basePageStateMap: basePageStateMap.current,
|
|
824
831
|
}),
|
|
825
|
-
[
|
|
832
|
+
[
|
|
833
|
+
pathRoutes,
|
|
834
|
+
frameSlots,
|
|
835
|
+
pendingFrameSlots,
|
|
836
|
+
pendingLocation?.pathRoute.path,
|
|
837
|
+
location.pathRoute.path,
|
|
838
|
+
prevLocation?.pathRoute.path,
|
|
839
|
+
],
|
|
826
840
|
);
|
|
827
841
|
useEffect(() => {
|
|
828
842
|
if (!transitionPageStateSnapshot) pageStateByPathRef.current = pageStateByPath;
|
|
@@ -991,18 +1005,21 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
991
1005
|
if (kind === "push") window.history.pushState({}, "", href);
|
|
992
1006
|
else window.history.replaceState({}, "", href);
|
|
993
1007
|
debugFrame("navigation.commit", { id: intent.id, kind, to: href });
|
|
994
|
-
window.setTimeout(
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1008
|
+
window.setTimeout(
|
|
1009
|
+
() => {
|
|
1010
|
+
setTransitionPageStateSnapshot(null);
|
|
1011
|
+
setLocationState((current) => ({
|
|
1012
|
+
...current,
|
|
1013
|
+
pendingLocation: null,
|
|
1014
|
+
navigationIntent: null,
|
|
1015
|
+
phase: "idle",
|
|
1016
|
+
}));
|
|
1017
|
+
navigationLocked.current = false;
|
|
1018
|
+
clearPendingSlots();
|
|
1019
|
+
debugFrame("transition.actionEnd", { id: plan.id, phase: "idle" });
|
|
1020
|
+
},
|
|
1021
|
+
Math.max(360, plan.duration),
|
|
1022
|
+
);
|
|
1006
1023
|
};
|
|
1007
1024
|
|
|
1008
1025
|
const waitUntilReady = () => {
|
|
@@ -1170,8 +1187,17 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
1170
1187
|
if (lastBroadcastSyncHref.current === syncHref) return;
|
|
1171
1188
|
lastBroadcastSyncHref.current = syncHref;
|
|
1172
1189
|
if (applyingSyncNavigation.current || globalThis.__AKAN_DEV_SYNC_NAVIGATION_APPLYING__) return;
|
|
1173
|
-
broadcastSyncNavigation(
|
|
1174
|
-
|
|
1190
|
+
broadcastSyncNavigation(
|
|
1191
|
+
history.current.type === "back" ? "back" : history.current.type === "forward" ? "push" : "pop",
|
|
1192
|
+
syncHref,
|
|
1193
|
+
);
|
|
1194
|
+
}, [
|
|
1195
|
+
resolvedLocation.pathRoute.path,
|
|
1196
|
+
resolvedLocation.search,
|
|
1197
|
+
resolvedLocation.hash,
|
|
1198
|
+
broadcastSyncNavigation,
|
|
1199
|
+
history,
|
|
1200
|
+
]);
|
|
1175
1201
|
useEffect(() => {
|
|
1176
1202
|
const resetSyncNavigation = () => {
|
|
1177
1203
|
window.setTimeout(() => {
|
|
@@ -1180,15 +1206,16 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
1180
1206
|
}, 1000);
|
|
1181
1207
|
};
|
|
1182
1208
|
const handleSyncNavigation = (event: Event) => {
|
|
1183
|
-
const { href, kind = "push" } =
|
|
1184
|
-
.detail ?? {};
|
|
1209
|
+
const { href, kind = "push" } =
|
|
1210
|
+
(event as CustomEvent<{ href?: string; kind?: "push" | "replace" | "back" | "pop" }>).detail ?? {};
|
|
1185
1211
|
if (!href) return;
|
|
1186
1212
|
const target = new URL(href, window.location.origin);
|
|
1187
1213
|
const targetHref = `${target.pathname}${target.search}${target.hash}`;
|
|
1188
1214
|
if (targetHref === getSyncRouteHref(getCurrentLocation())) return;
|
|
1189
1215
|
applyingSyncNavigation.current = true;
|
|
1190
1216
|
globalThis.__AKAN_DEV_SYNC_NAVIGATION_APPLYING__ = true;
|
|
1191
|
-
if (kind === "replace" || kind === "back" || kind === "pop")
|
|
1217
|
+
if (kind === "replace" || kind === "back" || kind === "pop")
|
|
1218
|
+
clientRouter.replace(targetHref, { scrollToTop: false });
|
|
1192
1219
|
else clientRouter.push(targetHref, { scrollToTop: false });
|
|
1193
1220
|
resetSyncNavigation();
|
|
1194
1221
|
};
|
|
@@ -1281,7 +1308,11 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
1281
1308
|
const now = Date.now();
|
|
1282
1309
|
const lastHandled = handledDeepLinkRef.current;
|
|
1283
1310
|
const shouldResetStack = resetStack || (!lastHandled && now - mountedAt < 5000);
|
|
1284
|
-
if (
|
|
1311
|
+
if (
|
|
1312
|
+
lastHandled?.href === href &&
|
|
1313
|
+
now - lastHandled.handledAt < 1000 &&
|
|
1314
|
+
(!shouldResetStack || lastHandled.resetStack)
|
|
1315
|
+
)
|
|
1285
1316
|
return;
|
|
1286
1317
|
handledDeepLinkRef.current = { href, handledAt: now, resetStack: shouldResetStack };
|
|
1287
1318
|
debugFrame("native.deepLink", {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import {
|
|
3
|
+
Device,
|
|
3
4
|
debugFrame,
|
|
4
5
|
defaultPageState,
|
|
5
|
-
Device,
|
|
6
6
|
type FrameLayoutState,
|
|
7
7
|
type FramePlatformProfile,
|
|
8
|
-
type FrameSnapshot,
|
|
9
8
|
type FrameSlotRegistration,
|
|
9
|
+
type FrameSnapshot,
|
|
10
10
|
type KeyboardAccessoryFrameState,
|
|
11
11
|
type KeyboardFrameState,
|
|
12
12
|
type Location,
|
|
@@ -119,34 +119,37 @@ export function useFrameSlots() {
|
|
|
119
119
|
});
|
|
120
120
|
const frameSlotId = useRef(0);
|
|
121
121
|
|
|
122
|
-
const registerFrameSlot = useCallback(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
const registerFrameSlot = useCallback(
|
|
123
|
+
(path: string, slot: FrameSlotRegistration, bucket: FrameSlotBucket = "active") => {
|
|
124
|
+
const id = `${slot.source ?? slot.type}-${frameSlotId.current++}`;
|
|
125
|
+
debugFrame("frameSlot.register", { path, id, bucket, slot });
|
|
126
|
+
setFrameSlotsByBucket((prev) => ({
|
|
127
|
+
...prev,
|
|
128
|
+
[bucket]: {
|
|
129
|
+
...prev[bucket],
|
|
130
|
+
[path]: {
|
|
131
|
+
...(prev[bucket][path] ?? {}),
|
|
132
|
+
[id]: slot,
|
|
133
|
+
},
|
|
132
134
|
},
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
135
|
+
}));
|
|
136
|
+
return () => {
|
|
137
|
+
debugFrame("frameSlot.unregister", { path, id, bucket, type: slot.type, source: slot.source, role: slot.role });
|
|
138
|
+
setFrameSlotsByBucket((prev) => {
|
|
139
|
+
const pathSlots = prev[bucket][path];
|
|
140
|
+
if (!pathSlots?.[id]) return prev;
|
|
141
|
+
const nextPathSlots = { ...pathSlots };
|
|
142
|
+
delete nextPathSlots[id];
|
|
143
|
+
const nextBucket = { ...prev[bucket] };
|
|
144
|
+
if (Object.keys(nextPathSlots).length > 0) nextBucket[path] = nextPathSlots;
|
|
145
|
+
else delete nextBucket[path];
|
|
146
|
+
const next = { ...prev, [bucket]: nextBucket };
|
|
147
|
+
return next;
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
[],
|
|
152
|
+
);
|
|
150
153
|
|
|
151
154
|
const promotePendingSlots = useCallback(() => {
|
|
152
155
|
setFrameSlotsByBucket((prev) => ({
|
|
@@ -188,9 +191,7 @@ export function getFramePlatformProfile(): FramePlatformProfile {
|
|
|
188
191
|
(window.matchMedia?.("(display-mode: standalone)")?.matches ||
|
|
189
192
|
(navigator as Navigator & { standalone?: boolean }).standalone === true);
|
|
190
193
|
const hasCssSafeArea =
|
|
191
|
-
typeof document !== "undefined" &&
|
|
192
|
-
typeof CSS !== "undefined" &&
|
|
193
|
-
CSS.supports("top: env(safe-area-inset-top)");
|
|
194
|
+
typeof document !== "undefined" && typeof CSS !== "undefined" && CSS.supports("top: env(safe-area-inset-top)");
|
|
194
195
|
return isStandalone || hasCssSafeArea ? "mobileWeb" : "web";
|
|
195
196
|
}
|
|
196
197
|
|
|
@@ -273,10 +274,12 @@ export function resolveKeyboardFrame({
|
|
|
273
274
|
sticky: boolean;
|
|
274
275
|
freeze?: boolean;
|
|
275
276
|
}): KeyboardFrameState {
|
|
276
|
-
const visualFallbackHeight =
|
|
277
|
+
const visualFallbackHeight =
|
|
278
|
+
keyboardHeight <= 0 && visualViewportKeyboardHeight > 0 ? visualViewportKeyboardHeight : 0;
|
|
277
279
|
const effectiveKeyboardHeight = keyboardHeight > 0 ? keyboardHeight : visualFallbackHeight;
|
|
278
280
|
const source = keyboardHeight > 0 ? "native" : visualFallbackHeight > 0 ? "visualViewport" : "fallback";
|
|
279
|
-
const visualCompensation =
|
|
281
|
+
const visualCompensation =
|
|
282
|
+
platformProfile === "ios" ? 0 : Math.min(visualViewportKeyboardHeight, effectiveKeyboardHeight);
|
|
280
283
|
const offset = sticky && !freeze ? Math.max(0, effectiveKeyboardHeight - visualCompensation) : 0;
|
|
281
284
|
return {
|
|
282
285
|
height: effectiveKeyboardHeight,
|