@mandujs/core 0.28.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.
- package/package.json +1 -1
- package/src/bundler/generate-static-params.ts +290 -0
- package/src/bundler/prerender.ts +242 -69
- package/src/client/hydrate.ts +340 -0
- package/src/client/index.ts +11 -0
- package/src/config/mandu.ts +40 -0
- package/src/config/validate.ts +36 -0
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -0
- package/src/dev-error-overlay/index.ts +30 -0
- package/src/dev-error-overlay/overlay-client.ts +300 -0
- package/src/dev-error-overlay/overlay-injector.ts +243 -0
- package/src/dev-error-overlay/overlay-styles.ts +52 -0
- package/src/dev-error-overlay/types.ts +66 -0
- package/src/middleware/bridge.ts +147 -0
- package/src/middleware/compose.ts +134 -0
- package/src/middleware/define.ts +132 -0
- package/src/middleware/index.ts +76 -50
- package/src/router/fs-patterns.test.ts +96 -0
- package/src/router/fs-routes.ts +1 -0
- package/src/router/fs-scanner.ts +10 -1
- package/src/router/fs-types.ts +8 -0
- package/src/runtime/server.ts +310 -10
- package/src/runtime/ssr.ts +70 -2
- package/src/spec/schema.ts +6 -0
|
@@ -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
|
+
}
|
package/src/middleware/index.ts
CHANGED
|
@@ -1,50 +1,76 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu Middleware
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
type
|
|
23
|
-
type
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} from "./
|
|
37
|
-
export {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
type
|
|
49
|
-
type
|
|
50
|
-
|
|
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
|
+
});
|
package/src/router/fs-routes.ts
CHANGED
|
@@ -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
|
}
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -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
|
|
package/src/router/fs-types.ts
CHANGED
|
@@ -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
|
|