@rexeus/typeweaver-server 0.5.1
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.md +393 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +4 -0
- package/dist/index.cjs +110 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +101 -0
- package/dist/lib/Errors.ts +38 -0
- package/dist/lib/FetchApiAdapter.ts +362 -0
- package/dist/lib/Middleware.ts +71 -0
- package/dist/lib/NodeAdapter.ts +121 -0
- package/dist/lib/PathMatcher.ts +56 -0
- package/dist/lib/RequestHandler.ts +35 -0
- package/dist/lib/Router.ts +263 -0
- package/dist/lib/ServerContext.ts +54 -0
- package/dist/lib/StateMap.ts +57 -0
- package/dist/lib/TypedMiddleware.ts +140 -0
- package/dist/lib/TypeweaverApp.ts +376 -0
- package/dist/lib/TypeweaverRouter.ts +138 -0
- package/dist/lib/index.ts +38 -0
- package/dist/metafile-cjs.json +1 -0
- package/dist/metafile-esm.json +1 -0
- package/dist/templates/Router.ejs +43 -0
- package/package.json +71 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was automatically generated by typeweaver.
|
|
3
|
+
* DO NOT EDIT. Instead, modify the source definition file and generate again.
|
|
4
|
+
*
|
|
5
|
+
* @generated by @rexeus/typeweaver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IHttpResponse } from "@rexeus/typeweaver-core";
|
|
9
|
+
import type { ServerContext } from "./ServerContext";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A middleware function that processes requests in the pipeline.
|
|
13
|
+
*
|
|
14
|
+
* Follows a return-based onion model: call `next()` to pass control to the next
|
|
15
|
+
* middleware or handler and receive the response, then return it (optionally modified).
|
|
16
|
+
*
|
|
17
|
+
* When middleware provides state, it passes the state object to `next(state)`.
|
|
18
|
+
* The pipeline merges this state into `ctx.state` before continuing to the
|
|
19
|
+
* next middleware, guaranteeing downstream consumers can read it.
|
|
20
|
+
*
|
|
21
|
+
* To short-circuit the pipeline, return a response without calling `next()`.
|
|
22
|
+
*/
|
|
23
|
+
export type Middleware = (
|
|
24
|
+
ctx: ServerContext,
|
|
25
|
+
next: (state?: Record<string, unknown>) => Promise<IHttpResponse>
|
|
26
|
+
) => Promise<IHttpResponse>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Executes a middleware pipeline in onion order (return-based).
|
|
30
|
+
*
|
|
31
|
+
* Each middleware receives a `next()` function that optionally accepts a state
|
|
32
|
+
* object. When state is provided, it is merged into `ctx.state` before invoking
|
|
33
|
+
* the next middleware in the chain. The final `next()` calls the provided
|
|
34
|
+
* `finalHandler`.
|
|
35
|
+
*
|
|
36
|
+
* @param middlewares - Ordered list of middleware functions to execute
|
|
37
|
+
* @param ctx - The server context shared across the pipeline
|
|
38
|
+
* @param finalHandler - The handler to execute after all middleware
|
|
39
|
+
* @returns The response produced by the pipeline
|
|
40
|
+
*/
|
|
41
|
+
export async function executeMiddlewarePipeline(
|
|
42
|
+
middlewares: Middleware[],
|
|
43
|
+
ctx: ServerContext,
|
|
44
|
+
finalHandler: () => Promise<IHttpResponse>
|
|
45
|
+
): Promise<IHttpResponse> {
|
|
46
|
+
let index = 0;
|
|
47
|
+
|
|
48
|
+
const advance = async (): Promise<IHttpResponse> => {
|
|
49
|
+
if (index < middlewares.length) {
|
|
50
|
+
const currentIndex = index++;
|
|
51
|
+
let called = false;
|
|
52
|
+
|
|
53
|
+
return middlewares[currentIndex]!(ctx, async state => {
|
|
54
|
+
if (called) {
|
|
55
|
+
throw new Error("next() called multiple times");
|
|
56
|
+
}
|
|
57
|
+
called = true;
|
|
58
|
+
|
|
59
|
+
if (state) {
|
|
60
|
+
ctx.state.merge(state);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return advance();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return finalHandler();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return advance();
|
|
71
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was automatically generated by typeweaver.
|
|
3
|
+
* DO NOT EDIT. Instead, modify the source definition file and generate again.
|
|
4
|
+
*
|
|
5
|
+
* @generated by @rexeus/typeweaver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PayloadTooLargeError } from "./Errors";
|
|
9
|
+
import type { TypeweaverApp } from "./TypeweaverApp";
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapts a `TypeweaverApp` to Node.js `http.createServer`.
|
|
14
|
+
*
|
|
15
|
+
* Converts `IncomingMessage` to a Fetch API `Request`, calls `app.fetch()`,
|
|
16
|
+
* and writes the `Response` back to `ServerResponse`.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { createServer } from "node:http";
|
|
21
|
+
* import { nodeAdapter } from "./generated/lib/server";
|
|
22
|
+
* import app from "./server";
|
|
23
|
+
*
|
|
24
|
+
* createServer(nodeAdapter(app)).listen(3000);
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_MAX_BODY_SIZE = 1_048_576; // 1 MB
|
|
28
|
+
|
|
29
|
+
export type NodeAdapterOptions = {
|
|
30
|
+
readonly maxBodySize?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function nodeAdapter(
|
|
34
|
+
app: TypeweaverApp<any>,
|
|
35
|
+
options?: NodeAdapterOptions
|
|
36
|
+
): (req: IncomingMessage, res: ServerResponse) => void {
|
|
37
|
+
const maxBodySize = options?.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
|
|
38
|
+
return (req, res) => {
|
|
39
|
+
void handleRequest(app, req, res, maxBodySize);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function handleRequest(
|
|
44
|
+
app: TypeweaverApp<any>,
|
|
45
|
+
req: IncomingMessage,
|
|
46
|
+
res: ServerResponse,
|
|
47
|
+
maxBodySize: number
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
51
|
+
const isBodyless = req.method === "GET" || req.method === "HEAD";
|
|
52
|
+
const body = isBodyless ? undefined : await collectBody(req, maxBodySize);
|
|
53
|
+
|
|
54
|
+
const request = new Request(url, {
|
|
55
|
+
method: req.method,
|
|
56
|
+
headers: req.headers as Record<string, string>,
|
|
57
|
+
body,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const response = await app.fetch(request);
|
|
61
|
+
|
|
62
|
+
response.headers.forEach((value, key) => {
|
|
63
|
+
if (key.toLowerCase() !== "set-cookie") {
|
|
64
|
+
res.setHeader(key, value);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const cookies = response.headers.getSetCookie();
|
|
68
|
+
if (cookies.length > 0) {
|
|
69
|
+
res.setHeader("set-cookie", cookies);
|
|
70
|
+
}
|
|
71
|
+
res.writeHead(response.status);
|
|
72
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof PayloadTooLargeError) {
|
|
75
|
+
if (!res.headersSent) {
|
|
76
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
77
|
+
}
|
|
78
|
+
res.end(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
81
|
+
message: "Request body exceeds the size limit",
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.error(error);
|
|
88
|
+
if (!res.headersSent) {
|
|
89
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
90
|
+
}
|
|
91
|
+
res.end(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
94
|
+
message: "An unexpected error occurred",
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectBody(
|
|
101
|
+
req: IncomingMessage,
|
|
102
|
+
maxBodySize: number
|
|
103
|
+
): Promise<Buffer> {
|
|
104
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
105
|
+
const chunks: Buffer[] = [];
|
|
106
|
+
let totalBytes = 0;
|
|
107
|
+
|
|
108
|
+
req.on("data", (chunk: Buffer) => {
|
|
109
|
+
totalBytes += chunk.byteLength;
|
|
110
|
+
if (totalBytes > maxBodySize) {
|
|
111
|
+
req.destroy();
|
|
112
|
+
reject(new PayloadTooLargeError(totalBytes, maxBodySize));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
chunks.push(chunk);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
req.on("end", () => resolve(Buffer.concat(chunks, totalBytes)));
|
|
119
|
+
req.on("error", reject);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was automatically generated by typeweaver.
|
|
3
|
+
* DO NOT EDIT. Instead, modify the source definition file and generate again.
|
|
4
|
+
*
|
|
5
|
+
* @generated by @rexeus/typeweaver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a predicate that tests whether a request path matches a pattern.
|
|
10
|
+
*
|
|
11
|
+
* Supports three pattern types:
|
|
12
|
+
* - **Exact match**: `"/users"` matches only `"/users"`
|
|
13
|
+
* - **Prefix match**: `"/users/*"` matches `"/users"` and any path beneath it
|
|
14
|
+
* (e.g. `"/users/123"`, `"/users/123/posts"`)
|
|
15
|
+
* - **Parameterized segments**: `"/users/:id"` matches paths where `:id` stands
|
|
16
|
+
* for exactly one segment (e.g. `"/users/123"` but not `"/users/123/posts"`)
|
|
17
|
+
*
|
|
18
|
+
* Uses the same `:paramName` syntax as typeweaver route definitions.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const isUsersPath = pathMatcher("/users/*");
|
|
23
|
+
* const isUserDetail = pathMatcher("/users/:id");
|
|
24
|
+
*
|
|
25
|
+
* const usersGuard = defineMiddleware(async (ctx, next) => {
|
|
26
|
+
* if (!isUsersPath(ctx.request.path)) return next();
|
|
27
|
+
* // guard logic...
|
|
28
|
+
* return next();
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function pathMatcher(pattern: string): (path: string) => boolean {
|
|
33
|
+
if (pattern.endsWith("/*")) {
|
|
34
|
+
const prefix = pattern.slice(0, -2);
|
|
35
|
+
return path => path === prefix || path.startsWith(prefix + "/");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const segments = pattern.split("/");
|
|
39
|
+
const hasParams = segments.some(s => s.startsWith(":"));
|
|
40
|
+
|
|
41
|
+
if (!hasParams) {
|
|
42
|
+
return path => path === pattern;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const segmentCount = segments.length;
|
|
46
|
+
const matchers = segments.map(s => (s.startsWith(":") ? null : s));
|
|
47
|
+
|
|
48
|
+
return path => {
|
|
49
|
+
const parts = path.split("/");
|
|
50
|
+
if (parts.length !== segmentCount) return false;
|
|
51
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
52
|
+
if (matchers[i] !== null && matchers[i] !== parts[i]) return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was automatically generated by typeweaver.
|
|
3
|
+
* DO NOT EDIT. Instead, modify the source definition file and generate again.
|
|
4
|
+
*
|
|
5
|
+
* @generated by @rexeus/typeweaver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IHttpRequest, IHttpResponse } from "@rexeus/typeweaver-core";
|
|
9
|
+
import type { ServerContext } from "./ServerContext";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A type-safe request handler function.
|
|
13
|
+
*
|
|
14
|
+
* Receives an `IHttpRequest` (validated when `validateRequests` is enabled)
|
|
15
|
+
* and the `ServerContext`, and returns a typed `IHttpResponse`. No framework-specific
|
|
16
|
+
* types — everything is in typeweaver's native format.
|
|
17
|
+
*
|
|
18
|
+
* @template TRequest - The specific request type (e.g., `ICreateTodoRequest`)
|
|
19
|
+
* @template TResponse - The specific response type (e.g., `CreateTodoResponse`)
|
|
20
|
+
* @template TState - The state shape available in the server context
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const handler: RequestHandler<ICreateTodoRequest, CreateTodoResponse> =
|
|
25
|
+
* async (request, ctx) => ({
|
|
26
|
+
* statusCode: 201,
|
|
27
|
+
* body: { id: "todo_1", title: request.body.title },
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export type RequestHandler<
|
|
32
|
+
TRequest extends IHttpRequest = IHttpRequest,
|
|
33
|
+
TResponse extends IHttpResponse = IHttpResponse,
|
|
34
|
+
TState extends Record<string, unknown> = Record<string, unknown>,
|
|
35
|
+
> = (request: TRequest, ctx: ServerContext<TState>) => Promise<TResponse>;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was automatically generated by typeweaver.
|
|
3
|
+
* DO NOT EDIT. Instead, modify the source definition file and generate again.
|
|
4
|
+
*
|
|
5
|
+
* @generated by @rexeus/typeweaver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
HttpMethod,
|
|
10
|
+
IHttpResponse,
|
|
11
|
+
IRequestValidator,
|
|
12
|
+
RequestValidationError,
|
|
13
|
+
} from "@rexeus/typeweaver-core";
|
|
14
|
+
import type { RequestHandler } from "./RequestHandler";
|
|
15
|
+
import type { ServerContext } from "./ServerContext";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A registered route with its method, path pattern, validator, and handler.
|
|
19
|
+
*/
|
|
20
|
+
export type RouteDefinition = {
|
|
21
|
+
readonly method: HttpMethod;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly validator: IRequestValidator;
|
|
24
|
+
readonly handler: RequestHandler;
|
|
25
|
+
/** Reference to the router config for error handling. */
|
|
26
|
+
readonly routerConfig: RouterErrorConfig;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Error handling configuration associated with a router.
|
|
31
|
+
*/
|
|
32
|
+
export type RouterErrorConfig = {
|
|
33
|
+
readonly validateRequests: boolean;
|
|
34
|
+
readonly handleHttpResponseErrors: HttpResponseErrorHandler | boolean;
|
|
35
|
+
readonly handleValidationErrors: ValidationErrorHandler | boolean;
|
|
36
|
+
readonly handleUnknownErrors: UnknownErrorHandler | boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handles HTTP response errors thrown by request handlers.
|
|
41
|
+
* The error parameter is an `HttpResponse` instance (thrown via `throw new HttpResponse(...)`).
|
|
42
|
+
*/
|
|
43
|
+
export type HttpResponseErrorHandler = (
|
|
44
|
+
error: IHttpResponse,
|
|
45
|
+
ctx: ServerContext
|
|
46
|
+
) => Promise<IHttpResponse> | IHttpResponse;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Handles request validation errors.
|
|
50
|
+
*/
|
|
51
|
+
export type ValidationErrorHandler = (
|
|
52
|
+
error: RequestValidationError,
|
|
53
|
+
ctx: ServerContext
|
|
54
|
+
) => Promise<IHttpResponse> | IHttpResponse;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handles any unknown errors not caught by other handlers.
|
|
58
|
+
*/
|
|
59
|
+
export type UnknownErrorHandler = (
|
|
60
|
+
error: unknown,
|
|
61
|
+
ctx: ServerContext
|
|
62
|
+
) => Promise<IHttpResponse> | IHttpResponse;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Result of a successful route match.
|
|
66
|
+
*/
|
|
67
|
+
export type RouteMatch = {
|
|
68
|
+
readonly route: RouteDefinition;
|
|
69
|
+
readonly params: Record<string, string>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A node in the radix tree.
|
|
74
|
+
*
|
|
75
|
+
* Each node represents a single path segment.
|
|
76
|
+
* Static children are stored in a `Map` keyed by segment string.
|
|
77
|
+
* A single `paramChild` holds the branch for `:param` segments.
|
|
78
|
+
* Leaf nodes store a `Map` of HTTP method → route definition.
|
|
79
|
+
*/
|
|
80
|
+
type RadixNode = {
|
|
81
|
+
readonly staticChildren: Map<string, RadixNode>;
|
|
82
|
+
paramChild: { readonly name: string; readonly node: RadixNode } | undefined;
|
|
83
|
+
readonly methods: Map<string, RouteDefinition>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* High-performance radix tree router with path parameter support.
|
|
88
|
+
*
|
|
89
|
+
* Routes are stored in a tree structure where each level corresponds
|
|
90
|
+
* to a path segment. This gives O(d) lookup time where d is the depth
|
|
91
|
+
* (number of segments) of the URL — independent of the total number
|
|
92
|
+
* of registered routes.
|
|
93
|
+
*
|
|
94
|
+
* Supports:
|
|
95
|
+
* - Static paths: `/accounts`
|
|
96
|
+
* - Named parameters: `/todos/:todoId`
|
|
97
|
+
* - Multiple parameters: `/todos/:todoId/subtodos/:subtodoId`
|
|
98
|
+
* - Automatic HEAD → GET fallback (per HTTP spec)
|
|
99
|
+
* - 405 Method Not Allowed detection with `Allow` header
|
|
100
|
+
*/
|
|
101
|
+
export class Router {
|
|
102
|
+
private readonly root: RadixNode = Router.createNode();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Register a route in the radix tree.
|
|
106
|
+
*/
|
|
107
|
+
public add(definition: RouteDefinition): void {
|
|
108
|
+
const segments = Router.toSegments(definition.path);
|
|
109
|
+
|
|
110
|
+
let current = this.root;
|
|
111
|
+
|
|
112
|
+
for (const segment of segments) {
|
|
113
|
+
if (segment.startsWith(":")) {
|
|
114
|
+
const paramName = segment.slice(1);
|
|
115
|
+
if (current.paramChild && current.paramChild.name !== paramName) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Conflicting path parameter names at "${definition.path}": ":${current.paramChild.name}" vs ":${paramName}"`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!current.paramChild) {
|
|
121
|
+
current.paramChild = { name: paramName, node: Router.createNode() };
|
|
122
|
+
}
|
|
123
|
+
current = current.paramChild.node;
|
|
124
|
+
} else {
|
|
125
|
+
let child = current.staticChildren.get(segment);
|
|
126
|
+
if (!child) {
|
|
127
|
+
child = Router.createNode();
|
|
128
|
+
current.staticChildren.set(segment, child);
|
|
129
|
+
}
|
|
130
|
+
current = child;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (current.methods.has(definition.method)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Route conflict: ${definition.method} ${definition.path} is already registered`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
current.methods.set(definition.method, definition);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Find a matching route for the given method and path.
|
|
145
|
+
*
|
|
146
|
+
* Traverses the radix tree in O(d) time where d is the number
|
|
147
|
+
* of path segments. Static segments are matched first (exact match),
|
|
148
|
+
* then parameterized segments are tried as fallback.
|
|
149
|
+
*
|
|
150
|
+
* For HEAD requests, automatically falls back to the GET handler
|
|
151
|
+
* if no explicit HEAD handler is registered (per HTTP spec).
|
|
152
|
+
*
|
|
153
|
+
* @returns The matched route with extracted path parameters, or `undefined` if no match.
|
|
154
|
+
*/
|
|
155
|
+
public match(method: string, path: string): RouteMatch | undefined {
|
|
156
|
+
const upperMethod = method.toUpperCase();
|
|
157
|
+
const segments = Router.toSegments(path);
|
|
158
|
+
const params: Record<string, string> = {};
|
|
159
|
+
|
|
160
|
+
const node = this.traverse(this.root, segments, 0, params);
|
|
161
|
+
if (!node) return undefined;
|
|
162
|
+
|
|
163
|
+
const definition =
|
|
164
|
+
node.methods.get(upperMethod) ??
|
|
165
|
+
(upperMethod === "HEAD" ? node.methods.get("GET") : undefined);
|
|
166
|
+
|
|
167
|
+
if (!definition) return undefined;
|
|
168
|
+
|
|
169
|
+
return { route: definition, params };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Find a matching node for the given path, regardless of HTTP method.
|
|
174
|
+
*
|
|
175
|
+
* Used to distinguish "path not found" (404) from "method not allowed" (405).
|
|
176
|
+
*
|
|
177
|
+
* @returns The allowed methods for this path, or `undefined` if the path doesn't exist.
|
|
178
|
+
*/
|
|
179
|
+
public matchPath(path: string): { allowedMethods: string[] } | undefined {
|
|
180
|
+
const segments = Router.toSegments(path);
|
|
181
|
+
const params: Record<string, string> = {};
|
|
182
|
+
|
|
183
|
+
const node = this.traverse(this.root, segments, 0, params);
|
|
184
|
+
if (!node || node.methods.size === 0) return undefined;
|
|
185
|
+
|
|
186
|
+
const methods = Array.from(node.methods.keys());
|
|
187
|
+
if (methods.includes("GET") && !methods.includes("HEAD")) {
|
|
188
|
+
methods.push("HEAD");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { allowedMethods: methods.sort() };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Recursively traverse the radix tree to find a matching node.
|
|
196
|
+
*
|
|
197
|
+
* Prioritises static children over param children for correct
|
|
198
|
+
* and predictable matching behaviour.
|
|
199
|
+
*
|
|
200
|
+
* Path parameters are URL-decoded during extraction.
|
|
201
|
+
*/
|
|
202
|
+
private traverse(
|
|
203
|
+
node: RadixNode,
|
|
204
|
+
segments: string[],
|
|
205
|
+
index: number,
|
|
206
|
+
params: Record<string, string>
|
|
207
|
+
): RadixNode | undefined {
|
|
208
|
+
// All segments consumed — this node is the match candidate
|
|
209
|
+
if (index === segments.length) {
|
|
210
|
+
return node;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const segment = segments[index]!;
|
|
214
|
+
|
|
215
|
+
// 1. Try static child first (higher priority)
|
|
216
|
+
const staticChild = node.staticChildren.get(segment);
|
|
217
|
+
if (staticChild) {
|
|
218
|
+
const result = this.traverse(staticChild, segments, index + 1, params);
|
|
219
|
+
if (result && result.methods.size > 0) return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 2. Try param child as fallback
|
|
223
|
+
if (node.paramChild) {
|
|
224
|
+
const { name, node: paramNode } = node.paramChild;
|
|
225
|
+
params[name] = Router.decodePathSegment(segment);
|
|
226
|
+
const result = this.traverse(paramNode, segments, index + 1, params);
|
|
227
|
+
if (result && result.methods.size > 0) return result;
|
|
228
|
+
// Backtrack: remove param if this branch didn't match
|
|
229
|
+
delete params[name];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private static createNode(): RadixNode {
|
|
236
|
+
return {
|
|
237
|
+
staticChildren: new Map(),
|
|
238
|
+
paramChild: undefined,
|
|
239
|
+
methods: new Map(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Decodes a URL-encoded path segment while guarding against path traversal.
|
|
245
|
+
*
|
|
246
|
+
* Encoded dot-segments like `%2e%2e` would decode to `..`, which could
|
|
247
|
+
* enable directory traversal if a downstream handler builds file paths
|
|
248
|
+
* from params. Returning the raw segment neutralises this vector.
|
|
249
|
+
*/
|
|
250
|
+
private static decodePathSegment(segment: string): string {
|
|
251
|
+
try {
|
|
252
|
+
const decoded = decodeURIComponent(segment);
|
|
253
|
+
if (decoded === ".." || decoded === ".") return segment;
|
|
254
|
+
return decoded;
|
|
255
|
+
} catch {
|
|
256
|
+
return segment;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private static toSegments(path: string): string[] {
|
|
261
|
+
return path.split("/").filter(s => s.length > 0);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was automatically generated by typeweaver.
|
|
3
|
+
* DO NOT EDIT. Instead, modify the source definition file and generate again.
|
|
4
|
+
*
|
|
5
|
+
* @generated by @rexeus/typeweaver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IHttpRequest } from "@rexeus/typeweaver-core";
|
|
9
|
+
import type { StateMap } from "./StateMap";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Context object passed through the middleware pipeline and to request handlers.
|
|
13
|
+
*
|
|
14
|
+
* The context carries the current request and a typed state map
|
|
15
|
+
* that middleware can use to pass data downstream to handlers.
|
|
16
|
+
*
|
|
17
|
+
* When parameterized with a state shape, provides compile-time type safety
|
|
18
|
+
* for state access. Without an explicit type parameter, accepts any key/value
|
|
19
|
+
* (backward compatible).
|
|
20
|
+
*
|
|
21
|
+
* All data is in typeweaver's native `IHttpRequest`/`IHttpResponse` format —
|
|
22
|
+
* no framework-specific types leak into middleware or handlers.
|
|
23
|
+
*
|
|
24
|
+
* Responses are returned, not mutated on the context (return-based middleware).
|
|
25
|
+
*
|
|
26
|
+
* @template TState - The accumulated state shape from middleware
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* // Typed state — no casts needed
|
|
31
|
+
* type AppState = { userId: string };
|
|
32
|
+
*
|
|
33
|
+
* // In middleware: set state (typed)
|
|
34
|
+
* app.use(async (ctx, next) => {
|
|
35
|
+
* ctx.state.set("userId", "user_123");
|
|
36
|
+
* return next();
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* // In handler: read state (typed)
|
|
40
|
+
* handleCreateTodo: async (request, ctx: ServerContext<AppState>) => {
|
|
41
|
+
* const userId = ctx.state.get("userId"); // string | undefined
|
|
42
|
+
* return { statusCode: 201, body: { id: "1", title: "..." } };
|
|
43
|
+
* };
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export type ServerContext<
|
|
47
|
+
TState extends Record<string, unknown> = Record<string, unknown>,
|
|
48
|
+
> = {
|
|
49
|
+
/** The incoming HTTP request in typeweaver format. */
|
|
50
|
+
readonly request: IHttpRequest;
|
|
51
|
+
|
|
52
|
+
/** Type-safe key-value store for sharing data between middleware and handlers. */
|
|
53
|
+
readonly state: StateMap<TState>;
|
|
54
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was automatically generated by typeweaver.
|
|
3
|
+
* DO NOT EDIT. Instead, modify the source definition file and generate again.
|
|
4
|
+
*
|
|
5
|
+
* @generated by @rexeus/typeweaver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A type-safe wrapper around `Map<string, unknown>` for middleware state.
|
|
10
|
+
*
|
|
11
|
+
* When parameterized with a specific state shape, provides compile-time
|
|
12
|
+
* type safety for `get`/`set`/`has` operations. When using the default
|
|
13
|
+
* `Record<string, unknown>`, behaves identically to `Map<string, unknown>`.
|
|
14
|
+
*
|
|
15
|
+
* The API surface is intentionally narrow (`get`, `set`, `has`, `merge` only).
|
|
16
|
+
* Generic `Map` methods like `forEach`, `entries`, or `delete` are excluded
|
|
17
|
+
* to prevent bypassing type safety.
|
|
18
|
+
*
|
|
19
|
+
* @template TState - The shape of state this map carries
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* // Typed — constrains keys and values
|
|
24
|
+
* type MyState = { userId: string; role: "admin" | "user" };
|
|
25
|
+
* const typed = new StateMap<MyState>();
|
|
26
|
+
* typed.set("userId", "u_123"); // ✓
|
|
27
|
+
* typed.set("userId", 123); // ✗ compile error
|
|
28
|
+
* typed.get("userId"); // string (guaranteed by middleware pipeline)
|
|
29
|
+
* typed.get("nonexistent"); // ✗ compile error
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export class StateMap<
|
|
33
|
+
TState extends Record<string, unknown> = Record<string, unknown>,
|
|
34
|
+
> {
|
|
35
|
+
private readonly map = new Map<string, unknown>();
|
|
36
|
+
|
|
37
|
+
public set<K extends string & keyof TState>(key: K, value: TState[K]): void {
|
|
38
|
+
this.map.set(key, value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public get<K extends string & keyof TState>(key: K): TState[K] {
|
|
42
|
+
return this.map.get(key) as TState[K];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public has<K extends string & keyof TState>(key: K): boolean {
|
|
46
|
+
return this.map.has(key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public merge(state: Record<string, unknown>): void {
|
|
50
|
+
for (const [key, value] of Object.entries(state)) {
|
|
51
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
this.map.set(key, value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|