@mandujs/core 0.12.2 → 0.13.0
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/README.ko.md +304 -304
- package/README.md +653 -653
- package/package.json +1 -1
- package/src/brain/architecture/analyzer.ts +28 -26
- package/src/brain/doctor/analyzer.ts +1 -1
- package/src/bundler/build.ts +91 -91
- package/src/bundler/css.ts +302 -302
- package/src/bundler/dev.ts +0 -1
- package/src/change/history.ts +3 -3
- package/src/change/snapshot.ts +10 -9
- package/src/change/transaction.ts +2 -2
- package/src/client/Link.tsx +227 -227
- package/src/client/globals.ts +44 -44
- package/src/client/hooks.ts +267 -267
- package/src/client/index.ts +5 -5
- package/src/client/island.ts +8 -8
- package/src/client/router.ts +435 -435
- package/src/client/runtime.ts +23 -23
- package/src/client/serialize.ts +404 -404
- package/src/client/window-state.ts +101 -101
- package/src/config/mandu.ts +94 -96
- package/src/config/validate.ts +213 -215
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/content-layer.ts +314 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/data-store.ts +245 -245
- package/src/content/digest.ts +133 -133
- package/src/content/index.ts +164 -164
- package/src/content/loader-context.ts +172 -172
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +169 -169
- package/src/content/loaders/glob.ts +252 -252
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/client.ts +16 -16
- package/src/contract/define.ts +459 -459
- package/src/contract/handler.ts +10 -10
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +404 -404
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/registry.ts +568 -568
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/contract/validator.ts +32 -32
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +465 -465
- package/src/devtools/client/catchers/error-catcher.ts +327 -327
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/catchers/network-proxy.ts +363 -363
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/kitchen-root.tsx +362 -362
- package/src/devtools/client/components/mandu-character.tsx +241 -241
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
- package/src/devtools/client/components/panel/index.ts +32 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
- package/src/devtools/client/components/panel/network-panel.tsx +292 -292
- package/src/devtools/client/components/panel/panel-container.tsx +259 -259
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/client/state-manager.ts +478 -478
- package/src/devtools/design-tokens.ts +263 -263
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +266 -266
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +444 -444
- package/src/devtools/types.ts +319 -319
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +222 -222
- package/src/devtools/worker/worker-manager.ts +409 -409
- package/src/error/classifier.ts +2 -2
- package/src/error/domains.ts +265 -265
- package/src/error/formatter.ts +32 -32
- package/src/error/result.ts +46 -46
- package/src/error/stack-analyzer.ts +5 -0
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +569 -569
- package/src/filling/deps.ts +238 -238
- package/src/generator/contract-glue.ts +2 -1
- package/src/generator/generate.ts +12 -10
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +80 -79
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +128 -128
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/cqrs.test.ts +35 -14
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/presets/index.ts +291 -291
- package/src/guard/reporter.ts +445 -445
- package/src/guard/rules.ts +12 -12
- package/src/guard/statistics.ts +578 -578
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/types.ts +348 -348
- package/src/guard/validator.ts +834 -834
- package/src/guard/watcher.ts +404 -404
- package/src/index.ts +1 -0
- package/src/intent/index.ts +310 -310
- package/src/island/index.ts +304 -304
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/paths.test.ts +47 -0
- package/src/paths.ts +47 -0
- package/src/plugins/index.ts +38 -38
- package/src/plugins/registry.ts +377 -377
- package/src/plugins/types.ts +363 -363
- package/src/report/build.ts +1 -1
- package/src/report/index.ts +1 -1
- package/src/router/fs-patterns.ts +387 -387
- package/src/router/fs-routes.ts +344 -401
- package/src/router/fs-scanner.ts +497 -497
- package/src/router/fs-types.ts +270 -278
- package/src/router/index.ts +81 -81
- package/src/runtime/boundary.tsx +232 -232
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/logger.ts +677 -677
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/router.ts +105 -105
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +24 -24
- package/src/runtime/session-key.ts +328 -328
- package/src/runtime/ssr.ts +367 -367
- package/src/runtime/streaming-ssr.ts +1245 -1245
- package/src/runtime/trace.ts +144 -144
- package/src/seo/index.ts +214 -214
- package/src/seo/integration/ssr.ts +307 -307
- package/src/seo/render/basic.ts +427 -427
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +191 -191
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +126 -126
- package/src/seo/resolve/index.ts +353 -353
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +97 -97
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +575 -575
- package/src/slot/validator.ts +39 -39
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/utils/bun.ts +8 -8
- package/src/utils/lru-cache.ts +75 -75
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/rules.ts +5 -5
package/src/client/serialize.ts
CHANGED
|
@@ -1,404 +1,404 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu Props Serialization 📦
|
|
3
|
-
* Fresh 스타일 고급 직렬화/역직렬화
|
|
4
|
-
*
|
|
5
|
-
* @see https://fresh.deno.dev/docs/concepts/islands
|
|
6
|
-
*
|
|
7
|
-
* 지원 타입:
|
|
8
|
-
* - 원시형: null, boolean, number, string, bigint, undefined
|
|
9
|
-
* - 특수 객체: Date, URL, RegExp, Map, Set
|
|
10
|
-
* - 순환 참조
|
|
11
|
-
* - 중첩 객체/배열
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// ============================================
|
|
15
|
-
// 타입 마커
|
|
16
|
-
// ============================================
|
|
17
|
-
|
|
18
|
-
const TYPE_MARKERS = {
|
|
19
|
-
/** undefined */
|
|
20
|
-
UNDEFINED: "\x00_",
|
|
21
|
-
/** Date */
|
|
22
|
-
DATE: "\x00D",
|
|
23
|
-
/** URL */
|
|
24
|
-
URL: "\x00U",
|
|
25
|
-
/** RegExp */
|
|
26
|
-
REGEXP: "\x00R",
|
|
27
|
-
/** Map */
|
|
28
|
-
MAP: "\x00M",
|
|
29
|
-
/** Set */
|
|
30
|
-
SET: "\x00S",
|
|
31
|
-
/** 순환 참조 */
|
|
32
|
-
REF: "\x00$",
|
|
33
|
-
/** BigInt */
|
|
34
|
-
BIGINT: "\x00B",
|
|
35
|
-
/** Symbol (제한적 지원) */
|
|
36
|
-
SYMBOL: "\x00Y",
|
|
37
|
-
/** Error */
|
|
38
|
-
ERROR: "\x00E",
|
|
39
|
-
} as const;
|
|
40
|
-
|
|
41
|
-
// ============================================
|
|
42
|
-
// 직렬화
|
|
43
|
-
// ============================================
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 직렬화 컨텍스트 (순환 참조 추적)
|
|
47
|
-
*/
|
|
48
|
-
interface SerializeContext {
|
|
49
|
-
/** 이미 본 객체 → 인덱스 */
|
|
50
|
-
seen: Map<object, number>;
|
|
51
|
-
/** 참조 테이블 */
|
|
52
|
-
refs: object[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Props 직렬화
|
|
57
|
-
*
|
|
58
|
-
* @example
|
|
59
|
-
* ```typescript
|
|
60
|
-
* const props = {
|
|
61
|
-
* date: new Date(),
|
|
62
|
-
* url: new URL('https://example.com'),
|
|
63
|
-
* items: new Set([1, 2, 3]),
|
|
64
|
-
* cache: new Map([['key', 'value']]),
|
|
65
|
-
* };
|
|
66
|
-
*
|
|
67
|
-
* const json = serializeProps(props);
|
|
68
|
-
* // 클라이언트로 전송
|
|
69
|
-
* ```
|
|
70
|
-
*/
|
|
71
|
-
export function serializeProps(props: Record<string, unknown>): string {
|
|
72
|
-
const ctx: SerializeContext = { seen: new Map(), refs: [] };
|
|
73
|
-
return JSON.stringify(serialize(props, ctx));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 값 직렬화 (재귀)
|
|
78
|
-
*/
|
|
79
|
-
function serialize(value: unknown, ctx: SerializeContext): unknown {
|
|
80
|
-
// null
|
|
81
|
-
if (value === null) return null;
|
|
82
|
-
|
|
83
|
-
// undefined
|
|
84
|
-
if (value === undefined) return TYPE_MARKERS.UNDEFINED;
|
|
85
|
-
|
|
86
|
-
// 원시형
|
|
87
|
-
if (typeof value === "boolean" || typeof value === "number") {
|
|
88
|
-
return value;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (typeof value === "string") {
|
|
92
|
-
// 타입 마커와 충돌 방지 (첫 문자가 \x00인 경우)
|
|
93
|
-
if (value.startsWith("\x00")) {
|
|
94
|
-
return "\x00\x00" + value;
|
|
95
|
-
}
|
|
96
|
-
return value;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (typeof value === "bigint") {
|
|
100
|
-
return TYPE_MARKERS.BIGINT + value.toString();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (typeof value === "symbol") {
|
|
104
|
-
// Symbol은 description만 보존
|
|
105
|
-
return TYPE_MARKERS.SYMBOL + (value.description ?? "");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// 함수는 직렬화 불가
|
|
109
|
-
if (typeof value === "function") {
|
|
110
|
-
console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
|
|
111
|
-
return undefined;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 객체 순환 참조 체크
|
|
115
|
-
if (typeof value === "object") {
|
|
116
|
-
const existing = ctx.seen.get(value);
|
|
117
|
-
if (existing !== undefined) {
|
|
118
|
-
return TYPE_MARKERS.REF + existing;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const idx = ctx.refs.length;
|
|
122
|
-
ctx.seen.set(value, idx);
|
|
123
|
-
ctx.refs.push(value);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Date
|
|
127
|
-
if (value instanceof Date) {
|
|
128
|
-
return TYPE_MARKERS.DATE + value.toISOString();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// URL
|
|
132
|
-
if (value instanceof URL) {
|
|
133
|
-
return TYPE_MARKERS.URL + value.href;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// RegExp
|
|
137
|
-
if (value instanceof RegExp) {
|
|
138
|
-
return TYPE_MARKERS.REGEXP + value.toString();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Error
|
|
142
|
-
if (value instanceof Error) {
|
|
143
|
-
return [
|
|
144
|
-
TYPE_MARKERS.ERROR,
|
|
145
|
-
value.name,
|
|
146
|
-
value.message,
|
|
147
|
-
value.stack ?? "",
|
|
148
|
-
];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Map
|
|
152
|
-
if (value instanceof Map) {
|
|
153
|
-
const entries: [unknown, unknown][] = [];
|
|
154
|
-
for (const [k, v] of value.entries()) {
|
|
155
|
-
entries.push([serialize(k, ctx), serialize(v, ctx)]);
|
|
156
|
-
}
|
|
157
|
-
return [TYPE_MARKERS.MAP, ...entries];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Set
|
|
161
|
-
if (value instanceof Set) {
|
|
162
|
-
const items: unknown[] = [];
|
|
163
|
-
for (const item of value) {
|
|
164
|
-
items.push(serialize(item, ctx));
|
|
165
|
-
}
|
|
166
|
-
return [TYPE_MARKERS.SET, ...items];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// 배열
|
|
170
|
-
if (Array.isArray(value)) {
|
|
171
|
-
return value.map((item) => serialize(item, ctx));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// 일반 객체
|
|
175
|
-
const result: Record<string, unknown> = {};
|
|
176
|
-
for (const [k, v] of Object.entries(value as object)) {
|
|
177
|
-
const serialized = serialize(v, ctx);
|
|
178
|
-
if (serialized !== undefined) {
|
|
179
|
-
result[k] = serialized;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return result;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ============================================
|
|
186
|
-
// 역직렬화
|
|
187
|
-
// ============================================
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* 역직렬화 컨텍스트 (순환 참조 복원)
|
|
191
|
-
*/
|
|
192
|
-
interface DeserializeContext {
|
|
193
|
-
refs: unknown[];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Props 역직렬화
|
|
198
|
-
*
|
|
199
|
-
* @example
|
|
200
|
-
* ```typescript
|
|
201
|
-
* // 서버에서 받은 JSON
|
|
202
|
-
* const json = '{"date":"\x00D2025-01-28T00:00:00.000Z"}';
|
|
203
|
-
*
|
|
204
|
-
* const props = deserializeProps(json);
|
|
205
|
-
* console.log(props.date instanceof Date); // true
|
|
206
|
-
* ```
|
|
207
|
-
*/
|
|
208
|
-
export function deserializeProps(json: string): Record<string, unknown> {
|
|
209
|
-
const ctx: DeserializeContext = { refs: [] };
|
|
210
|
-
const parsed = JSON.parse(json);
|
|
211
|
-
return deserialize(parsed, ctx) as Record<string, unknown>;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* 값 역직렬화 (재귀)
|
|
216
|
-
*/
|
|
217
|
-
function deserialize(value: unknown, ctx: DeserializeContext): unknown {
|
|
218
|
-
// null
|
|
219
|
-
if (value === null) return null;
|
|
220
|
-
|
|
221
|
-
// 문자열 → 타입 마커 체크
|
|
222
|
-
if (typeof value === "string") {
|
|
223
|
-
// undefined
|
|
224
|
-
if (value === TYPE_MARKERS.UNDEFINED) return undefined;
|
|
225
|
-
|
|
226
|
-
// 이스케이프된 문자열 (\x00\x00 → \x00)
|
|
227
|
-
if (value.startsWith("\x00\x00")) {
|
|
228
|
-
return value.slice(2);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Date
|
|
232
|
-
if (value.startsWith(TYPE_MARKERS.DATE)) {
|
|
233
|
-
return new Date(value.slice(2));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// URL
|
|
237
|
-
if (value.startsWith(TYPE_MARKERS.URL)) {
|
|
238
|
-
return new URL(value.slice(2));
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// RegExp
|
|
242
|
-
if (value.startsWith(TYPE_MARKERS.REGEXP)) {
|
|
243
|
-
const str = value.slice(2);
|
|
244
|
-
const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
|
|
245
|
-
if (match) {
|
|
246
|
-
return new RegExp(match[1], match[2]);
|
|
247
|
-
}
|
|
248
|
-
return str; // 파싱 실패 시 문자열 반환
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// BigInt
|
|
252
|
-
if (value.startsWith(TYPE_MARKERS.BIGINT)) {
|
|
253
|
-
return BigInt(value.slice(2));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Symbol
|
|
257
|
-
if (value.startsWith(TYPE_MARKERS.SYMBOL)) {
|
|
258
|
-
return Symbol(value.slice(2));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// 순환 참조
|
|
262
|
-
if (value.startsWith(TYPE_MARKERS.REF)) {
|
|
263
|
-
const idx = parseInt(value.slice(2), 10);
|
|
264
|
-
return ctx.refs[idx];
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return value;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// 원시형
|
|
271
|
-
if (typeof value === "boolean" || typeof value === "number") {
|
|
272
|
-
return value;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// 배열 → 특수 타입 체크
|
|
276
|
-
if (Array.isArray(value)) {
|
|
277
|
-
const marker = value[0];
|
|
278
|
-
|
|
279
|
-
// Error
|
|
280
|
-
if (marker === TYPE_MARKERS.ERROR) {
|
|
281
|
-
const [, name, message, stack] = value as [string, string, string, string];
|
|
282
|
-
const error = new Error(message);
|
|
283
|
-
error.name = name;
|
|
284
|
-
if (stack) error.stack = stack;
|
|
285
|
-
ctx.refs.push(error);
|
|
286
|
-
return error;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Map
|
|
290
|
-
if (marker === TYPE_MARKERS.MAP) {
|
|
291
|
-
const map = new Map();
|
|
292
|
-
ctx.refs.push(map);
|
|
293
|
-
for (let i = 1; i < value.length; i++) {
|
|
294
|
-
const [k, v] = value[i] as [unknown, unknown];
|
|
295
|
-
map.set(deserialize(k, ctx), deserialize(v, ctx));
|
|
296
|
-
}
|
|
297
|
-
return map;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Set
|
|
301
|
-
if (marker === TYPE_MARKERS.SET) {
|
|
302
|
-
const set = new Set();
|
|
303
|
-
ctx.refs.push(set);
|
|
304
|
-
for (let i = 1; i < value.length; i++) {
|
|
305
|
-
set.add(deserialize(value[i], ctx));
|
|
306
|
-
}
|
|
307
|
-
return set;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// 일반 배열
|
|
311
|
-
const arr: unknown[] = [];
|
|
312
|
-
ctx.refs.push(arr);
|
|
313
|
-
for (const item of value) {
|
|
314
|
-
arr.push(deserialize(item, ctx));
|
|
315
|
-
}
|
|
316
|
-
return arr;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// 일반 객체
|
|
320
|
-
if (typeof value === "object") {
|
|
321
|
-
const obj: Record<string, unknown> = {};
|
|
322
|
-
ctx.refs.push(obj);
|
|
323
|
-
for (const [k, v] of Object.entries(value)) {
|
|
324
|
-
obj[k] = deserialize(v, ctx);
|
|
325
|
-
}
|
|
326
|
-
return obj;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return value;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ============================================
|
|
333
|
-
// 유틸리티
|
|
334
|
-
// ============================================
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* 직렬화 가능 여부 체크
|
|
338
|
-
*/
|
|
339
|
-
export function isSerializable(value: unknown): boolean {
|
|
340
|
-
if (value === null || value === undefined) return true;
|
|
341
|
-
|
|
342
|
-
const type = typeof value;
|
|
343
|
-
if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
|
|
344
|
-
return true;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (type === "function" || type === "symbol") {
|
|
348
|
-
return false;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (value instanceof Map || value instanceof Set) {
|
|
356
|
-
return true;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
if (Array.isArray(value)) {
|
|
360
|
-
return value.every(isSerializable);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (type === "object") {
|
|
364
|
-
return Object.values(value as object).every(isSerializable);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return false;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* SSR에서 클라이언트로 props 전달용 스크립트 생성
|
|
372
|
-
*/
|
|
373
|
-
export function generatePropsScript(
|
|
374
|
-
islandId: string,
|
|
375
|
-
props: Record<string, unknown>
|
|
376
|
-
): string {
|
|
377
|
-
const json = serializeProps(props);
|
|
378
|
-
const escaped = json
|
|
379
|
-
.replace(/</g, "\\u003c")
|
|
380
|
-
.replace(/>/g, "\\u003e")
|
|
381
|
-
.replace(/&/g, "\\u0026");
|
|
382
|
-
|
|
383
|
-
return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* 클라이언트에서 props 스크립트 파싱
|
|
388
|
-
*/
|
|
389
|
-
export function parsePropsScript(islandId: string): Record<string, unknown> | null {
|
|
390
|
-
if (typeof document === "undefined") return null;
|
|
391
|
-
|
|
392
|
-
const script = document.querySelector(
|
|
393
|
-
`script[data-mandu-props="${islandId}"]`
|
|
394
|
-
) as HTMLScriptElement | null;
|
|
395
|
-
|
|
396
|
-
if (!script?.textContent) return null;
|
|
397
|
-
|
|
398
|
-
try {
|
|
399
|
-
return deserializeProps(script.textContent);
|
|
400
|
-
} catch (err) {
|
|
401
|
-
console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
|
|
402
|
-
return null;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Props Serialization 📦
|
|
3
|
+
* Fresh 스타일 고급 직렬화/역직렬화
|
|
4
|
+
*
|
|
5
|
+
* @see https://fresh.deno.dev/docs/concepts/islands
|
|
6
|
+
*
|
|
7
|
+
* 지원 타입:
|
|
8
|
+
* - 원시형: null, boolean, number, string, bigint, undefined
|
|
9
|
+
* - 특수 객체: Date, URL, RegExp, Map, Set
|
|
10
|
+
* - 순환 참조
|
|
11
|
+
* - 중첩 객체/배열
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ============================================
|
|
15
|
+
// 타입 마커
|
|
16
|
+
// ============================================
|
|
17
|
+
|
|
18
|
+
const TYPE_MARKERS = {
|
|
19
|
+
/** undefined */
|
|
20
|
+
UNDEFINED: "\x00_",
|
|
21
|
+
/** Date */
|
|
22
|
+
DATE: "\x00D",
|
|
23
|
+
/** URL */
|
|
24
|
+
URL: "\x00U",
|
|
25
|
+
/** RegExp */
|
|
26
|
+
REGEXP: "\x00R",
|
|
27
|
+
/** Map */
|
|
28
|
+
MAP: "\x00M",
|
|
29
|
+
/** Set */
|
|
30
|
+
SET: "\x00S",
|
|
31
|
+
/** 순환 참조 */
|
|
32
|
+
REF: "\x00$",
|
|
33
|
+
/** BigInt */
|
|
34
|
+
BIGINT: "\x00B",
|
|
35
|
+
/** Symbol (제한적 지원) */
|
|
36
|
+
SYMBOL: "\x00Y",
|
|
37
|
+
/** Error */
|
|
38
|
+
ERROR: "\x00E",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
// ============================================
|
|
42
|
+
// 직렬화
|
|
43
|
+
// ============================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 직렬화 컨텍스트 (순환 참조 추적)
|
|
47
|
+
*/
|
|
48
|
+
interface SerializeContext {
|
|
49
|
+
/** 이미 본 객체 → 인덱스 */
|
|
50
|
+
seen: Map<object, number>;
|
|
51
|
+
/** 참조 테이블 */
|
|
52
|
+
refs: object[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Props 직렬화
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const props = {
|
|
61
|
+
* date: new Date(),
|
|
62
|
+
* url: new URL('https://example.com'),
|
|
63
|
+
* items: new Set([1, 2, 3]),
|
|
64
|
+
* cache: new Map([['key', 'value']]),
|
|
65
|
+
* };
|
|
66
|
+
*
|
|
67
|
+
* const json = serializeProps(props);
|
|
68
|
+
* // 클라이언트로 전송
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function serializeProps(props: Record<string, unknown>): string {
|
|
72
|
+
const ctx: SerializeContext = { seen: new Map(), refs: [] };
|
|
73
|
+
return JSON.stringify(serialize(props, ctx));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 값 직렬화 (재귀)
|
|
78
|
+
*/
|
|
79
|
+
function serialize(value: unknown, ctx: SerializeContext): unknown {
|
|
80
|
+
// null
|
|
81
|
+
if (value === null) return null;
|
|
82
|
+
|
|
83
|
+
// undefined
|
|
84
|
+
if (value === undefined) return TYPE_MARKERS.UNDEFINED;
|
|
85
|
+
|
|
86
|
+
// 원시형
|
|
87
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
// 타입 마커와 충돌 방지 (첫 문자가 \x00인 경우)
|
|
93
|
+
if (value.startsWith("\x00")) {
|
|
94
|
+
return "\x00\x00" + value;
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof value === "bigint") {
|
|
100
|
+
return TYPE_MARKERS.BIGINT + value.toString();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof value === "symbol") {
|
|
104
|
+
// Symbol은 description만 보존
|
|
105
|
+
return TYPE_MARKERS.SYMBOL + (value.description ?? "");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 함수는 직렬화 불가
|
|
109
|
+
if (typeof value === "function") {
|
|
110
|
+
console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 객체 순환 참조 체크
|
|
115
|
+
if (typeof value === "object") {
|
|
116
|
+
const existing = ctx.seen.get(value);
|
|
117
|
+
if (existing !== undefined) {
|
|
118
|
+
return TYPE_MARKERS.REF + existing;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const idx = ctx.refs.length;
|
|
122
|
+
ctx.seen.set(value, idx);
|
|
123
|
+
ctx.refs.push(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Date
|
|
127
|
+
if (value instanceof Date) {
|
|
128
|
+
return TYPE_MARKERS.DATE + value.toISOString();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// URL
|
|
132
|
+
if (value instanceof URL) {
|
|
133
|
+
return TYPE_MARKERS.URL + value.href;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// RegExp
|
|
137
|
+
if (value instanceof RegExp) {
|
|
138
|
+
return TYPE_MARKERS.REGEXP + value.toString();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Error
|
|
142
|
+
if (value instanceof Error) {
|
|
143
|
+
return [
|
|
144
|
+
TYPE_MARKERS.ERROR,
|
|
145
|
+
value.name,
|
|
146
|
+
value.message,
|
|
147
|
+
value.stack ?? "",
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Map
|
|
152
|
+
if (value instanceof Map) {
|
|
153
|
+
const entries: [unknown, unknown][] = [];
|
|
154
|
+
for (const [k, v] of value.entries()) {
|
|
155
|
+
entries.push([serialize(k, ctx), serialize(v, ctx)]);
|
|
156
|
+
}
|
|
157
|
+
return [TYPE_MARKERS.MAP, ...entries];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Set
|
|
161
|
+
if (value instanceof Set) {
|
|
162
|
+
const items: unknown[] = [];
|
|
163
|
+
for (const item of value) {
|
|
164
|
+
items.push(serialize(item, ctx));
|
|
165
|
+
}
|
|
166
|
+
return [TYPE_MARKERS.SET, ...items];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 배열
|
|
170
|
+
if (Array.isArray(value)) {
|
|
171
|
+
return value.map((item) => serialize(item, ctx));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 일반 객체
|
|
175
|
+
const result: Record<string, unknown> = {};
|
|
176
|
+
for (const [k, v] of Object.entries(value as object)) {
|
|
177
|
+
const serialized = serialize(v, ctx);
|
|
178
|
+
if (serialized !== undefined) {
|
|
179
|
+
result[k] = serialized;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ============================================
|
|
186
|
+
// 역직렬화
|
|
187
|
+
// ============================================
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 역직렬화 컨텍스트 (순환 참조 복원)
|
|
191
|
+
*/
|
|
192
|
+
interface DeserializeContext {
|
|
193
|
+
refs: unknown[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Props 역직렬화
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```typescript
|
|
201
|
+
* // 서버에서 받은 JSON
|
|
202
|
+
* const json = '{"date":"\x00D2025-01-28T00:00:00.000Z"}';
|
|
203
|
+
*
|
|
204
|
+
* const props = deserializeProps(json);
|
|
205
|
+
* console.log(props.date instanceof Date); // true
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
export function deserializeProps(json: string): Record<string, unknown> {
|
|
209
|
+
const ctx: DeserializeContext = { refs: [] };
|
|
210
|
+
const parsed = JSON.parse(json);
|
|
211
|
+
return deserialize(parsed, ctx) as Record<string, unknown>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 값 역직렬화 (재귀)
|
|
216
|
+
*/
|
|
217
|
+
function deserialize(value: unknown, ctx: DeserializeContext): unknown {
|
|
218
|
+
// null
|
|
219
|
+
if (value === null) return null;
|
|
220
|
+
|
|
221
|
+
// 문자열 → 타입 마커 체크
|
|
222
|
+
if (typeof value === "string") {
|
|
223
|
+
// undefined
|
|
224
|
+
if (value === TYPE_MARKERS.UNDEFINED) return undefined;
|
|
225
|
+
|
|
226
|
+
// 이스케이프된 문자열 (\x00\x00 → \x00)
|
|
227
|
+
if (value.startsWith("\x00\x00")) {
|
|
228
|
+
return value.slice(2);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Date
|
|
232
|
+
if (value.startsWith(TYPE_MARKERS.DATE)) {
|
|
233
|
+
return new Date(value.slice(2));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// URL
|
|
237
|
+
if (value.startsWith(TYPE_MARKERS.URL)) {
|
|
238
|
+
return new URL(value.slice(2));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// RegExp
|
|
242
|
+
if (value.startsWith(TYPE_MARKERS.REGEXP)) {
|
|
243
|
+
const str = value.slice(2);
|
|
244
|
+
const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
|
|
245
|
+
if (match) {
|
|
246
|
+
return new RegExp(match[1], match[2]);
|
|
247
|
+
}
|
|
248
|
+
return str; // 파싱 실패 시 문자열 반환
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// BigInt
|
|
252
|
+
if (value.startsWith(TYPE_MARKERS.BIGINT)) {
|
|
253
|
+
return BigInt(value.slice(2));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Symbol
|
|
257
|
+
if (value.startsWith(TYPE_MARKERS.SYMBOL)) {
|
|
258
|
+
return Symbol(value.slice(2));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 순환 참조
|
|
262
|
+
if (value.startsWith(TYPE_MARKERS.REF)) {
|
|
263
|
+
const idx = parseInt(value.slice(2), 10);
|
|
264
|
+
return ctx.refs[idx];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 원시형
|
|
271
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 배열 → 특수 타입 체크
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
const marker = value[0];
|
|
278
|
+
|
|
279
|
+
// Error
|
|
280
|
+
if (marker === TYPE_MARKERS.ERROR) {
|
|
281
|
+
const [, name, message, stack] = value as [string, string, string, string];
|
|
282
|
+
const error = new Error(message);
|
|
283
|
+
error.name = name;
|
|
284
|
+
if (stack) error.stack = stack;
|
|
285
|
+
ctx.refs.push(error);
|
|
286
|
+
return error;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Map
|
|
290
|
+
if (marker === TYPE_MARKERS.MAP) {
|
|
291
|
+
const map = new Map();
|
|
292
|
+
ctx.refs.push(map);
|
|
293
|
+
for (let i = 1; i < value.length; i++) {
|
|
294
|
+
const [k, v] = value[i] as [unknown, unknown];
|
|
295
|
+
map.set(deserialize(k, ctx), deserialize(v, ctx));
|
|
296
|
+
}
|
|
297
|
+
return map;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Set
|
|
301
|
+
if (marker === TYPE_MARKERS.SET) {
|
|
302
|
+
const set = new Set();
|
|
303
|
+
ctx.refs.push(set);
|
|
304
|
+
for (let i = 1; i < value.length; i++) {
|
|
305
|
+
set.add(deserialize(value[i], ctx));
|
|
306
|
+
}
|
|
307
|
+
return set;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 일반 배열
|
|
311
|
+
const arr: unknown[] = [];
|
|
312
|
+
ctx.refs.push(arr);
|
|
313
|
+
for (const item of value) {
|
|
314
|
+
arr.push(deserialize(item, ctx));
|
|
315
|
+
}
|
|
316
|
+
return arr;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 일반 객체
|
|
320
|
+
if (typeof value === "object") {
|
|
321
|
+
const obj: Record<string, unknown> = {};
|
|
322
|
+
ctx.refs.push(obj);
|
|
323
|
+
for (const [k, v] of Object.entries(value)) {
|
|
324
|
+
obj[k] = deserialize(v, ctx);
|
|
325
|
+
}
|
|
326
|
+
return obj;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ============================================
|
|
333
|
+
// 유틸리티
|
|
334
|
+
// ============================================
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 직렬화 가능 여부 체크
|
|
338
|
+
*/
|
|
339
|
+
export function isSerializable(value: unknown): boolean {
|
|
340
|
+
if (value === null || value === undefined) return true;
|
|
341
|
+
|
|
342
|
+
const type = typeof value;
|
|
343
|
+
if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (type === "function" || type === "symbol") {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (value instanceof Map || value instanceof Set) {
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (Array.isArray(value)) {
|
|
360
|
+
return value.every(isSerializable);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (type === "object") {
|
|
364
|
+
return Object.values(value as object).every(isSerializable);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* SSR에서 클라이언트로 props 전달용 스크립트 생성
|
|
372
|
+
*/
|
|
373
|
+
export function generatePropsScript(
|
|
374
|
+
islandId: string,
|
|
375
|
+
props: Record<string, unknown>
|
|
376
|
+
): string {
|
|
377
|
+
const json = serializeProps(props);
|
|
378
|
+
const escaped = json
|
|
379
|
+
.replace(/</g, "\\u003c")
|
|
380
|
+
.replace(/>/g, "\\u003e")
|
|
381
|
+
.replace(/&/g, "\\u0026");
|
|
382
|
+
|
|
383
|
+
return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 클라이언트에서 props 스크립트 파싱
|
|
388
|
+
*/
|
|
389
|
+
export function parsePropsScript(islandId: string): Record<string, unknown> | null {
|
|
390
|
+
if (typeof document === "undefined") return null;
|
|
391
|
+
|
|
392
|
+
const script = document.querySelector(
|
|
393
|
+
`script[data-mandu-props="${islandId}"]`
|
|
394
|
+
) as HTMLScriptElement | null;
|
|
395
|
+
|
|
396
|
+
if (!script?.textContent) return null;
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
return deserializeProps(script.textContent);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|