@mandujs/core 0.5.1 → 0.5.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/package.json +1 -1
- package/src/bundler/build.ts +114 -57
- package/src/bundler/types.ts +5 -1
- package/src/client/index.ts +9 -1
- package/src/client/island.ts +59 -0
- package/src/filling/context.ts +235 -5
- package/src/filling/index.ts +2 -1
- package/src/runtime/cors.ts +277 -0
- package/src/runtime/env.ts +386 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/server.ts +218 -9
- package/src/runtime/ssr.ts +18 -5
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -28,6 +28,9 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
|
|
|
28
28
|
runtime: "",
|
|
29
29
|
vendor: "",
|
|
30
30
|
},
|
|
31
|
+
importMap: {
|
|
32
|
+
imports: {},
|
|
33
|
+
},
|
|
31
34
|
};
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -201,18 +204,45 @@ export { islandRegistry, hydratedRoots };
|
|
|
201
204
|
}
|
|
202
205
|
|
|
203
206
|
/**
|
|
204
|
-
*
|
|
207
|
+
* React shim 소스 생성 (import map용)
|
|
205
208
|
*/
|
|
206
|
-
function
|
|
209
|
+
function generateReactShimSource(): string {
|
|
207
210
|
return `
|
|
208
211
|
/**
|
|
209
|
-
* Mandu
|
|
210
|
-
*
|
|
212
|
+
* Mandu React Shim (Generated)
|
|
213
|
+
* import map을 통해 bare specifier 해결
|
|
211
214
|
*/
|
|
215
|
+
import * as React from 'react';
|
|
216
|
+
export * from 'react';
|
|
217
|
+
export default React;
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
212
220
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
/**
|
|
222
|
+
* React DOM shim 소스 생성
|
|
223
|
+
*/
|
|
224
|
+
function generateReactDOMShimSource(): string {
|
|
225
|
+
return `
|
|
226
|
+
/**
|
|
227
|
+
* Mandu React DOM Shim (Generated)
|
|
228
|
+
*/
|
|
229
|
+
import * as ReactDOM from 'react-dom';
|
|
230
|
+
export * from 'react-dom';
|
|
231
|
+
export default ReactDOM;
|
|
232
|
+
`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* React DOM Client shim 소스 생성
|
|
237
|
+
*/
|
|
238
|
+
function generateReactDOMClientShimSource(): string {
|
|
239
|
+
return `
|
|
240
|
+
/**
|
|
241
|
+
* Mandu React DOM Client Shim (Generated)
|
|
242
|
+
*/
|
|
243
|
+
import * as ReactDOMClient from 'react-dom/client';
|
|
244
|
+
export * from 'react-dom/client';
|
|
245
|
+
export default ReactDOMClient;
|
|
216
246
|
`;
|
|
217
247
|
}
|
|
218
248
|
|
|
@@ -292,57 +322,77 @@ async function buildRuntime(
|
|
|
292
322
|
}
|
|
293
323
|
|
|
294
324
|
/**
|
|
295
|
-
* Vendor 번들 빌드
|
|
325
|
+
* Vendor shim 번들 빌드 결과
|
|
326
|
+
*/
|
|
327
|
+
interface VendorBuildResult {
|
|
328
|
+
success: boolean;
|
|
329
|
+
react: string;
|
|
330
|
+
reactDom: string;
|
|
331
|
+
reactDomClient: string;
|
|
332
|
+
errors: string[];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Vendor shim 번들 빌드
|
|
337
|
+
* React, ReactDOM, ReactDOMClient를 각각의 shim으로 빌드
|
|
296
338
|
*/
|
|
297
|
-
async function
|
|
339
|
+
async function buildVendorShims(
|
|
298
340
|
outDir: string,
|
|
299
341
|
options: BundlerOptions
|
|
300
|
-
): Promise<
|
|
301
|
-
const
|
|
302
|
-
const
|
|
342
|
+
): Promise<VendorBuildResult> {
|
|
343
|
+
const errors: string[] = [];
|
|
344
|
+
const results: Record<string, string> = {
|
|
345
|
+
react: "",
|
|
346
|
+
reactDom: "",
|
|
347
|
+
reactDomClient: "",
|
|
348
|
+
};
|
|
303
349
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
350
|
+
const shims = [
|
|
351
|
+
{ name: "_react", source: generateReactShimSource(), key: "react" },
|
|
352
|
+
{ name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
|
|
353
|
+
{ name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
|
|
354
|
+
];
|
|
307
355
|
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
outdir: outDir,
|
|
312
|
-
naming: outputName,
|
|
313
|
-
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
314
|
-
sourcemap: options.sourcemap ? "external" : "none",
|
|
315
|
-
target: "browser",
|
|
316
|
-
define: {
|
|
317
|
-
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
318
|
-
...options.define,
|
|
319
|
-
},
|
|
320
|
-
});
|
|
356
|
+
for (const shim of shims) {
|
|
357
|
+
const srcPath = path.join(outDir, `${shim.name}.src.js`);
|
|
358
|
+
const outputName = `${shim.name}.js`;
|
|
321
359
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
360
|
+
try {
|
|
361
|
+
await Bun.write(srcPath, shim.source);
|
|
362
|
+
|
|
363
|
+
const result = await Bun.build({
|
|
364
|
+
entrypoints: [srcPath],
|
|
365
|
+
outdir: outDir,
|
|
366
|
+
naming: outputName,
|
|
367
|
+
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
368
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
369
|
+
target: "browser",
|
|
370
|
+
define: {
|
|
371
|
+
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
372
|
+
...options.define,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await fs.unlink(srcPath).catch(() => {});
|
|
377
|
+
|
|
378
|
+
if (!result.success) {
|
|
379
|
+
errors.push(`[${shim.name}] ${result.logs.map((l) => l.message).join(", ")}`);
|
|
380
|
+
} else {
|
|
381
|
+
results[shim.key] = `/.mandu/client/${outputName}`;
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
await fs.unlink(srcPath).catch(() => {});
|
|
385
|
+
errors.push(`[${shim.name}] ${String(error)}`);
|
|
331
386
|
}
|
|
332
|
-
|
|
333
|
-
return {
|
|
334
|
-
success: true,
|
|
335
|
-
outputPath: `/.mandu/client/${outputName}`,
|
|
336
|
-
errors: [],
|
|
337
|
-
};
|
|
338
|
-
} catch (error) {
|
|
339
|
-
await fs.unlink(vendorPath).catch(() => {});
|
|
340
|
-
return {
|
|
341
|
-
success: false,
|
|
342
|
-
outputPath: "",
|
|
343
|
-
errors: [String(error)],
|
|
344
|
-
};
|
|
345
387
|
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
success: errors.length === 0,
|
|
391
|
+
react: results.react,
|
|
392
|
+
reactDom: results.reactDom,
|
|
393
|
+
reactDomClient: results.reactDomClient,
|
|
394
|
+
errors,
|
|
395
|
+
};
|
|
346
396
|
}
|
|
347
397
|
|
|
348
398
|
/**
|
|
@@ -411,7 +461,7 @@ function createBundleManifest(
|
|
|
411
461
|
outputs: BundleOutput[],
|
|
412
462
|
routes: RouteSpec[],
|
|
413
463
|
runtimePath: string,
|
|
414
|
-
|
|
464
|
+
vendorResult: VendorBuildResult,
|
|
415
465
|
env: "development" | "production"
|
|
416
466
|
): BundleManifest {
|
|
417
467
|
const bundles: BundleManifest["bundles"] = {};
|
|
@@ -422,7 +472,7 @@ function createBundleManifest(
|
|
|
422
472
|
|
|
423
473
|
bundles[output.routeId] = {
|
|
424
474
|
js: output.outputPath,
|
|
425
|
-
dependencies: ["_runtime", "
|
|
475
|
+
dependencies: ["_runtime", "_react"],
|
|
426
476
|
priority: hydration?.priority || "visible",
|
|
427
477
|
};
|
|
428
478
|
}
|
|
@@ -434,7 +484,14 @@ function createBundleManifest(
|
|
|
434
484
|
bundles,
|
|
435
485
|
shared: {
|
|
436
486
|
runtime: runtimePath,
|
|
437
|
-
vendor:
|
|
487
|
+
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
488
|
+
},
|
|
489
|
+
importMap: {
|
|
490
|
+
imports: {
|
|
491
|
+
"react": vendorResult.react,
|
|
492
|
+
"react-dom": vendorResult.reactDom,
|
|
493
|
+
"react-dom/client": vendorResult.reactDomClient,
|
|
494
|
+
},
|
|
438
495
|
},
|
|
439
496
|
};
|
|
440
497
|
}
|
|
@@ -523,10 +580,10 @@ export async function buildClientBundles(
|
|
|
523
580
|
errors.push(...runtimeResult.errors.map((e) => `[Runtime] ${e}`));
|
|
524
581
|
}
|
|
525
582
|
|
|
526
|
-
// 4. Vendor 번들 빌드
|
|
527
|
-
const vendorResult = await
|
|
583
|
+
// 4. Vendor shim 번들 빌드 (React, ReactDOM, ReactDOMClient)
|
|
584
|
+
const vendorResult = await buildVendorShims(outDir, options);
|
|
528
585
|
if (!vendorResult.success) {
|
|
529
|
-
errors.push(...vendorResult.errors
|
|
586
|
+
errors.push(...vendorResult.errors);
|
|
530
587
|
}
|
|
531
588
|
|
|
532
589
|
// 5. 각 Island 번들 빌드
|
|
@@ -544,7 +601,7 @@ export async function buildClientBundles(
|
|
|
544
601
|
outputs,
|
|
545
602
|
hydratedRoutes,
|
|
546
603
|
runtimeResult.outputPath,
|
|
547
|
-
vendorResult
|
|
604
|
+
vendorResult,
|
|
548
605
|
env
|
|
549
606
|
);
|
|
550
607
|
|
package/src/bundler/types.ts
CHANGED
|
@@ -57,9 +57,13 @@ export interface BundleManifest {
|
|
|
57
57
|
shared: {
|
|
58
58
|
/** Hydration 런타임 */
|
|
59
59
|
runtime: string;
|
|
60
|
-
/**
|
|
60
|
+
/** React 번들 경로 */
|
|
61
61
|
vendor: string;
|
|
62
62
|
};
|
|
63
|
+
/** Import map for bare specifiers (react, react-dom, etc.) */
|
|
64
|
+
importMap?: {
|
|
65
|
+
imports: Record<string, string>;
|
|
66
|
+
};
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/**
|
package/src/client/index.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// Island API
|
|
18
18
|
export {
|
|
19
19
|
island,
|
|
20
|
+
wrapComponent,
|
|
20
21
|
useServerData,
|
|
21
22
|
useHydrated,
|
|
22
23
|
useIslandEvent,
|
|
@@ -25,6 +26,7 @@ export {
|
|
|
25
26
|
type IslandMetadata,
|
|
26
27
|
type CompiledIsland,
|
|
27
28
|
type FetchOptions,
|
|
29
|
+
type WrapComponentOptions,
|
|
28
30
|
} from "./island";
|
|
29
31
|
|
|
30
32
|
// Runtime API
|
|
@@ -41,7 +43,7 @@ export {
|
|
|
41
43
|
} from "./runtime";
|
|
42
44
|
|
|
43
45
|
// Re-export as Mandu namespace for consistent API
|
|
44
|
-
import { island } from "./island";
|
|
46
|
+
import { island, wrapComponent } from "./island";
|
|
45
47
|
import { hydrateIslands, initializeRuntime } from "./runtime";
|
|
46
48
|
|
|
47
49
|
/**
|
|
@@ -54,6 +56,12 @@ export const Mandu = {
|
|
|
54
56
|
*/
|
|
55
57
|
island,
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Wrap existing React component as island
|
|
61
|
+
* @see wrapComponent
|
|
62
|
+
*/
|
|
63
|
+
wrapComponent,
|
|
64
|
+
|
|
57
65
|
/**
|
|
58
66
|
* Hydrate all islands on the page
|
|
59
67
|
* @see hydrateIslands
|
package/src/client/island.ts
CHANGED
|
@@ -166,6 +166,65 @@ export function useIslandEvent<T = unknown>(
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* 기존 React 컴포넌트를 Island로 래핑
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* // 기존 React 컴포넌트
|
|
175
|
+
* import DatePicker from 'react-datepicker';
|
|
176
|
+
*
|
|
177
|
+
* // Island로 래핑 (serverData가 그대로 props로 전달됨)
|
|
178
|
+
* export default Mandu.wrapComponent(DatePicker);
|
|
179
|
+
*
|
|
180
|
+
* // 또는 props 변환이 필요한 경우
|
|
181
|
+
* export default Mandu.wrapComponent(DatePicker, {
|
|
182
|
+
* transformProps: (serverData) => ({
|
|
183
|
+
* selected: new Date(serverData.selectedDate),
|
|
184
|
+
* onChange: (date) => console.log(date),
|
|
185
|
+
* })
|
|
186
|
+
* });
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export interface WrapComponentOptions<TServerData, TProps> {
|
|
190
|
+
/** 서버 데이터를 컴포넌트 props로 변환 */
|
|
191
|
+
transformProps?: (serverData: TServerData) => TProps;
|
|
192
|
+
/** 에러 시 표시할 UI */
|
|
193
|
+
errorBoundary?: (error: Error, reset: () => void) => ReactNode;
|
|
194
|
+
/** 로딩 중 표시할 UI */
|
|
195
|
+
loading?: () => ReactNode;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function wrapComponent<TProps extends Record<string, any>>(
|
|
199
|
+
Component: React.ComponentType<TProps>,
|
|
200
|
+
options?: WrapComponentOptions<TProps, TProps>
|
|
201
|
+
): CompiledIsland<TProps, TProps>;
|
|
202
|
+
|
|
203
|
+
export function wrapComponent<TServerData, TProps>(
|
|
204
|
+
Component: React.ComponentType<TProps>,
|
|
205
|
+
options: WrapComponentOptions<TServerData, TProps> & { transformProps: (serverData: TServerData) => TProps }
|
|
206
|
+
): CompiledIsland<TServerData, TProps>;
|
|
207
|
+
|
|
208
|
+
export function wrapComponent<TServerData, TProps>(
|
|
209
|
+
Component: React.ComponentType<TProps>,
|
|
210
|
+
options?: WrapComponentOptions<TServerData, TProps>
|
|
211
|
+
): CompiledIsland<TServerData, TProps> {
|
|
212
|
+
const { transformProps, errorBoundary, loading } = options || {};
|
|
213
|
+
|
|
214
|
+
return island({
|
|
215
|
+
setup: (serverData: TServerData) => {
|
|
216
|
+
return transformProps ? transformProps(serverData) : (serverData as unknown as TProps);
|
|
217
|
+
},
|
|
218
|
+
render: (props: TProps) => {
|
|
219
|
+
// React.createElement를 사용하여 Component 렌더링
|
|
220
|
+
const React = require("react");
|
|
221
|
+
return React.createElement(Component, props);
|
|
222
|
+
},
|
|
223
|
+
errorBoundary,
|
|
224
|
+
loading,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
169
228
|
/**
|
|
170
229
|
* API 호출 헬퍼
|
|
171
230
|
*/
|
package/src/filling/context.ts
CHANGED
|
@@ -5,12 +5,211 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ZodSchema } from "zod";
|
|
7
7
|
|
|
8
|
+
// ========== Cookie Types ==========
|
|
9
|
+
|
|
10
|
+
export interface CookieOptions {
|
|
11
|
+
/** 쿠키 만료 시간 (Date 객체 또는 문자열) */
|
|
12
|
+
expires?: Date | string;
|
|
13
|
+
/** 쿠키 유효 기간 (초) */
|
|
14
|
+
maxAge?: number;
|
|
15
|
+
/** 쿠키 도메인 */
|
|
16
|
+
domain?: string;
|
|
17
|
+
/** 쿠키 경로 */
|
|
18
|
+
path?: string;
|
|
19
|
+
/** HTTPS에서만 전송 */
|
|
20
|
+
secure?: boolean;
|
|
21
|
+
/** JavaScript에서 접근 불가 */
|
|
22
|
+
httpOnly?: boolean;
|
|
23
|
+
/** Same-Site 정책 */
|
|
24
|
+
sameSite?: "strict" | "lax" | "none";
|
|
25
|
+
/** 파티션 키 (CHIPS) */
|
|
26
|
+
partitioned?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Cookie Manager - 쿠키 읽기/쓰기 관리
|
|
31
|
+
*/
|
|
32
|
+
export class CookieManager {
|
|
33
|
+
private requestCookies: Map<string, string>;
|
|
34
|
+
private responseCookies: Map<string, { value: string; options: CookieOptions }>;
|
|
35
|
+
private deletedCookies: Set<string>;
|
|
36
|
+
|
|
37
|
+
constructor(request: Request) {
|
|
38
|
+
this.requestCookies = this.parseRequestCookies(request);
|
|
39
|
+
this.responseCookies = new Map();
|
|
40
|
+
this.deletedCookies = new Set();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private parseRequestCookies(request: Request): Map<string, string> {
|
|
44
|
+
const cookies = new Map<string, string>();
|
|
45
|
+
const cookieHeader = request.headers.get("cookie");
|
|
46
|
+
|
|
47
|
+
if (cookieHeader) {
|
|
48
|
+
const pairs = cookieHeader.split(";");
|
|
49
|
+
for (const pair of pairs) {
|
|
50
|
+
const [name, ...rest] = pair.trim().split("=");
|
|
51
|
+
if (name) {
|
|
52
|
+
cookies.set(name, decodeURIComponent(rest.join("=")));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return cookies;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 쿠키 값 읽기
|
|
62
|
+
* @example
|
|
63
|
+
* const session = ctx.cookies.get('session');
|
|
64
|
+
*/
|
|
65
|
+
get(name: string): string | undefined {
|
|
66
|
+
return this.requestCookies.get(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 쿠키 존재 여부 확인
|
|
71
|
+
*/
|
|
72
|
+
has(name: string): boolean {
|
|
73
|
+
return this.requestCookies.has(name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 모든 쿠키 가져오기
|
|
78
|
+
*/
|
|
79
|
+
getAll(): Record<string, string> {
|
|
80
|
+
return Object.fromEntries(this.requestCookies);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 쿠키 설정
|
|
85
|
+
* @example
|
|
86
|
+
* ctx.cookies.set('session', 'abc123', { httpOnly: true, maxAge: 3600 });
|
|
87
|
+
*/
|
|
88
|
+
set(name: string, value: string, options: CookieOptions = {}): void {
|
|
89
|
+
this.responseCookies.set(name, { value, options });
|
|
90
|
+
this.deletedCookies.delete(name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 쿠키 삭제
|
|
95
|
+
* @example
|
|
96
|
+
* ctx.cookies.delete('session');
|
|
97
|
+
*/
|
|
98
|
+
delete(name: string, options: Pick<CookieOptions, "domain" | "path"> = {}): void {
|
|
99
|
+
this.responseCookies.delete(name);
|
|
100
|
+
this.deletedCookies.add(name);
|
|
101
|
+
// 삭제용 쿠키 설정 (maxAge=0)
|
|
102
|
+
this.responseCookies.set(name, {
|
|
103
|
+
value: "",
|
|
104
|
+
options: {
|
|
105
|
+
...options,
|
|
106
|
+
maxAge: 0,
|
|
107
|
+
expires: new Date(0),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Set-Cookie 헤더 값들 생성
|
|
114
|
+
*/
|
|
115
|
+
getSetCookieHeaders(): string[] {
|
|
116
|
+
const headers: string[] = [];
|
|
117
|
+
|
|
118
|
+
for (const [name, { value, options }] of this.responseCookies) {
|
|
119
|
+
headers.push(this.serializeCookie(name, value, options));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return headers;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 쿠키를 Set-Cookie 헤더 형식으로 직렬화
|
|
127
|
+
*/
|
|
128
|
+
private serializeCookie(name: string, value: string, options: CookieOptions): string {
|
|
129
|
+
const parts: string[] = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
130
|
+
|
|
131
|
+
if (options.maxAge !== undefined) {
|
|
132
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (options.expires) {
|
|
136
|
+
const expires =
|
|
137
|
+
options.expires instanceof Date
|
|
138
|
+
? options.expires.toUTCString()
|
|
139
|
+
: options.expires;
|
|
140
|
+
parts.push(`Expires=${expires}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (options.domain) {
|
|
144
|
+
parts.push(`Domain=${options.domain}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.path) {
|
|
148
|
+
parts.push(`Path=${options.path}`);
|
|
149
|
+
} else {
|
|
150
|
+
parts.push("Path=/"); // 기본값
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (options.secure) {
|
|
154
|
+
parts.push("Secure");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options.httpOnly) {
|
|
158
|
+
parts.push("HttpOnly");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (options.sameSite) {
|
|
162
|
+
parts.push(`SameSite=${options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (options.partitioned) {
|
|
166
|
+
parts.push("Partitioned");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return parts.join("; ");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Response에 Set-Cookie 헤더들 적용
|
|
174
|
+
*/
|
|
175
|
+
applyToResponse(response: Response): Response {
|
|
176
|
+
const setCookieHeaders = this.getSetCookieHeaders();
|
|
177
|
+
|
|
178
|
+
if (setCookieHeaders.length === 0) {
|
|
179
|
+
return response;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Headers를 복사하여 수정
|
|
183
|
+
const newHeaders = new Headers(response.headers);
|
|
184
|
+
|
|
185
|
+
for (const setCookie of setCookieHeaders) {
|
|
186
|
+
newHeaders.append("Set-Cookie", setCookie);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Response(response.body, {
|
|
190
|
+
status: response.status,
|
|
191
|
+
statusText: response.statusText,
|
|
192
|
+
headers: newHeaders,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 응답에 적용할 쿠키가 있는지 확인
|
|
198
|
+
*/
|
|
199
|
+
hasPendingCookies(): boolean {
|
|
200
|
+
return this.responseCookies.size > 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ========== ManduContext ==========
|
|
205
|
+
|
|
8
206
|
export class ManduContext {
|
|
9
207
|
private store: Map<string, unknown> = new Map();
|
|
10
208
|
private _params: Record<string, string>;
|
|
11
209
|
private _query: Record<string, string>;
|
|
12
210
|
private _shouldContinue: boolean = true;
|
|
13
211
|
private _response: Response | null = null;
|
|
212
|
+
private _cookies: CookieManager;
|
|
14
213
|
|
|
15
214
|
constructor(
|
|
16
215
|
public readonly request: Request,
|
|
@@ -18,6 +217,7 @@ export class ManduContext {
|
|
|
18
217
|
) {
|
|
19
218
|
this._params = params;
|
|
20
219
|
this._query = this.parseQuery();
|
|
220
|
+
this._cookies = new CookieManager(request);
|
|
21
221
|
}
|
|
22
222
|
|
|
23
223
|
private parseQuery(): Record<string, string> {
|
|
@@ -58,6 +258,22 @@ export class ManduContext {
|
|
|
58
258
|
return this.request.url;
|
|
59
259
|
}
|
|
60
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Cookie Manager
|
|
263
|
+
* @example
|
|
264
|
+
* // 쿠키 읽기
|
|
265
|
+
* const session = ctx.cookies.get('session');
|
|
266
|
+
*
|
|
267
|
+
* // 쿠키 설정
|
|
268
|
+
* ctx.cookies.set('session', 'abc123', { httpOnly: true, maxAge: 3600 });
|
|
269
|
+
*
|
|
270
|
+
* // 쿠키 삭제
|
|
271
|
+
* ctx.cookies.delete('session');
|
|
272
|
+
*/
|
|
273
|
+
get cookies(): CookieManager {
|
|
274
|
+
return this._cookies;
|
|
275
|
+
}
|
|
276
|
+
|
|
61
277
|
/**
|
|
62
278
|
* Parse request body with optional Zod validation
|
|
63
279
|
* @example
|
|
@@ -95,6 +311,16 @@ export class ManduContext {
|
|
|
95
311
|
// 🥟 Response 보내기
|
|
96
312
|
// ============================================
|
|
97
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Response에 쿠키 헤더 적용 (내부 사용)
|
|
316
|
+
*/
|
|
317
|
+
private withCookies(response: Response): Response {
|
|
318
|
+
if (this._cookies.hasPendingCookies()) {
|
|
319
|
+
return this._cookies.applyToResponse(response);
|
|
320
|
+
}
|
|
321
|
+
return response;
|
|
322
|
+
}
|
|
323
|
+
|
|
98
324
|
/** 200 OK */
|
|
99
325
|
ok<T>(data: T): Response {
|
|
100
326
|
return this.json(data, 200);
|
|
@@ -107,7 +333,7 @@ export class ManduContext {
|
|
|
107
333
|
|
|
108
334
|
/** 204 No Content */
|
|
109
335
|
noContent(): Response {
|
|
110
|
-
return new Response(null, { status: 204 });
|
|
336
|
+
return this.withCookies(new Response(null, { status: 204 }));
|
|
111
337
|
}
|
|
112
338
|
|
|
113
339
|
/** 400 Bad Request */
|
|
@@ -137,28 +363,32 @@ export class ManduContext {
|
|
|
137
363
|
|
|
138
364
|
/** Custom JSON response */
|
|
139
365
|
json<T>(data: T, status: number = 200): Response {
|
|
140
|
-
|
|
366
|
+
const response = Response.json(data, { status });
|
|
367
|
+
return this.withCookies(response);
|
|
141
368
|
}
|
|
142
369
|
|
|
143
370
|
/** Custom text response */
|
|
144
371
|
text(data: string, status: number = 200): Response {
|
|
145
|
-
|
|
372
|
+
const response = new Response(data, {
|
|
146
373
|
status,
|
|
147
374
|
headers: { "Content-Type": "text/plain" },
|
|
148
375
|
});
|
|
376
|
+
return this.withCookies(response);
|
|
149
377
|
}
|
|
150
378
|
|
|
151
379
|
/** Custom HTML response */
|
|
152
380
|
html(data: string, status: number = 200): Response {
|
|
153
|
-
|
|
381
|
+
const response = new Response(data, {
|
|
154
382
|
status,
|
|
155
383
|
headers: { "Content-Type": "text/html" },
|
|
156
384
|
});
|
|
385
|
+
return this.withCookies(response);
|
|
157
386
|
}
|
|
158
387
|
|
|
159
388
|
/** Redirect response */
|
|
160
389
|
redirect(url: string, status: 301 | 302 | 307 | 308 = 302): Response {
|
|
161
|
-
|
|
390
|
+
const response = Response.redirect(url, status);
|
|
391
|
+
return this.withCookies(response);
|
|
162
392
|
}
|
|
163
393
|
|
|
164
394
|
// ============================================
|
package/src/filling/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Mandu Filling Module - 만두소 🥟
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export { ManduContext, NEXT_SYMBOL, ValidationError } from "./context";
|
|
5
|
+
export { ManduContext, NEXT_SYMBOL, ValidationError, CookieManager } from "./context";
|
|
6
|
+
export type { CookieOptions } from "./context";
|
|
6
7
|
export { ManduFilling, Mandu } from "./filling";
|
|
7
8
|
export type { Handler, Guard, HttpMethod } from "./filling";
|