@mandujs/core 0.54.17 → 0.54.19
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/package.json +3 -1
- package/src/agent/__tests__/context.test.ts +94 -25
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/agent/verify.ts +55 -24
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +478 -9
- package/src/bundler/build.ts +424 -746
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/client/__tests__/props-serialization.test.ts +37 -0
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/props-serialization.ts +233 -0
- package/src/client/runtime-entry.ts +567 -0
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -404
- package/src/diagnose/__tests__/checks.test.ts +132 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +154 -29
- package/src/router/client-entry.ts +111 -313
- package/src/router/fs-routes.test.ts +443 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +176 -57
- package/src/router/fs-types.ts +11 -2
- package/src/router/route-source-analyzer.ts +521 -0
- package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
- package/src/runtime/__tests__/page-render-response.test.ts +218 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +24 -1
- package/src/runtime/server.ts +14 -0
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/bundler/dev.ts
CHANGED
|
@@ -21,12 +21,13 @@ import {
|
|
|
21
21
|
scanFileImports,
|
|
22
22
|
DEFAULT_MAX_CLOSURE_DEPTH,
|
|
23
23
|
} from "./reverse-import-graph";
|
|
24
|
-
import path from "path";
|
|
25
|
-
import fs from "fs";
|
|
26
|
-
import { LRUCache } from "../utils/lru-cache";
|
|
27
|
-
import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
import path from "path";
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import { LRUCache } from "../utils/lru-cache";
|
|
27
|
+
import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
|
|
28
|
+
export { generateFastRefreshPreamble } from "./fast-refresh-preamble";
|
|
29
|
+
|
|
30
|
+
/**
|
|
30
31
|
* #184: 공통 디렉토리 변경 시 사용하는 sentinel.
|
|
31
32
|
* `onSSRChange`에 특정 파일 경로 대신 이 상수를 전달하면 "전체 SSR 레지스트리 invalidate" 의미.
|
|
32
33
|
*/
|
|
@@ -135,12 +136,14 @@ export interface RebuildResult {
|
|
|
135
136
|
error?: string;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
export interface DevBundler {
|
|
139
|
-
/** 초기 빌드 결과 */
|
|
140
|
-
initialBuild: BundleResult;
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
export interface DevBundler {
|
|
140
|
+
/** 초기 빌드 결과 */
|
|
141
|
+
initialBuild: BundleResult;
|
|
142
|
+
/** Reverse import graph initial seed completion for deterministic tests. */
|
|
143
|
+
reverseGraphReady: Promise<void>;
|
|
144
|
+
/** 파일 감시 중지 */
|
|
145
|
+
close: () => void;
|
|
146
|
+
}
|
|
144
147
|
|
|
145
148
|
/**
|
|
146
149
|
* #180: 파일 경로 비교를 위한 정규화.
|
|
@@ -647,7 +650,8 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
647
650
|
// surface typed-only stubs that have no source yet); misses simply
|
|
648
651
|
// keep the legacy "silent drop" behavior so no project is made
|
|
649
652
|
// worse by turning this on.
|
|
650
|
-
const reverseGraph = new ReverseImportGraph();
|
|
653
|
+
const reverseGraph = new ReverseImportGraph();
|
|
654
|
+
let reverseGraphSeedPromise: Promise<void> | null = null;
|
|
651
655
|
|
|
652
656
|
/**
|
|
653
657
|
* Re-scan a single file's imports and update its row in the
|
|
@@ -1239,11 +1243,15 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1239
1243
|
* stay silent. Dispatch is idempotent — each root is touched at
|
|
1240
1244
|
* most once per change via the `dispatched` set.
|
|
1241
1245
|
*/
|
|
1242
|
-
const dispatchByTransitiveImporters = async (
|
|
1243
|
-
changedFile: string,
|
|
1244
|
-
): Promise<number> => {
|
|
1245
|
-
const absChanged = path.resolve(rootDir, changedFile);
|
|
1246
|
-
|
|
1246
|
+
const dispatchByTransitiveImporters = async (
|
|
1247
|
+
changedFile: string,
|
|
1248
|
+
): Promise<number> => {
|
|
1249
|
+
const absChanged = path.resolve(rootDir, changedFile);
|
|
1250
|
+
if (reverseGraphSeedPromise) {
|
|
1251
|
+
await reverseGraphSeedPromise;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Refresh the changed file's OWN imports first. A leaf edit can
|
|
1247
1255
|
// legitimately add or remove imports (e.g. switching a barrel
|
|
1248
1256
|
// from `./en` to `./ko`); the reverse graph must track that so
|
|
1249
1257
|
// the NEXT unknown-file change uses the correct set.
|
|
@@ -1655,16 +1663,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1655
1663
|
// and the first user edit can't race the seed (first event goes
|
|
1656
1664
|
// through the 100 ms debounce, by which point the scan has finished
|
|
1657
1665
|
// for any realistic project size).
|
|
1658
|
-
seedReverseGraph().catch((err) => {
|
|
1659
|
-
console.warn(
|
|
1660
|
-
"[Mandu HMR] reverse import-graph seed skipped:",
|
|
1661
|
-
err instanceof Error ? err.message : String(err),
|
|
1662
|
-
);
|
|
1663
|
-
});
|
|
1664
|
-
|
|
1665
|
-
return {
|
|
1666
|
-
initialBuild,
|
|
1667
|
-
|
|
1666
|
+
reverseGraphSeedPromise = seedReverseGraph().catch((err) => {
|
|
1667
|
+
console.warn(
|
|
1668
|
+
"[Mandu HMR] reverse import-graph seed skipped:",
|
|
1669
|
+
err instanceof Error ? err.message : String(err),
|
|
1670
|
+
);
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
return {
|
|
1674
|
+
initialBuild,
|
|
1675
|
+
reverseGraphReady: reverseGraphSeedPromise,
|
|
1676
|
+
close: () => {
|
|
1668
1677
|
// B6: clear all per-file timers to release event-loop refs.
|
|
1669
1678
|
// Phase 17 — `LRUCache.clear()` fires the registered `onEvict`
|
|
1670
1679
|
// (`clearTimeout(timer)`) for every entry before dropping them,
|
|
@@ -2208,90 +2217,8 @@ export function createHMRServer(
|
|
|
2208
2217
|
};
|
|
2209
2218
|
}
|
|
2210
2219
|
|
|
2211
|
-
/**
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2214
|
-
* Emitted by the SSR renderer in dev mode, **before** any island JS
|
|
2215
|
-
* evaluates. Two concerns, one <script>:
|
|
2216
|
-
*
|
|
2217
|
-
* 1. Install inert stubs for `$RefreshReg$` / `$RefreshSig$` on
|
|
2218
|
-
* `window`. Bun's `reactFastRefresh: true` transform inserts calls
|
|
2219
|
-
* to these at the top of every transformed module; if they are
|
|
2220
|
-
* undefined when the module body runs, we get a runtime error and
|
|
2221
|
-
* the island never hydrates. Vite's preamble does the same inline
|
|
2222
|
-
* stub install for the same reason.
|
|
2223
|
-
*
|
|
2224
|
-
* 2. Fire a dynamic `import()` of the bundled glue (`_fast-refresh-
|
|
2225
|
-
* runtime.js`), which in turn `await`s the real `react-refresh/
|
|
2226
|
-
* runtime`, installs `window.__MANDU_HMR__`, and upgrades the
|
|
2227
|
-
* stubs to live wrappers that forward to the refresh runtime. The
|
|
2228
|
-
* race between "module evaluates and calls `$RefreshReg$`" and
|
|
2229
|
-
* "glue has upgraded the stubs" is benign — registrations that
|
|
2230
|
-
* land on the stub are simply no-ops, which at worst means the
|
|
2231
|
-
* very first mount isn't tracked. Subsequent hot swaps land on
|
|
2232
|
-
* the live wrappers and work normally.
|
|
2233
|
-
*
|
|
2234
|
-
* The emitted script is **inline** (no `type="module"`, no external
|
|
2235
|
-
* src). This is deliberate: the stubs must exist before *any* module
|
|
2236
|
-
* script runs, and inline execution blocks the parser. The dynamic
|
|
2237
|
-
* import inside the inline script is non-blocking so we don't stall
|
|
2238
|
-
* First Contentful Paint.
|
|
2239
|
-
*
|
|
2240
|
-
* CSP note: the inline <script> uses no `eval` or `new Function`; it
|
|
2241
|
-
* only calls `Object.assign`, defines functions, and initiates an
|
|
2242
|
-
* `import()`. All of these are permitted under `script-src 'self'
|
|
2243
|
-
* 'unsafe-inline'` which is Mandu's default dev CSP (production CSP
|
|
2244
|
-
* forbids `unsafe-inline`, but this preamble is dev-only).
|
|
2245
|
-
*
|
|
2246
|
-
* `glueUrl` and `runtimeUrl` come from the build manifest's
|
|
2247
|
-
* `shared.fastRefresh` block (populated only in dev). Both must be
|
|
2248
|
-
* absolute URLs served from the same origin as the HTML, which our
|
|
2249
|
-
* bundler always guarantees (`/.mandu/client/...`).
|
|
2250
|
-
*/
|
|
2251
|
-
export function generateFastRefreshPreamble(
|
|
2252
|
-
glueUrl: string,
|
|
2253
|
-
runtimeUrl: string,
|
|
2254
|
-
): string {
|
|
2255
|
-
// Both URLs must be non-empty. If either is missing (e.g. vendor
|
|
2256
|
-
// shim build failed), the caller (ssr.ts) should skip this preamble
|
|
2257
|
-
// entirely — defensive guard here keeps the output valid regardless.
|
|
2258
|
-
if (!glueUrl || !runtimeUrl) {
|
|
2259
|
-
return `<script>/* Mandu Fast Refresh: missing runtime assets, preamble skipped */</script>`;
|
|
2260
|
-
}
|
|
2261
|
-
// JSON.stringify escapes the URLs safely for inline `<script>`:
|
|
2262
|
-
// - quotes produce a valid JS string literal
|
|
2263
|
-
// - forward-slashes / backslashes are handled
|
|
2264
|
-
// We also `split('</')` to avoid a stray `</script>` sequence in the
|
|
2265
|
-
// URL bytes breaking the enclosing tag. This is the same defense
|
|
2266
|
-
// Vite uses in its own preamble emitter.
|
|
2267
|
-
const glueLit = JSON.stringify(glueUrl).split("</").join('<"+"/');
|
|
2268
|
-
const runtimeLit = JSON.stringify(runtimeUrl).split("</").join('<"+"/');
|
|
2269
|
-
return `<script>
|
|
2270
|
-
// Phase 7.1 B-3 React Fast Refresh preamble (Mandu dev-only)
|
|
2271
|
-
(function () {
|
|
2272
|
-
if (typeof window === "undefined") return;
|
|
2273
|
-
// Install inert stubs so transformed modules that run BEFORE the
|
|
2274
|
-
// async runtime upgrade don't hit ReferenceError on $RefreshReg$.
|
|
2275
|
-
if (!window.$RefreshReg$) window.$RefreshReg$ = function () {};
|
|
2276
|
-
if (!window.$RefreshSig$) window.$RefreshSig$ = function () { return function (t) { return t; }; };
|
|
2277
|
-
// Async-load the glue; failures are reported but never throw out of
|
|
2278
|
-
// the preamble — a missing runtime degrades to full-reload HMR.
|
|
2279
|
-
import(${glueLit})
|
|
2280
|
-
.then(function (mod) {
|
|
2281
|
-
var runtimeImport = function () { return import(${runtimeLit}); };
|
|
2282
|
-
if (mod && typeof mod.installGlobal === "function") {
|
|
2283
|
-
return mod.installGlobal({ runtimeImport: runtimeImport });
|
|
2284
|
-
}
|
|
2285
|
-
})
|
|
2286
|
-
.catch(function (err) {
|
|
2287
|
-
console.error("[Mandu Fast Refresh] preamble failed:", err);
|
|
2288
|
-
});
|
|
2289
|
-
})();
|
|
2290
|
-
</script>`;
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
/**
|
|
2294
|
-
* HMR 클라이언트 스크립트 생성
|
|
2220
|
+
/**
|
|
2221
|
+
* HMR 클라이언트 스크립트 생성
|
|
2295
2222
|
* 브라우저에서 실행되어 HMR 서버와 연결.
|
|
2296
2223
|
*
|
|
2297
2224
|
* Phase 7.0 R1 Agent C additions:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small runtime-safe Fast Refresh preamble helper.
|
|
3
|
+
*
|
|
4
|
+
* Keep this separate from `bundler/dev.ts`: SSR runtimes import the helper
|
|
5
|
+
* while edge bundles must not pull the dev bundler, build graph, or TypeScript
|
|
6
|
+
* compiler into production worker output.
|
|
7
|
+
*/
|
|
8
|
+
export function generateFastRefreshPreamble(
|
|
9
|
+
glueUrl: string,
|
|
10
|
+
runtimeUrl: string,
|
|
11
|
+
): string {
|
|
12
|
+
// Both URLs must be non-empty. If either is missing (e.g. vendor
|
|
13
|
+
// shim build failed), the caller should skip this preamble entirely.
|
|
14
|
+
if (!glueUrl || !runtimeUrl) {
|
|
15
|
+
return `<script>/* Mandu Fast Refresh: missing runtime assets, preamble skipped */</script>`;
|
|
16
|
+
}
|
|
17
|
+
// JSON.stringify escapes the URLs safely for inline `<script>`:
|
|
18
|
+
// - quotes produce a valid JS string literal
|
|
19
|
+
// - forward-slashes / backslashes are handled
|
|
20
|
+
// We also `split('</')` to avoid a stray `</script>` sequence in the
|
|
21
|
+
// URL bytes breaking the enclosing tag. This is the same defense
|
|
22
|
+
// Vite uses in its own preamble emitter.
|
|
23
|
+
const glueLit = JSON.stringify(glueUrl).split("</").join('<"+"/');
|
|
24
|
+
const runtimeLit = JSON.stringify(runtimeUrl).split("</").join('<"+"/');
|
|
25
|
+
return `<script>
|
|
26
|
+
// Phase 7.1 B-3 React Fast Refresh preamble (Mandu dev-only)
|
|
27
|
+
(function () {
|
|
28
|
+
if (typeof window === "undefined") return;
|
|
29
|
+
// Install inert stubs so transformed modules that run BEFORE the
|
|
30
|
+
// async runtime upgrade don't hit ReferenceError on $RefreshReg$.
|
|
31
|
+
if (!window.$RefreshReg$) window.$RefreshReg$ = function () {};
|
|
32
|
+
if (!window.$RefreshSig$) window.$RefreshSig$ = function () { return function (t) { return t; }; };
|
|
33
|
+
// Async-load the glue; failures are reported but never throw out of
|
|
34
|
+
// the preamble — a missing runtime degrades to full-reload HMR.
|
|
35
|
+
import(${glueLit})
|
|
36
|
+
.then(function (mod) {
|
|
37
|
+
var runtimeImport = function () { return import(${runtimeLit}); };
|
|
38
|
+
if (mod && typeof mod.installGlobal === "function") {
|
|
39
|
+
return mod.installGlobal({ runtimeImport: runtimeImport });
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
.catch(function (err) {
|
|
43
|
+
console.error("[Mandu Fast Refresh] preamble failed:", err);
|
|
44
|
+
});
|
|
45
|
+
})();
|
|
46
|
+
</script>`;
|
|
47
|
+
}
|
package/src/bundler/index.ts
CHANGED
|
@@ -173,6 +173,15 @@ const PartialEntrySchema = z.object({
|
|
|
173
173
|
priority: PrioritySchema,
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
const BoundaryEntrySchema = z.object({
|
|
177
|
+
route: z.string().min(1),
|
|
178
|
+
js: safeManduUrl("boundaries[].js"),
|
|
179
|
+
module: z.string().min(1),
|
|
180
|
+
exportName: z.string().min(1),
|
|
181
|
+
priority: PrioritySchema,
|
|
182
|
+
hydrate: z.string().min(1),
|
|
183
|
+
});
|
|
184
|
+
|
|
176
185
|
const FastRefreshSchema = z.object({
|
|
177
186
|
runtime: safeManduUrl("shared.fastRefresh.runtime"),
|
|
178
187
|
glue: safeManduUrl("shared.fastRefresh.glue"),
|
|
@@ -229,6 +238,7 @@ export const BundleManifestSchema = z
|
|
|
229
238
|
bundles: z.record(z.string(), BundleEntrySchema),
|
|
230
239
|
islands: z.record(z.string(), IslandEntrySchema).optional(),
|
|
231
240
|
partials: z.record(z.string(), PartialEntrySchema).optional(),
|
|
241
|
+
boundaries: z.record(z.string(), BoundaryEntrySchema).optional(),
|
|
232
242
|
shared: SharedSchema,
|
|
233
243
|
importMap: ImportMapSchema.optional(),
|
|
234
244
|
})
|
package/src/bundler/types.ts
CHANGED
|
@@ -77,8 +77,26 @@ export interface BundleManifest {
|
|
|
77
77
|
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
78
78
|
}
|
|
79
79
|
>;
|
|
80
|
-
/**
|
|
81
|
-
|
|
80
|
+
/** Compiler-owned client boundary bundles keyed by boundary id. */
|
|
81
|
+
boundaries?: Record<
|
|
82
|
+
string,
|
|
83
|
+
{
|
|
84
|
+
/** Owning route id */
|
|
85
|
+
route: string;
|
|
86
|
+
/** JavaScript bundle path */
|
|
87
|
+
js: string;
|
|
88
|
+
/** Source client module */
|
|
89
|
+
module: string;
|
|
90
|
+
/** Client export name */
|
|
91
|
+
exportName: string;
|
|
92
|
+
/** Hydration priority */
|
|
93
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
94
|
+
/** Hydration mode emitted into data-hydrate */
|
|
95
|
+
hydrate: string;
|
|
96
|
+
}
|
|
97
|
+
>;
|
|
98
|
+
/** 공유 청크 */
|
|
99
|
+
shared: {
|
|
82
100
|
/** Hydration 런타임 */
|
|
83
101
|
runtime: string;
|
|
84
102
|
/** React 번들 경로 */
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { deserializeProps, serializeProps } from "../props-serialization";
|
|
3
|
+
|
|
4
|
+
describe("props serialization", () => {
|
|
5
|
+
it("roundtrips complex browser hydration props through the shared deserializer", () => {
|
|
6
|
+
const input = {
|
|
7
|
+
date: new Date("2026-05-23T00:00:00.000Z"),
|
|
8
|
+
map: new Map<unknown, unknown>([
|
|
9
|
+
["count", 3],
|
|
10
|
+
["nested", { ok: true }],
|
|
11
|
+
]),
|
|
12
|
+
set: new Set<unknown>(["a", "b"]),
|
|
13
|
+
url: new URL("https://mandu.dev/docs?phase=4"),
|
|
14
|
+
missing: undefined,
|
|
15
|
+
nested: {
|
|
16
|
+
list: [1, undefined, { value: "x" }],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const output = deserializeProps(serializeProps(input));
|
|
21
|
+
|
|
22
|
+
expect(output.date).toBeInstanceOf(Date);
|
|
23
|
+
expect((output.date as Date).toISOString()).toBe("2026-05-23T00:00:00.000Z");
|
|
24
|
+
expect(output.map).toBeInstanceOf(Map);
|
|
25
|
+
expect((output.map as Map<unknown, unknown>).get("count")).toBe(3);
|
|
26
|
+
expect((output.map as Map<unknown, unknown>).get("nested")).toEqual({ ok: true });
|
|
27
|
+
expect(output.set).toBeInstanceOf(Set);
|
|
28
|
+
expect(Array.from(output.set as Set<unknown>)).toEqual(["a", "b"]);
|
|
29
|
+
expect(output.url).toBeInstanceOf(URL);
|
|
30
|
+
expect((output.url as URL).href).toBe("https://mandu.dev/docs?phase=4");
|
|
31
|
+
expect(Object.prototype.hasOwnProperty.call(output, "missing")).toBe(true);
|
|
32
|
+
expect(output.missing).toBeUndefined();
|
|
33
|
+
expect(output.nested).toEqual({
|
|
34
|
+
list: [1, undefined, { value: "x" }],
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/client/hydrate.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* 2. Unit tests can import it under happy-dom/JSDOM without triggering
|
|
17
17
|
* global mutation (no `document.querySelectorAll` on module eval).
|
|
18
18
|
*
|
|
19
|
-
* The
|
|
20
|
-
* delegates strategy selection to `scheduleHydration()` here — SSR emits the
|
|
19
|
+
* The bundled runtime entry (`client/runtime-entry.ts`)
|
|
20
|
+
* delegates strategy selection to `scheduleHydration()` here — SSR emits the
|
|
21
21
|
* `data-hydrate` attribute, the runtime reads it and dispatches.
|
|
22
22
|
*
|
|
23
23
|
* Design contract:
|
package/src/client/index.ts
CHANGED
|
@@ -168,7 +168,7 @@ import { Link, NavLink } from "./Link";
|
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* Mandu Client namespace
|
|
171
|
-
* v0.8.0: Hydration is handled automatically (
|
|
171
|
+
* v0.8.0: Hydration is handled automatically (runtime-entry)
|
|
172
172
|
* Note: Use `ManduClient` to avoid conflict with other Mandu exports
|
|
173
173
|
*/
|
|
174
174
|
export const ManduClient = {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe Mandu props serialization.
|
|
3
|
+
*
|
|
4
|
+
* This module intentionally has no DOM, Bun, or Node imports. Runtime entry
|
|
5
|
+
* code may read from the document, but serialization semantics live here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TYPE_MARKERS = {
|
|
9
|
+
UNDEFINED: "\x00_",
|
|
10
|
+
DATE: "\x00D",
|
|
11
|
+
URL: "\x00U",
|
|
12
|
+
REGEXP: "\x00R",
|
|
13
|
+
MAP: "\x00M",
|
|
14
|
+
SET: "\x00S",
|
|
15
|
+
REF: "\x00$",
|
|
16
|
+
BIGINT: "\x00B",
|
|
17
|
+
SYMBOL: "\x00Y",
|
|
18
|
+
ERROR: "\x00E",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
interface SerializeContext {
|
|
22
|
+
seen: Map<object, number>;
|
|
23
|
+
refs: object[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DeserializeContext {
|
|
27
|
+
refs: unknown[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function serializeProps(props: Record<string, unknown>): string {
|
|
31
|
+
const ctx: SerializeContext = { seen: new Map(), refs: [] };
|
|
32
|
+
return JSON.stringify(serialize(props, ctx));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function serialize(value: unknown, ctx: SerializeContext): unknown {
|
|
36
|
+
if (value === null) return null;
|
|
37
|
+
if (value === undefined) return TYPE_MARKERS.UNDEFINED;
|
|
38
|
+
|
|
39
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
return value.startsWith("\x00") ? "\x00\x00" + value : value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof value === "bigint") {
|
|
48
|
+
return TYPE_MARKERS.BIGINT + value.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof value === "symbol") {
|
|
52
|
+
return TYPE_MARKERS.SYMBOL + (value.description ?? "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof value === "function") {
|
|
56
|
+
console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (typeof value === "object") {
|
|
61
|
+
const existing = ctx.seen.get(value);
|
|
62
|
+
if (existing !== undefined) {
|
|
63
|
+
return TYPE_MARKERS.REF + existing;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const idx = ctx.refs.length;
|
|
67
|
+
ctx.seen.set(value, idx);
|
|
68
|
+
ctx.refs.push(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (value instanceof Date) {
|
|
72
|
+
return TYPE_MARKERS.DATE + value.toISOString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (value instanceof URL) {
|
|
76
|
+
return TYPE_MARKERS.URL + value.href;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (value instanceof RegExp) {
|
|
80
|
+
return TYPE_MARKERS.REGEXP + value.toString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (value instanceof Error) {
|
|
84
|
+
return [
|
|
85
|
+
TYPE_MARKERS.ERROR,
|
|
86
|
+
value.name,
|
|
87
|
+
value.message,
|
|
88
|
+
value.stack ?? "",
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (value instanceof Map) {
|
|
93
|
+
const entries: [unknown, unknown][] = [];
|
|
94
|
+
for (const [key, nested] of value.entries()) {
|
|
95
|
+
entries.push([serialize(key, ctx), serialize(nested, ctx)]);
|
|
96
|
+
}
|
|
97
|
+
return [TYPE_MARKERS.MAP, ...entries];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (value instanceof Set) {
|
|
101
|
+
const items: unknown[] = [];
|
|
102
|
+
for (const item of value) {
|
|
103
|
+
items.push(serialize(item, ctx));
|
|
104
|
+
}
|
|
105
|
+
return [TYPE_MARKERS.SET, ...items];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
return value.map((item) => serialize(item, ctx));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result: Record<string, unknown> = {};
|
|
113
|
+
for (const [key, nested] of Object.entries(value as object)) {
|
|
114
|
+
const serialized = serialize(nested, ctx);
|
|
115
|
+
if (serialized !== undefined) {
|
|
116
|
+
result[key] = serialized;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function deserializeProps(json: string): Record<string, unknown> {
|
|
123
|
+
const ctx: DeserializeContext = { refs: [] };
|
|
124
|
+
const parsed = JSON.parse(json);
|
|
125
|
+
return deserialize(parsed, ctx) as Record<string, unknown>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function deserialize(value: unknown, ctx: DeserializeContext): unknown {
|
|
129
|
+
if (value === null) return null;
|
|
130
|
+
|
|
131
|
+
if (typeof value === "string") {
|
|
132
|
+
if (value === TYPE_MARKERS.UNDEFINED) return undefined;
|
|
133
|
+
if (value.startsWith("\x00\x00")) return value.slice(2);
|
|
134
|
+
if (value.startsWith(TYPE_MARKERS.DATE)) return new Date(value.slice(2));
|
|
135
|
+
if (value.startsWith(TYPE_MARKERS.URL)) return new URL(value.slice(2));
|
|
136
|
+
if (value.startsWith(TYPE_MARKERS.REGEXP)) {
|
|
137
|
+
const str = value.slice(2);
|
|
138
|
+
const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
|
|
139
|
+
return match ? new RegExp(match[1], match[2]) : str;
|
|
140
|
+
}
|
|
141
|
+
if (value.startsWith(TYPE_MARKERS.BIGINT)) return BigInt(value.slice(2));
|
|
142
|
+
if (value.startsWith(TYPE_MARKERS.SYMBOL)) return Symbol(value.slice(2));
|
|
143
|
+
if (value.startsWith(TYPE_MARKERS.REF)) {
|
|
144
|
+
return ctx.refs[parseInt(value.slice(2), 10)];
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
const marker = value[0];
|
|
155
|
+
|
|
156
|
+
if (marker === TYPE_MARKERS.ERROR) {
|
|
157
|
+
const [, name, message, stack] = value as [string, string, string, string];
|
|
158
|
+
const error = new Error(message);
|
|
159
|
+
error.name = name;
|
|
160
|
+
if (stack) error.stack = stack;
|
|
161
|
+
ctx.refs.push(error);
|
|
162
|
+
return error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (marker === TYPE_MARKERS.MAP) {
|
|
166
|
+
const map = new Map();
|
|
167
|
+
ctx.refs.push(map);
|
|
168
|
+
for (let i = 1; i < value.length; i++) {
|
|
169
|
+
const [key, nested] = value[i] as [unknown, unknown];
|
|
170
|
+
map.set(deserialize(key, ctx), deserialize(nested, ctx));
|
|
171
|
+
}
|
|
172
|
+
return map;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (marker === TYPE_MARKERS.SET) {
|
|
176
|
+
const set = new Set();
|
|
177
|
+
ctx.refs.push(set);
|
|
178
|
+
for (let i = 1; i < value.length; i++) {
|
|
179
|
+
set.add(deserialize(value[i], ctx));
|
|
180
|
+
}
|
|
181
|
+
return set;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const arr: unknown[] = [];
|
|
185
|
+
ctx.refs.push(arr);
|
|
186
|
+
for (const item of value) {
|
|
187
|
+
arr.push(deserialize(item, ctx));
|
|
188
|
+
}
|
|
189
|
+
return arr;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (typeof value === "object") {
|
|
193
|
+
const obj: Record<string, unknown> = {};
|
|
194
|
+
ctx.refs.push(obj);
|
|
195
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
196
|
+
obj[key] = deserialize(nested, ctx);
|
|
197
|
+
}
|
|
198
|
+
return obj;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function isSerializable(value: unknown): boolean {
|
|
205
|
+
if (value === null || value === undefined) return true;
|
|
206
|
+
|
|
207
|
+
const type = typeof value;
|
|
208
|
+
if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (type === "function" || type === "symbol") {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (value instanceof Map || value instanceof Set) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (Array.isArray(value)) {
|
|
225
|
+
return value.every(isSerializable);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (type === "object") {
|
|
229
|
+
return Object.values(value as object).every(isSerializable);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return false;
|
|
233
|
+
}
|