@mandujs/core 0.27.0 → 0.29.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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Middleware composition — Phase 18.ε
3
+ *
4
+ * Builds a request pipeline from an array of `Middleware` layers and a
5
+ * final handler. The resulting function has Next.js / SvelteKit semantics:
6
+ *
7
+ * - **Declaration order = outer-to-inner.** `compose(a, b, c)` runs
8
+ * `a.handler(req, nextA)` first; `nextA()` runs `b.handler`, whose
9
+ * `nextB()` runs `c.handler`, whose `nextC()` invokes `finalHandler`.
10
+ *
11
+ * - **Short-circuit.** Any middleware may return a Response without
12
+ * calling `next()`. Downstream middleware and the final handler are
13
+ * skipped; the outer chain receives the short-circuit Response.
14
+ *
15
+ * - **Rewrite.** `next(modifiedReq)` propagates `modifiedReq` to the
16
+ * remainder of the chain. The current middleware still sees the
17
+ * original `req` argument (no mutation).
18
+ *
19
+ * - **Match filter.** Middleware with a `match(req) === false` are
20
+ * skipped at their position in the chain — `next()` transparently
21
+ * advances to the next layer.
22
+ *
23
+ * - **Error propagation.** Throws inside middleware are re-thrown to
24
+ * the caller. Mandu's outer `handleRequest` wraps this in the
25
+ * framework's error boundary (error → 500 via `errorToResponse`),
26
+ * so middleware authors never need their own top-level try/catch
27
+ * unless they want to convert specific errors to specific responses.
28
+ *
29
+ * - **Double-next guard.** Calling `next()` twice inside a single
30
+ * middleware is a programming error (it would re-execute downstream
31
+ * layers with duplicate side effects). The second call throws a
32
+ * `MiddlewareError` with the offending middleware's name so the bug
33
+ * surfaces immediately in dev.
34
+ *
35
+ * @see {@link Middleware} for the interface.
36
+ * @see `docs/architect/middleware-composition.md` for patterns.
37
+ */
38
+ import type { Middleware } from "./define";
39
+
40
+ /**
41
+ * The finalized request handler that sits at the bottom of the middleware
42
+ * chain. Typically this is `handleRequest(req, router, registry)` adapted
43
+ * to the `(req) => Promise<Response>` shape.
44
+ */
45
+ export type FinalHandler = (req: Request) => Promise<Response>;
46
+
47
+ /**
48
+ * The function produced by {@link compose}. Applies the middleware chain
49
+ * on top of `finalHandler` for the given request.
50
+ */
51
+ export type ComposedHandler = (
52
+ req: Request,
53
+ finalHandler: FinalHandler
54
+ ) => Promise<Response>;
55
+
56
+ /**
57
+ * Thrown when a middleware calls `next()` more than once. Identifies the
58
+ * middleware by name so the diagnostic is actionable.
59
+ */
60
+ export class MiddlewareError extends Error {
61
+ override readonly name = "MiddlewareError";
62
+ constructor(
63
+ public readonly middlewareName: string,
64
+ message: string
65
+ ) {
66
+ super(`[${middlewareName}] ${message}`);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Compose a middleware chain. Zero middleware produces a passthrough:
72
+ * `compose()(req, final) === final(req)`.
73
+ */
74
+ export function compose(...middlewares: Middleware[]): ComposedHandler {
75
+ // Defensive copy — callers mutating the source array after compose() must
76
+ // not affect the frozen pipeline. Also narrows index type for the inner
77
+ // recursion and makes an empty-array fast path trivial.
78
+ const chain = middlewares.slice();
79
+
80
+ if (chain.length === 0) {
81
+ return (req, finalHandler) => finalHandler(req);
82
+ }
83
+
84
+ return async function composed(
85
+ req: Request,
86
+ finalHandler: FinalHandler
87
+ ): Promise<Response> {
88
+ // Recursive dispatcher. `index` is the next middleware to try; `current`
89
+ // is the Request object that layer will receive. Each invocation either:
90
+ // (a) index === chain.length → delegate to finalHandler(current)
91
+ // (b) chain[index].match(current) === false → skip, recurse to next
92
+ // (c) run chain[index].handler(current, next) where next() = dispatch(i+1, …)
93
+ async function dispatch(index: number, current: Request): Promise<Response> {
94
+ if (index >= chain.length) {
95
+ return finalHandler(current);
96
+ }
97
+ const mw = chain[index]!;
98
+
99
+ // Evaluate match filter. Throws in `match` are framework-bug territory —
100
+ // we surface them to the outer error boundary rather than papering over.
101
+ if (mw.match) {
102
+ let matched: boolean;
103
+ try {
104
+ matched = mw.match(current);
105
+ } catch (err) {
106
+ throw new MiddlewareError(
107
+ mw.name,
108
+ `\`match(req)\` threw: ${err instanceof Error ? err.message : String(err)}`
109
+ );
110
+ }
111
+ if (!matched) {
112
+ return dispatch(index + 1, current);
113
+ }
114
+ }
115
+
116
+ // Single-use `next` guard — second invocation throws.
117
+ let nextCalled = false;
118
+ const next = (override?: Request): Promise<Response> => {
119
+ if (nextCalled) {
120
+ throw new MiddlewareError(
121
+ mw.name,
122
+ "next() was called more than once. Each middleware must call next() at most once."
123
+ );
124
+ }
125
+ nextCalled = true;
126
+ return dispatch(index + 1, override ?? current);
127
+ };
128
+
129
+ return mw.handler(current, next);
130
+ }
131
+
132
+ return dispatch(0, req);
133
+ };
134
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Canonical Middleware Composition API — Phase 18.ε
3
+ *
4
+ * This module defines Mandu's request-level middleware contract. It is the
5
+ * runtime analogue of Next.js `middleware.ts` and SvelteKit's `hooks.server.ts`
6
+ * `handle` sequence: each middleware is a thin onion layer that wraps the
7
+ * final route handler, can short-circuit by returning a Response without
8
+ * calling `next()`, and executes in declaration order (outermost first).
9
+ *
10
+ * This API is intentionally **request-level**, not context-level:
11
+ *
12
+ * - `filling().use(...)` / `MiddlewarePlugin` — operates on a per-route
13
+ * `ManduContext`. Good for inline concerns like `csrf()` / `session()`
14
+ * / `secure()` that a single filling chain wants to compose.
15
+ *
16
+ * - `Middleware` / `compose()` (this module) — operates on the raw
17
+ * `Request` BEFORE route dispatch. Good for app-wide policies:
18
+ * auth gates, tenant resolution, rate limiting, request logging,
19
+ * rewrites, redirects.
20
+ *
21
+ * Both APIs co-exist. Bridge wrappers live alongside each individual
22
+ * middleware module (e.g. `csrf.ts` exports `csrfMiddleware(...)`) so users
23
+ * who want the canonical composition API for an existing middleware can
24
+ * plug it in without boilerplate.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * import { defineMiddleware, compose } from "@mandujs/core/middleware";
29
+ *
30
+ * const requestId = defineMiddleware({
31
+ * name: "request-id",
32
+ * async handler(req, next) {
33
+ * const id = req.headers.get("x-request-id") ?? crypto.randomUUID();
34
+ * const res = await next();
35
+ * res.headers.set("x-request-id", id);
36
+ * return res;
37
+ * },
38
+ * });
39
+ *
40
+ * const authGate = defineMiddleware({
41
+ * name: "auth-gate",
42
+ * match: (req) => new URL(req.url).pathname.startsWith("/admin"),
43
+ * async handler(req, next) {
44
+ * if (!req.headers.get("authorization")) {
45
+ * return new Response("Unauthorized", { status: 401 });
46
+ * }
47
+ * return next();
48
+ * },
49
+ * });
50
+ *
51
+ * // In mandu.config.ts:
52
+ * export default {
53
+ * middleware: [requestId, authGate],
54
+ * } satisfies ManduConfig;
55
+ * ```
56
+ */
57
+
58
+ /**
59
+ * A request-level middleware. Runs BEFORE route dispatch in declaration order.
60
+ *
61
+ * - `name` identifies the middleware in error messages and diagnostic traces.
62
+ * - `match?` is an optional filter: middleware whose `match(req)` returns
63
+ * `false` are skipped (the chain proceeds to the next middleware). Absent
64
+ * means "always match".
65
+ * - `handler(req, next)` does the actual work. Return a Response directly
66
+ * to short-circuit (downstream middleware and the route handler are
67
+ * skipped). Call `next()` to invoke the rest of the chain and receive
68
+ * its Response — at which point you may mutate headers, re-wrap the
69
+ * body, log, etc.
70
+ *
71
+ * The `req` argument is the Request the current layer sees; a middleware
72
+ * may pass a modified Request to `next()` by calling it with a Request
73
+ * argument (rewrite pattern). When called with no argument, `next()` uses
74
+ * the Request passed into the handler.
75
+ */
76
+ export interface Middleware {
77
+ /** Display name used in diagnostics. Must be non-empty. */
78
+ name: string;
79
+ /**
80
+ * Optional route filter. When present and returns `false`, the chain
81
+ * skips this middleware entirely (proceeds to the next layer / final
82
+ * handler). When absent the middleware matches all requests.
83
+ *
84
+ * Must be synchronous and side-effect-free — `match` runs once per
85
+ * request per middleware and any throw short-circuits the chain with
86
+ * a 500 response.
87
+ */
88
+ match?: (req: Request) => boolean;
89
+ /**
90
+ * Middleware handler. Receives the current Request and a `next()` thunk
91
+ * that invokes the rest of the chain (or the final route handler, if
92
+ * this is the innermost middleware). Must return a Response.
93
+ *
94
+ * - Call `next()` to continue the chain with the same request.
95
+ * - Call `next(modifiedReq)` to continue with a rewritten request
96
+ * (downstream middleware and the final handler see `modifiedReq`).
97
+ * - Return a Response directly WITHOUT calling `next()` to short-circuit.
98
+ */
99
+ handler: (
100
+ req: Request,
101
+ next: (req?: Request) => Promise<Response>
102
+ ) => Promise<Response>;
103
+ }
104
+
105
+ /**
106
+ * Ergonomic helper — passes the middleware object through unchanged but
107
+ * preserves full type inference and documents intent at the call site.
108
+ * Mirrors Next.js `defineMiddleware()` and SvelteKit `Handle` exports.
109
+ *
110
+ * @throws {TypeError} when `m` is missing `name` or `handler`, or when
111
+ * `name` is empty. Fail-fast at definition time — we do NOT want a
112
+ * silent no-op layer corrupting a composition chain.
113
+ */
114
+ export function defineMiddleware(m: Middleware): Middleware {
115
+ if (!m || typeof m !== "object") {
116
+ throw new TypeError("[Mandu Middleware] defineMiddleware requires an object");
117
+ }
118
+ if (typeof m.name !== "string" || m.name.length === 0) {
119
+ throw new TypeError("[Mandu Middleware] defineMiddleware requires a non-empty `name`");
120
+ }
121
+ if (typeof m.handler !== "function") {
122
+ throw new TypeError(
123
+ `[Mandu Middleware] defineMiddleware requires a \`handler\` function (middleware "${m.name}")`
124
+ );
125
+ }
126
+ if (m.match !== undefined && typeof m.match !== "function") {
127
+ throw new TypeError(
128
+ `[Mandu Middleware] \`match\` must be a function when provided (middleware "${m.name}")`
129
+ );
130
+ }
131
+ return m;
132
+ }
@@ -1,50 +1,76 @@
1
- /**
2
- * Mandu Middleware Plugins
3
- * filling.use()로 조합 가능한 재사용 미들웨어
4
- */
5
-
6
- export { cors, type CorsMiddlewareOptions } from "./cors";
7
- export { jwt, type JwtMiddlewareOptions } from "./jwt";
8
- export { csrf, type CsrfMiddlewareOptions } from "./csrf";
9
- export { compress, type CompressMiddlewareOptions } from "./compress";
10
- export { logger, type LoggerMiddlewareOptions } from "./logger";
11
- export { timeout, type TimeoutMiddlewareOptions } from "./timeout";
12
- export {
13
- session,
14
- saveSession,
15
- destroySession,
16
- type SessionMiddlewareOptions,
17
- } from "./session";
18
- export {
19
- oauth,
20
- github,
21
- google,
22
- type OAuthOptions,
23
- type OAuthProvider,
24
- type OAuthProfile,
25
- } from "./oauth";
26
- export {
27
- secure,
28
- applySecureHeadersToResponse,
29
- buildCsp,
30
- DEFAULT_CSP_DIRECTIVES,
31
- type SecureMiddlewareOptions,
32
- type CspOptions,
33
- type BuiltCsp,
34
- type HstsOptions,
35
- type ReferrerPolicyValue,
36
- } from "./secure";
37
- export {
38
- rateLimit,
39
- createRateLimitGuard,
40
- createInMemoryStore,
41
- createSqliteStore,
42
- RateLimitError,
43
- type RateLimitResult,
44
- type RateLimitStore,
45
- type RateLimitMiddleware,
46
- type RateLimitMiddlewareOptions,
47
- type RateLimitGuard,
48
- type RateLimitGuardOptions,
49
- type SqliteRateLimitStoreOptions,
50
- } from "./rate-limit";
1
+ /**
2
+ * Mandu Middleware — two layers, one surface.
3
+ *
4
+ * 1. Filling-level (ctx-based): `.use(csrf(...))`, `.use(session(...))`
5
+ * etc. Runs inside the per-route filling chain with a `ManduContext`.
6
+ *
7
+ * 2. Request-level (Phase 18.ε canonical composition API):
8
+ * `defineMiddleware(...)`, `compose(...)`. Runs BEFORE route
9
+ * dispatch on the raw `Request`. Formalized in `define.ts` /
10
+ * `compose.ts`; bridge wrappers for the existing ctx-based
11
+ * middleware live in `bridge.ts`.
12
+ */
13
+
14
+ // Phase 18.ε — canonical composition API.
15
+ export {
16
+ defineMiddleware,
17
+ type Middleware,
18
+ } from "./define";
19
+ export {
20
+ compose,
21
+ MiddlewareError,
22
+ type ComposedHandler,
23
+ type FinalHandler,
24
+ } from "./compose";
25
+ export {
26
+ csrfMiddleware,
27
+ sessionMiddleware,
28
+ secureMiddleware,
29
+ rateLimitMiddleware,
30
+ } from "./bridge";
31
+
32
+ export { cors, type CorsMiddlewareOptions } from "./cors";
33
+ export { jwt, type JwtMiddlewareOptions } from "./jwt";
34
+ export { csrf, type CsrfMiddlewareOptions } from "./csrf";
35
+ export { compress, type CompressMiddlewareOptions } from "./compress";
36
+ export { logger, type LoggerMiddlewareOptions } from "./logger";
37
+ export { timeout, type TimeoutMiddlewareOptions } from "./timeout";
38
+ export {
39
+ session,
40
+ saveSession,
41
+ destroySession,
42
+ type SessionMiddlewareOptions,
43
+ } from "./session";
44
+ export {
45
+ oauth,
46
+ github,
47
+ google,
48
+ type OAuthOptions,
49
+ type OAuthProvider,
50
+ type OAuthProfile,
51
+ } from "./oauth";
52
+ export {
53
+ secure,
54
+ applySecureHeadersToResponse,
55
+ buildCsp,
56
+ DEFAULT_CSP_DIRECTIVES,
57
+ type SecureMiddlewareOptions,
58
+ type CspOptions,
59
+ type BuiltCsp,
60
+ type HstsOptions,
61
+ type ReferrerPolicyValue,
62
+ } from "./secure";
63
+ export {
64
+ rateLimit,
65
+ createRateLimitGuard,
66
+ createInMemoryStore,
67
+ createSqliteStore,
68
+ RateLimitError,
69
+ type RateLimitResult,
70
+ type RateLimitStore,
71
+ type RateLimitMiddleware,
72
+ type RateLimitMiddlewareOptions,
73
+ type RateLimitGuard,
74
+ type RateLimitGuardOptions,
75
+ type SqliteRateLimitStoreOptions,
76
+ } from "./rate-limit";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * fs-patterns — pattern detection unit tests (Phase 18.β)
3
+ *
4
+ * Focused counterpart to tests/router/route-conventions.test.ts —
5
+ * co-located with the module so `bun test src/router/` alone exercises
6
+ * all detection paths without needing a tmpdir fixture. Keeps the
7
+ * per-symbol tests cheap enough to be run in watch mode.
8
+ */
9
+ import { describe, it, expect } from "bun:test";
10
+ import {
11
+ detectFileType,
12
+ parseSegment,
13
+ parseSegments,
14
+ segmentsToPattern,
15
+ isGroupFolder,
16
+ validateSegments,
17
+ } from "./fs-patterns";
18
+
19
+ describe("fs-patterns — convention file detection", () => {
20
+ it("detects loading.tsx / loading.ts / loading.jsx / loading.js", () => {
21
+ expect(detectFileType("loading.tsx")).toBe("loading");
22
+ expect(detectFileType("loading.ts")).toBe("loading");
23
+ expect(detectFileType("loading.jsx")).toBe("loading");
24
+ expect(detectFileType("loading.js")).toBe("loading");
25
+ });
26
+
27
+ it("detects error.tsx / error.ts", () => {
28
+ expect(detectFileType("error.tsx")).toBe("error");
29
+ expect(detectFileType("error.ts")).toBe("error");
30
+ });
31
+
32
+ it("detects not-found.tsx / not-found.ts", () => {
33
+ expect(detectFileType("not-found.tsx")).toBe("not-found");
34
+ expect(detectFileType("not-found.ts")).toBe("not-found");
35
+ });
36
+
37
+ it("does NOT treat notfound.tsx (missing dash) as a convention", () => {
38
+ expect(detectFileType("notfound.tsx")).toBeNull();
39
+ expect(detectFileType("not_found.tsx")).toBeNull();
40
+ });
41
+
42
+ it("does NOT confuse loading.island.tsx for a loading convention", () => {
43
+ // Island suffix wins first (detectFileType checks island before loading)
44
+ expect(detectFileType("loading.island.tsx")).toBe("island");
45
+ });
46
+ });
47
+
48
+ describe("fs-patterns — route groups", () => {
49
+ it("identifies (marketing) as a group segment", () => {
50
+ expect(isGroupFolder("(marketing)")).toBe(true);
51
+ expect(isGroupFolder("(auth)")).toBe(true);
52
+ expect(isGroupFolder("marketing")).toBe(false);
53
+ expect(isGroupFolder("(not-matching")).toBe(false);
54
+ });
55
+
56
+ it("parses (name) as type=group with no param", () => {
57
+ const seg = parseSegment("(marketing)");
58
+ expect(seg.type).toBe("group");
59
+ expect(seg.paramName).toBeUndefined();
60
+ });
61
+
62
+ it("strips group segments from URL patterns", () => {
63
+ expect(segmentsToPattern(parseSegments("(mkt)/pricing/page.tsx"))).toBe("/pricing");
64
+ expect(segmentsToPattern(parseSegments("(a)/(b)/c/page.tsx"))).toBe("/c");
65
+ });
66
+ });
67
+
68
+ describe("fs-patterns — optional catch-all", () => {
69
+ it("parses [[...slug]] as optionalCatchAll with paramName", () => {
70
+ const seg = parseSegment("[[...slug]]");
71
+ expect(seg.type).toBe("optionalCatchAll");
72
+ expect(seg.paramName).toBe("slug");
73
+ });
74
+
75
+ it("emits :param*? pattern for optionalCatchAll", () => {
76
+ const pattern = segmentsToPattern(parseSegments("docs/[[...path]]/page.tsx"));
77
+ expect(pattern).toBe("/docs/:path*?");
78
+ });
79
+
80
+ it("distinguishes [[...x]] (optional) from [...x] (required)", () => {
81
+ expect(parseSegment("[...x]").type).toBe("catchAll");
82
+ expect(parseSegment("[[...x]]").type).toBe("optionalCatchAll");
83
+ });
84
+
85
+ it("rejects catch-all mid-path via validateSegments", () => {
86
+ const segs = parseSegments("[[...any]]/after/page.tsx");
87
+ const r = validateSegments(segs);
88
+ expect(r.valid).toBe(false);
89
+ });
90
+
91
+ it("accepts optional catch-all at the end", () => {
92
+ const segs = parseSegments("docs/[[...rest]]/page.tsx");
93
+ const r = validateSegments(segs);
94
+ expect(r.valid).toBe(true);
95
+ });
96
+ });
@@ -82,6 +82,7 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
82
82
  : {}),
83
83
  ...(fsRoute.loadingModule ? { loadingModule: normalizePath(fsRoute.loadingModule) } : {}),
84
84
  ...(fsRoute.errorModule ? { errorModule: normalizePath(fsRoute.errorModule) } : {}),
85
+ ...(fsRoute.notFoundModule ? { notFoundModule: normalizePath(fsRoute.notFoundModule) } : {}),
85
86
  };
86
87
  return pageRoute;
87
88
  }
@@ -241,6 +241,7 @@ export class FSScanner {
241
241
  const layoutMap = new Map<string, ScannedFile>();
242
242
  const loadingMap = new Map<string, ScannedFile>();
243
243
  const errorMap = new Map<string, ScannedFile>();
244
+ const notFoundMap = new Map<string, ScannedFile>();
244
245
  const islandMap = new Map<string, ScannedFile[]>();
245
246
  const routeFiles: ScannedFile[] = [];
246
247
  const metadataFiles: ScannedFile[] = [];
@@ -258,6 +259,9 @@ export class FSScanner {
258
259
  case "error":
259
260
  errorMap.set(dirPath, file);
260
261
  break;
262
+ case "not-found":
263
+ notFoundMap.set(dirPath, file);
264
+ break;
261
265
  case "island": {
262
266
  const existing = islandMap.get(dirPath);
263
267
  if (existing) {
@@ -392,9 +396,13 @@ export class FSScanner {
392
396
  }
393
397
  }
394
398
 
395
- // 로딩/에러 모듈 찾기
399
+ // 로딩/에러/404 모듈 찾기 — nearest-ancestor resolution.
400
+ // Phase 18.β: `not-found.tsx` joins `loading.tsx`/`error.tsx` in
401
+ // walking up the segment tree so a deeply nested route inherits
402
+ // its parent's 404 UI unless it declares its own.
396
403
  const loadingModule = this.findClosestSpecialFile(file.segments, loadingMap);
397
404
  const errorModule = this.findClosestSpecialFile(file.segments, errorMap);
405
+ const notFoundModule = this.findClosestSpecialFile(file.segments, notFoundMap);
398
406
 
399
407
  const route: FSRouteConfig = {
400
408
  id: routeId,
@@ -407,6 +415,7 @@ export class FSScanner {
407
415
  layoutChain,
408
416
  loadingModule,
409
417
  errorModule,
418
+ notFoundModule,
410
419
  sourceFile: file.absolutePath,
411
420
  };
412
421
 
@@ -129,6 +129,14 @@ export interface FSRouteConfig {
129
129
  /** 에러 UI 모듈 경로 */
130
130
  errorModule?: string;
131
131
 
132
+ /**
133
+ * 404 UI 모듈 경로. Nearest ancestor `not-found.tsx` resolved via
134
+ * `findClosestSpecialFile` at scan time. Used at runtime when a page
135
+ * loader calls `notFound()` or — for unmatched URLs — when the root
136
+ * route carries one. Phase 18.β — Next.js App Router parity.
137
+ */
138
+ notFoundModule?: string;
139
+
132
140
  /** Hydration 설정 */
133
141
  hydration?: HydrationConfig;
134
142