@mandujs/core 0.20.10 → 0.22.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.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Cookie codec abstraction.
3
+ *
4
+ * Two interchangeable implementations of the parse/serialize I/O layer used by
5
+ * `CookieManager`:
6
+ *
7
+ * - {@link LegacyCookieCodec}: pure-JS, runtime-neutral (works anywhere that
8
+ * has `Request`/`Response`/`encodeURIComponent`).
9
+ * - {@link BunCookieMapCodec}: delegates to `Bun.CookieMap` (Bun >= 1.3).
10
+ *
11
+ * The public `CookieManager` API is codec-agnostic. The codec only owns the
12
+ * translation between a wire-format `Cookie:` header string and in-memory
13
+ * entries, and between `(name, value, options)` and a `Set-Cookie:` string.
14
+ *
15
+ * Design notes:
16
+ * - Signed cookies (HMAC-SHA256) and `getParsed` live on `CookieManager` and do
17
+ * not go through the codec. Their format (`encodeURIComponent(value).sig`)
18
+ * must stay stable regardless of codec.
19
+ * - `Bun.CookieMap` auto-injects `SameSite=Lax` when `sameSite` is omitted and
20
+ * reorders attributes relative to the legacy serializer. The Bun codec
21
+ * post-processes output to strip the implicit `SameSite=Lax` so behavior
22
+ * matches the legacy codec exactly. Attribute order differs between codecs
23
+ * but is semantically identical (RFC 6265 treats attributes as an unordered
24
+ * set).
25
+ */
26
+
27
+ import type { CookieOptions } from "./context";
28
+
29
+ // ========== Interface ==========
30
+
31
+ /**
32
+ * Low-level cookie wire-format codec.
33
+ *
34
+ * Implementations translate between `Cookie:` / `Set-Cookie:` string forms and
35
+ * in-memory values. They carry no state beyond the call and must be safe to
36
+ * share across concurrent requests.
37
+ */
38
+ export interface CookieCodec {
39
+ /** Human-readable codec name for diagnostics and tests. */
40
+ readonly name: string;
41
+ /**
42
+ * Parse a raw `Cookie:` request header (RFC 6265) into a name→value map.
43
+ * Returns an empty map when `header` is null, empty, or unparseable.
44
+ *
45
+ * Values are URL-decoded. On decode failure the raw value is retained
46
+ * (matches legacy behavior so existing cookies in the wild keep working).
47
+ *
48
+ * For duplicate names the **first** occurrence wins; this matches RFC 6265
49
+ * §5.4's "the user agent SHOULD serve... the first match" guidance.
50
+ */
51
+ parseRequestHeader(header: string | null): Map<string, string>;
52
+ /**
53
+ * Serialize a single cookie into a `Set-Cookie:` header value.
54
+ *
55
+ * The returned string must not contain a leading `Set-Cookie:` prefix. Both
56
+ * name and value are URL-encoded to survive the header transport layer.
57
+ */
58
+ serializeSetCookie(name: string, value: string, options: CookieOptions): string;
59
+ }
60
+
61
+ // ========== Legacy (pure-JS) implementation ==========
62
+
63
+ /**
64
+ * Runtime-neutral codec. Used when `Bun.CookieMap` is unavailable (Node.js,
65
+ * Deno, browsers, edge runtimes without Bun).
66
+ */
67
+ export const LegacyCookieCodec: CookieCodec = {
68
+ name: "legacy",
69
+
70
+ parseRequestHeader(header: string | null): Map<string, string> {
71
+ const cookies = new Map<string, string>();
72
+ if (!header) return cookies;
73
+
74
+ const pairs = header.split(";");
75
+ for (const pair of pairs) {
76
+ const trimmed = pair.trim();
77
+ if (!trimmed) continue;
78
+ const eqIdx = trimmed.indexOf("=");
79
+ // RFC 6265 §4.2.1 requires cookie-pair = cookie-name "=" cookie-value.
80
+ // Bareword tokens (no '=') are not valid cookies; skip them.
81
+ if (eqIdx === -1) continue;
82
+ const rawName = trimmed.slice(0, eqIdx);
83
+ const rawValue = trimmed.slice(eqIdx + 1);
84
+ if (!rawName) continue;
85
+ const name = safeDecode(rawName);
86
+ // RFC 6265: first cookie wins on duplicate name.
87
+ if (cookies.has(name)) continue;
88
+ cookies.set(name, safeDecode(rawValue));
89
+ }
90
+ return cookies;
91
+ },
92
+
93
+ serializeSetCookie(name: string, value: string, options: CookieOptions): string {
94
+ const parts: string[] = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
95
+
96
+ if (options.maxAge !== undefined) {
97
+ parts.push(`Max-Age=${options.maxAge}`);
98
+ }
99
+
100
+ if (options.expires) {
101
+ const expires =
102
+ options.expires instanceof Date
103
+ ? options.expires.toUTCString()
104
+ : options.expires;
105
+ parts.push(`Expires=${expires}`);
106
+ }
107
+
108
+ if (options.domain) {
109
+ parts.push(`Domain=${options.domain}`);
110
+ }
111
+
112
+ // Default path matches legacy behavior and most browser defaults.
113
+ parts.push(options.path ? `Path=${options.path}` : "Path=/");
114
+
115
+ if (options.secure) parts.push("Secure");
116
+ if (options.httpOnly) parts.push("HttpOnly");
117
+
118
+ if (options.sameSite) {
119
+ parts.push(`SameSite=${capitalize(options.sameSite)}`);
120
+ }
121
+
122
+ if (options.partitioned) parts.push("Partitioned");
123
+
124
+ return parts.join("; ");
125
+ },
126
+ };
127
+
128
+ // ========== Bun.CookieMap-backed implementation ==========
129
+
130
+ interface BunCookieInit {
131
+ name: string;
132
+ value: string;
133
+ domain?: string;
134
+ path?: string;
135
+ expires?: number | Date;
136
+ secure?: boolean;
137
+ httpOnly?: boolean;
138
+ partitioned?: boolean;
139
+ sameSite?: "strict" | "lax" | "none" | "Strict" | "Lax" | "None";
140
+ maxAge?: number;
141
+ }
142
+
143
+ interface BunCookieMapCtor {
144
+ new (init?: string): {
145
+ get(name: string): string | null;
146
+ entries(): IterableIterator<[string, string]>;
147
+ set(init: BunCookieInit): void;
148
+ toSetCookieHeaders(): string[];
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Attempt to resolve `Bun.CookieMap` from the ambient runtime. Returns the
154
+ * constructor when available, or `null` on non-Bun runtimes or older Bun
155
+ * builds that lack the API.
156
+ */
157
+ function resolveBunCookieMap(): BunCookieMapCtor | null {
158
+ // `Bun` is only present in Bun's runtime. Access via `globalThis` to avoid
159
+ // ReferenceError under other runtimes while still allowing bundlers to
160
+ // tree-shake based on the dynamic lookup.
161
+ const bun = (globalThis as unknown as { Bun?: { CookieMap?: unknown } }).Bun;
162
+ const Ctor = bun?.CookieMap;
163
+ if (typeof Ctor !== "function") return null;
164
+ return Ctor as BunCookieMapCtor;
165
+ }
166
+
167
+ /**
168
+ * Factory for the Bun-native codec. Returns `null` if `Bun.CookieMap` is not
169
+ * available, allowing callers to fall back to the legacy codec.
170
+ */
171
+ export function createBunCookieMapCodec(): CookieCodec | null {
172
+ const CookieMap = resolveBunCookieMap();
173
+ if (!CookieMap) return null;
174
+
175
+ return {
176
+ name: "bun-cookiemap",
177
+
178
+ parseRequestHeader(header: string | null): Map<string, string> {
179
+ const cookies = new Map<string, string>();
180
+ if (!header) return cookies;
181
+ // Bun.CookieMap handles URL-decoding and whitespace, and iterates
182
+ // entries in insertion order.
183
+ const map = new CookieMap(header);
184
+ for (const [name, value] of map.entries()) {
185
+ if (!name) continue;
186
+ // First-wins semantics to match LegacyCookieCodec and RFC 6265.
187
+ if (!cookies.has(name)) cookies.set(name, value);
188
+ }
189
+ return cookies;
190
+ },
191
+
192
+ serializeSetCookie(name: string, value: string, options: CookieOptions): string {
193
+ const map = new CookieMap();
194
+ const init: BunCookieInit = { name, value };
195
+
196
+ if (options.maxAge !== undefined) init.maxAge = options.maxAge;
197
+ // NOTE: we deliberately omit `expires` from the Bun.CookieMap init and
198
+ // append our own Expires attribute below. Bun 1.3.10's CookieMap emits
199
+ // `Expires` in a non-standard form (e.g. `Tue, 15 Jun 2026 ... -0000`
200
+ // for a Monday — day-of-week mismatch) that breaks byte-parity with the
201
+ // legacy codec and is technically not RFC 7231 IMF-fixdate. Using
202
+ // `Date.toUTCString()` matches the legacy impl exactly.
203
+ if (options.domain) init.domain = options.domain;
204
+ init.path = options.path ?? "/";
205
+ if (options.secure) init.secure = true;
206
+ if (options.httpOnly) init.httpOnly = true;
207
+ if (options.partitioned) init.partitioned = true;
208
+ if (options.sameSite) init.sameSite = options.sameSite;
209
+
210
+ map.set(init);
211
+ const headers = map.toSetCookieHeaders();
212
+ if (headers.length === 0) {
213
+ // Defensive: Bun.CookieMap should always yield one header after set().
214
+ return LegacyCookieCodec.serializeSetCookie(name, value, options);
215
+ }
216
+ let header = headers[0];
217
+
218
+ // Bun.CookieMap auto-injects `SameSite=Lax` when sameSite is unspecified.
219
+ // The legacy codec omits SameSite in that case; preserve that behavior.
220
+ if (!options.sameSite) {
221
+ header = stripDefaultSameSite(header);
222
+ }
223
+
224
+ // Append RFC 7231-compliant Expires after Bun's other attributes.
225
+ if (options.expires !== undefined) {
226
+ const expiresValue =
227
+ options.expires instanceof Date
228
+ ? options.expires.toUTCString()
229
+ : options.expires;
230
+ header += `; Expires=${expiresValue}`;
231
+ }
232
+
233
+ return header;
234
+ },
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Remove the trailing `; SameSite=Lax` that Bun injects when sameSite is not
240
+ * specified. Only strips an **exact, isolated** attribute so explicit
241
+ * `SameSite=Lax` from the caller survives (caller would have set
242
+ * `options.sameSite='lax'` and this function is skipped anyway).
243
+ */
244
+ function stripDefaultSameSite(header: string): string {
245
+ // Prefer trailing position (Bun's current emit order).
246
+ if (header.endsWith("; SameSite=Lax")) {
247
+ return header.slice(0, -"; SameSite=Lax".length);
248
+ }
249
+ // Defensive: handle middle-position insertion if Bun reorders attributes.
250
+ const middle = header.indexOf("; SameSite=Lax;");
251
+ if (middle !== -1) {
252
+ return header.slice(0, middle) + header.slice(middle + "; SameSite=Lax".length);
253
+ }
254
+ return header;
255
+ }
256
+
257
+ // ========== Codec selection ==========
258
+
259
+ function capitalize(s: string): string {
260
+ return s.charAt(0).toUpperCase() + s.slice(1);
261
+ }
262
+
263
+ /**
264
+ * URL-decode a cookie token, preserving the raw value if the encoding is
265
+ * malformed. Matches the forgiving behavior of most cookie parsers (browsers,
266
+ * `cookie` npm package, Bun.CookieMap) so malformed cookies from clients do
267
+ * not crash server-side code.
268
+ */
269
+ function safeDecode(s: string): string {
270
+ try {
271
+ return decodeURIComponent(s);
272
+ } catch {
273
+ return s;
274
+ }
275
+ }
276
+
277
+ let activeCodec: CookieCodec = resolveDefaultCodec();
278
+
279
+ function resolveDefaultCodec(): CookieCodec {
280
+ return createBunCookieMapCodec() ?? LegacyCookieCodec;
281
+ }
282
+
283
+ /**
284
+ * Return the active codec. Resolved once at module load and overridable via
285
+ * {@link _setCodecForTesting}.
286
+ */
287
+ export function getCookieCodec(): CookieCodec {
288
+ return activeCodec;
289
+ }
290
+
291
+ /**
292
+ * Test-only escape hatch for forcing a specific codec. Pass a codec to
293
+ * override, or omit to restore the runtime-detected default.
294
+ *
295
+ * @internal
296
+ */
297
+ export function _setCodecForTesting(codec?: CookieCodec): void {
298
+ activeCodec = codec ?? resolveDefaultCodec();
299
+ }
@@ -80,10 +80,34 @@ export interface EventBusDeps {
80
80
  */
81
81
  export interface FillingDeps {
82
82
  /**
83
- * 데이터베이스 접근
83
+ * 데이터베이스 접근 (legacy shape — query/transaction).
84
+ * Kept for backward compatibility. New code should prefer `sql` below.
84
85
  */
85
86
  db?: DbDeps;
86
87
 
88
+ /**
89
+ * Phase 4c — Bun.sql-backed `Db` handle.
90
+ *
91
+ * This is the tagged-template callable shape produced by
92
+ * `@mandujs/core/db`'s `createDb()`. Generated resource repos
93
+ * (`createXRepo(db)` from `*.repo.ts`) consume this directly.
94
+ *
95
+ * Typed as `unknown` here to avoid a circular type import from
96
+ * `@mandujs/core/db` into the filling layer. Consumers cast via
97
+ * `import type { Db } from "@mandujs/core/db"` at the call site:
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * import type { Db } from "@mandujs/core/db";
102
+ * export default Mandu.filling().get((ctx) => {
103
+ * const db = ctx.deps.sql as Db;
104
+ * const repo = createUsersRepo(db);
105
+ * return ctx.ok(await repo.findMany());
106
+ * });
107
+ * ```
108
+ */
109
+ sql?: unknown;
110
+
87
111
  /**
88
112
  * 캐시 접근
89
113
  */
@@ -48,8 +48,19 @@ export interface MiddlewarePlugin {
48
48
  mapResponse?: MapResponseHandler;
49
49
  }
50
50
 
51
- /** Loader function type - SSR 데이터 로딩 */
52
- export type Loader<T = unknown> = (ctx: ManduContext) => T | Promise<T>;
51
+ /**
52
+ * Loader function type SSR data loader.
53
+ *
54
+ * Returns the route's data object `T`, or a `Response` to short-circuit
55
+ * the SSR pipeline (DX-3). A returned `Response` with a redirect-range
56
+ * status code becomes the final response — SSR is skipped and any pending
57
+ * `ctx.cookies` are merged into the outgoing headers. Use the
58
+ * `redirect(url)` helper for the common case; throwing a `Response` is
59
+ * also accepted (Remix idiom).
60
+ */
61
+ export type Loader<T = unknown> = (
62
+ ctx: ManduContext
63
+ ) => T | Response | Promise<T | Response>;
53
64
 
54
65
  /** Loader 실행 옵션 */
55
66
  export interface LoaderOptions<T = unknown> {
@@ -231,10 +242,24 @@ export class ManduFilling<TLoaderData = unknown> {
231
242
  return this.config.renderMode ?? "dynamic";
232
243
  }
233
244
 
245
+ /**
246
+ * Execute the registered loader.
247
+ *
248
+ * Return shape:
249
+ * - `TLoaderData` — normal loader data
250
+ * - `Response` — the loader returned a Response (e.g. `redirect("/login")`).
251
+ * Callers must check `instanceof Response` and short-circuit their
252
+ * pipeline instead of treating the value as page data.
253
+ * - `undefined` — no loader registered
254
+ *
255
+ * DX-3: the Response overload is explicit in the signature so downstream
256
+ * TypeScript consumers are forced to narrow before consuming data —
257
+ * prevents accidentally passing a Response into renderPageSSR.
258
+ */
234
259
  async executeLoader(
235
260
  ctx: ManduContext,
236
261
  options: LoaderOptions<TLoaderData> = {}
237
- ): Promise<TLoaderData | undefined> {
262
+ ): Promise<TLoaderData | Response | undefined> {
238
263
  if (!this.config.loader) {
239
264
  return undefined;
240
265
  }