@rexeus/typeweaver-server 0.10.3 → 0.10.5
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 +11 -5
- package/dist/index.cjs +2 -0
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/dist/lib/BodyLimitPolicy.ts +8 -1
- package/dist/lib/FetchApiAdapter.ts +5 -1
- package/dist/lib/Middleware.ts +2 -1
- package/dist/lib/NodeAdapter.ts +332 -16
- package/dist/lib/PathMatcher.ts +54 -10
- package/dist/lib/Router.ts +24 -8
- package/dist/lib/TypeweaverApp.ts +30 -13
- package/dist/lib/errors/ConflictingPathParameterNameError.ts +20 -0
- package/dist/lib/errors/DuplicateRouteRegistrationError.ts +19 -0
- package/dist/lib/errors/MiddlewareNextAlreadyCalledError.ts +16 -0
- package/dist/lib/errors/MissingRouterForPrefixedMountError.ts +16 -0
- package/dist/lib/errors/RequestBodyClosedBeforeEndError.ts +19 -0
- package/dist/lib/errors/RequestBodyReadAbortedError.ts +19 -0
- package/dist/lib/errors/index.ts +19 -0
- package/dist/lib/middleware/basicAuth.ts +11 -2
- package/dist/lib/middleware/bearerAuth.ts +11 -2
- package/dist/lib/middleware/cors.ts +236 -48
- package/dist/lib/middleware/header.ts +59 -0
- package/dist/lib/middleware/logger.ts +4 -2
- package/dist/lib/middleware/poweredBy.ts +6 -1
- package/dist/lib/middleware/requestId.ts +8 -8
- package/dist/lib/middleware/scoped.ts +37 -12
- package/dist/lib/middleware/secureHeaders.ts +3 -1
- package/dist/templates/Router.ejs +2 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -294,10 +294,10 @@ Each middleware is documented in detail — click the links above.
|
|
|
294
294
|
|
|
295
295
|
`TypeweaverApp` accepts an optional options object:
|
|
296
296
|
|
|
297
|
-
| Option | Type | Default | Description
|
|
298
|
-
| ------------- | -------------------------- | ------------------ |
|
|
299
|
-
| `maxBodySize` | `number` | `1_048_576` (1 MB) | Max request body size in bytes. Exceeding returns `413`.
|
|
300
|
-
| `onError` | `(error: unknown) => void` | `console.error` |
|
|
297
|
+
| Option | Type | Default | Description |
|
|
298
|
+
| ------------- | -------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
299
|
+
| `maxBodySize` | `number` | `1_048_576` (1 MB) | Max request body size in bytes. Exceeding returns `413`. |
|
|
300
|
+
| `onError` | `(error: unknown) => void` | `console.error` | Reports errors from the default unknown handler and top-level safety net. Falls back to `console.error` if it throws. |
|
|
301
301
|
|
|
302
302
|
```ts
|
|
303
303
|
const app = new TypeweaverApp({
|
|
@@ -325,7 +325,7 @@ errors fall through to the next handler in the chain (except `handleResponseVali
|
|
|
325
325
|
`false` means the invalid response is returned as-is — validation still runs for field stripping,
|
|
326
326
|
but invalid responses pass through unchanged). When set to a function, it receives the error and
|
|
327
327
|
`ServerContext` and must return an `IHttpResponse`. If a custom error handler throws, the framework
|
|
328
|
-
|
|
328
|
+
reports that handler failure through `onError` and falls through gracefully to the next handler.
|
|
329
329
|
|
|
330
330
|
### 🚨 Error Handling
|
|
331
331
|
|
|
@@ -431,6 +431,12 @@ new UserRouter({
|
|
|
431
431
|
});
|
|
432
432
|
```
|
|
433
433
|
|
|
434
|
+
The default unknown-error handler calls `onError` before returning a sanitized 500 response. A
|
|
435
|
+
custom `handleUnknownErrors` function replaces that default reporting strategy; if you need logging
|
|
436
|
+
or metrics with a custom unknown handler, perform that reporting inside the custom handler. If the
|
|
437
|
+
custom unknown handler throws, Typeweaver reports the handler failure through `onError` and then
|
|
438
|
+
falls through to the top-level safety net.
|
|
439
|
+
|
|
434
440
|
### 📋 Error Responses
|
|
435
441
|
|
|
436
442
|
| Status | Code | When |
|
package/dist/index.cjs
CHANGED
|
@@ -61,10 +61,12 @@ function writeRouter(resource, templateFile, context) {
|
|
|
61
61
|
function createOperationData(operation) {
|
|
62
62
|
const operationId = operation.operationId;
|
|
63
63
|
const className = (0, polycase.pascalCase)(operationId);
|
|
64
|
+
const jsDoc = (0, _rexeus_typeweaver_gen.createJSDocComment)(operation.summary, { indentation: " " });
|
|
64
65
|
return {
|
|
65
66
|
operationId,
|
|
66
67
|
className,
|
|
67
68
|
handlerName: `handle${className}Request`,
|
|
69
|
+
...jsDoc ? { jsDoc } : {},
|
|
68
70
|
method: operation.method,
|
|
69
71
|
path: operation.path
|
|
70
72
|
};
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { BasePlugin, compareRoutes, relative } from "@rexeus/typeweaver-gen";
|
|
3
|
+
import { BasePlugin, compareRoutes, createJSDocComment, relative } from "@rexeus/typeweaver-gen";
|
|
4
4
|
import { HttpMethod } from "@rexeus/typeweaver-core";
|
|
5
5
|
import { pascalCase } from "polycase";
|
|
6
6
|
//#region src/routerGenerator.ts
|
|
@@ -37,10 +37,12 @@ function writeRouter(resource, templateFile, context) {
|
|
|
37
37
|
function createOperationData(operation) {
|
|
38
38
|
const operationId = operation.operationId;
|
|
39
39
|
const className = pascalCase(operationId);
|
|
40
|
+
const jsDoc = createJSDocComment(operation.summary, { indentation: " " });
|
|
40
41
|
return {
|
|
41
42
|
operationId,
|
|
42
43
|
className,
|
|
43
44
|
handlerName: `handle${className}Request`,
|
|
45
|
+
...jsDoc ? { jsDoc } : {},
|
|
44
46
|
method: operation.method,
|
|
45
47
|
path: operation.path
|
|
46
48
|
};
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/routerGenerator.ts","../src/index.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { HttpMethod } from \"@rexeus/typeweaver-core\";\nimport {
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/routerGenerator.ts","../src/index.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { HttpMethod } from \"@rexeus/typeweaver-core\";\nimport {\n compareRoutes,\n createJSDocComment,\n relative,\n} from \"@rexeus/typeweaver-gen\";\nimport type {\n GeneratorContext,\n NormalizedOperation,\n NormalizedResource,\n} from \"@rexeus/typeweaver-gen\";\nimport { pascalCase } from \"polycase\";\n\nexport type RouterGenerationContext = Pick<\n GeneratorContext,\n | \"normalizedSpec\"\n | \"outputDir\"\n | \"getResourceOutputDir\"\n | \"renderTemplate\"\n | \"writeFile\"\n>;\n\ntype OperationData = {\n readonly operationId: string;\n readonly className: string;\n readonly handlerName: string;\n readonly jsDoc?: string;\n readonly method: string;\n readonly path: string;\n};\n\n/**\n * Generates TypeweaverRouter subclasses from API definitions.\n *\n * For each resource (e.g., `Todo`, `Account`), produces a `<ResourceName>Router.ts`\n * file that extends `TypeweaverRouter` and registers all operations as routes.\n */\n\n/**\n * Generates router files for all resources in the given context.\n *\n * @param context - The generator context containing resources, templates, and output configuration\n */\nexport function generate(context: RouterGenerationContext): void {\n const moduleDir = path.dirname(fileURLToPath(import.meta.url));\n const templateFile = path.join(moduleDir, \"templates\", \"Router.ejs\");\n\n for (const resource of context.normalizedSpec.resources) {\n writeRouter(resource, templateFile, context);\n }\n}\n\nfunction writeRouter(\n resource: NormalizedResource,\n templateFile: string,\n context: RouterGenerationContext\n): void {\n const pascalCaseEntityName = pascalCase(resource.name);\n const outputDir = context.getResourceOutputDir(resource.name);\n const outputPath = path.join(outputDir, `${pascalCaseEntityName}Router.ts`);\n\n const operations = resource.operations\n .filter(operation => operation.method !== HttpMethod.HEAD)\n .map(operation => createOperationData(operation))\n .sort((a, b) => compareRoutes(a, b));\n\n const content = context.renderTemplate(templateFile, {\n coreDir: relative(outputDir, context.outputDir),\n entityName: resource.name,\n pascalCaseEntityName,\n operations,\n });\n\n const relativePath = path.relative(context.outputDir, outputPath);\n context.writeFile(relativePath, content);\n}\n\nfunction createOperationData(operation: NormalizedOperation): OperationData {\n const operationId = operation.operationId;\n const className = pascalCase(operationId);\n const jsDoc = createJSDocComment(operation.summary, { indentation: \" \" });\n\n return {\n operationId,\n className,\n handlerName: `handle${className}Request`,\n ...(jsDoc ? { jsDoc } : {}),\n method: operation.method,\n path: operation.path,\n };\n}\n","import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { BasePlugin } from \"@rexeus/typeweaver-gen\";\nimport type { GeneratorContext } from \"@rexeus/typeweaver-gen\";\nimport { generate as generateRouters } from \"./routerGenerator.js\";\n\nconst moduleDir = path.dirname(fileURLToPath(import.meta.url));\n\n/**\n * Typeweaver plugin that generates a lightweight, dependency-free server\n * with built-in routing and middleware support.\n *\n * Copies the runtime library files (`TypeweaverApp`, `TypeweaverRouter`, `Router`,\n * `Middleware`, etc.) and generates typed router classes for each resource.\n */\nexport class ServerPlugin extends BasePlugin {\n public name = \"server\";\n public override depends = [\"types\"];\n\n /**\n * Generates the server runtime and typed routers for all resources.\n *\n * @param context - The generator context\n */\n public override generate(context: GeneratorContext): void {\n const libSourceDir = path.join(moduleDir, \"lib\");\n this.copyLibFiles(context, libSourceDir, this.name);\n\n generateRouters(context);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA6CA,SAAgB,SAAS,SAAwC;CAC/D,MAAM,YAAY,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC;CAC9D,MAAM,eAAe,KAAK,KAAK,WAAW,aAAa,aAAa;AAEpE,MAAK,MAAM,YAAY,QAAQ,eAAe,UAC5C,aAAY,UAAU,cAAc,QAAQ;;AAIhD,SAAS,YACP,UACA,cACA,SACM;CACN,MAAM,uBAAuB,WAAW,SAAS,KAAK;CACtD,MAAM,YAAY,QAAQ,qBAAqB,SAAS,KAAK;CAC7D,MAAM,aAAa,KAAK,KAAK,WAAW,GAAG,qBAAqB,WAAW;CAE3E,MAAM,aAAa,SAAS,WACzB,QAAO,cAAa,UAAU,WAAW,WAAW,KAAK,CACzD,KAAI,cAAa,oBAAoB,UAAU,CAAC,CAChD,MAAM,GAAG,MAAM,cAAc,GAAG,EAAE,CAAC;CAEtC,MAAM,UAAU,QAAQ,eAAe,cAAc;EACnD,SAAS,SAAS,WAAW,QAAQ,UAAU;EAC/C,YAAY,SAAS;EACrB;EACA;EACD,CAAC;CAEF,MAAM,eAAe,KAAK,SAAS,QAAQ,WAAW,WAAW;AACjE,SAAQ,UAAU,cAAc,QAAQ;;AAG1C,SAAS,oBAAoB,WAA+C;CAC1E,MAAM,cAAc,UAAU;CAC9B,MAAM,YAAY,WAAW,YAAY;CACzC,MAAM,QAAQ,mBAAmB,UAAU,SAAS,EAAE,aAAa,MAAM,CAAC;AAE1E,QAAO;EACL;EACA;EACA,aAAa,SAAS,UAAU;EAChC,GAAI,QAAQ,EAAE,OAAO,GAAG,EAAE;EAC1B,QAAQ,UAAU;EAClB,MAAM,UAAU;EACjB;;;;ACrFH,MAAM,YAAY,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC;;;;;;;;AAS9D,IAAa,eAAb,cAAkC,WAAW;CAC3C,OAAc;CACd,UAA0B,CAAC,QAAQ;;;;;;CAOnC,SAAyB,SAAiC;EACxD,MAAM,eAAe,KAAK,KAAK,WAAW,MAAM;AAChD,OAAK,aAAa,SAAS,cAAc,KAAK,KAAK;AAEnD,WAAgB,QAAQ"}
|
|
@@ -34,6 +34,8 @@ const FETCH_BODY_LIMIT_CAPABILITY: BodyLimitCapability =
|
|
|
34
34
|
const NODE_BODY_LIMIT_CAPABILITY: BodyLimitCapability =
|
|
35
35
|
"prevalidated-request-body";
|
|
36
36
|
|
|
37
|
+
const CONTENT_LENGTH_HEADER_PATTERN = /^\d+$/;
|
|
38
|
+
|
|
37
39
|
export function createFetchBodyLimitPolicy(
|
|
38
40
|
maxBodySize?: number
|
|
39
41
|
): BodyLimitPolicy {
|
|
@@ -64,7 +66,12 @@ export function parseContentLength(
|
|
|
64
66
|
return undefined;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
const
|
|
69
|
+
const trimmedValue = rawValue.trim();
|
|
70
|
+
if (!CONTENT_LENGTH_HEADER_PATTERN.test(trimmedValue)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const contentLength = Number(trimmedValue);
|
|
68
75
|
if (!Number.isFinite(contentLength) || contentLength < 0) {
|
|
69
76
|
return undefined;
|
|
70
77
|
}
|
|
@@ -326,7 +326,11 @@ export class FetchApiAdapter {
|
|
|
326
326
|
if (body instanceof Blob) return body;
|
|
327
327
|
|
|
328
328
|
try {
|
|
329
|
-
|
|
329
|
+
const serializedBody = JSON.stringify(body);
|
|
330
|
+
if (serializedBody === undefined) {
|
|
331
|
+
throw new TypeError("Response body cannot be serialized to JSON");
|
|
332
|
+
}
|
|
333
|
+
return serializedBody;
|
|
330
334
|
} catch (error) {
|
|
331
335
|
throw new ResponseSerializationError(
|
|
332
336
|
"Failed to serialize response body to JSON",
|
package/dist/lib/Middleware.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { IHttpResponse } from "@rexeus/typeweaver-core";
|
|
9
|
+
import { MiddlewareNextAlreadyCalledError } from "./errors/index.js";
|
|
9
10
|
import type { ServerContext } from "./ServerContext.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -52,7 +53,7 @@ export async function executeMiddlewarePipeline(
|
|
|
52
53
|
|
|
53
54
|
return middlewares[currentIndex]!(ctx, async state => {
|
|
54
55
|
if (called) {
|
|
55
|
-
throw new
|
|
56
|
+
throw new MiddlewareNextAlreadyCalledError(currentIndex);
|
|
56
57
|
}
|
|
57
58
|
called = true;
|
|
58
59
|
|
package/dist/lib/NodeAdapter.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
badRequestDefaultError,
|
|
9
10
|
createDefaultErrorBody,
|
|
10
11
|
internalServerErrorDefaultError,
|
|
11
12
|
payloadTooLargeDefaultError,
|
|
@@ -18,8 +19,10 @@ import {
|
|
|
18
19
|
} from "./BodyLimitPolicy.js";
|
|
19
20
|
import {
|
|
20
21
|
PayloadTooLargeError,
|
|
22
|
+
RequestBodyClosedBeforeEndError,
|
|
21
23
|
RequestBodyDrainTimeoutError,
|
|
22
|
-
|
|
24
|
+
RequestBodyReadAbortedError,
|
|
25
|
+
} from "./errors/index.js";
|
|
23
26
|
import {
|
|
24
27
|
getTypeweaverAppErrorReporter,
|
|
25
28
|
getTypeweaverAppRuntimeContext,
|
|
@@ -39,6 +42,15 @@ type DrainRequestOptions = {
|
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
const REQUEST_DRAIN_TIMEOUT_MS = 5_000;
|
|
45
|
+
const ORIGIN_FORM_BASE_URL_PROTOCOL = "http:";
|
|
46
|
+
const AUTHORITY_LIKE_REQUEST_TARGET_PREFIX = /^[\\/]{2}/;
|
|
47
|
+
const ASTERISK_FORM_REQUEST_TARGET = "*";
|
|
48
|
+
|
|
49
|
+
type ParsedAuthority = {
|
|
50
|
+
readonly host: string;
|
|
51
|
+
readonly hostname: string;
|
|
52
|
+
readonly port: string;
|
|
53
|
+
};
|
|
42
54
|
|
|
43
55
|
/**
|
|
44
56
|
* Adapts a `TypeweaverApp` to Node.js `http.createServer`.
|
|
@@ -82,12 +94,16 @@ async function handleRequest(
|
|
|
82
94
|
reportError: (error: unknown) => void
|
|
83
95
|
): Promise<void> {
|
|
84
96
|
try {
|
|
85
|
-
const url =
|
|
97
|
+
const url = createRequestUrl(req);
|
|
98
|
+
if (url === undefined) {
|
|
99
|
+
writeBadRequestResponse(req, res, bodyLimitPolicy.maxBodySize);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
86
102
|
const shouldValidateBody = shouldValidateRequestBody(req.method);
|
|
87
103
|
|
|
88
104
|
enforceContentLengthLimit(req, bodyLimitPolicy.maxBodySize);
|
|
89
105
|
|
|
90
|
-
if (!shouldValidateBody) {
|
|
106
|
+
if (!shouldValidateBody && hasReadableRequestBody(req)) {
|
|
91
107
|
const drainResult = await drainRequest(req, bodyLimitPolicy.maxBodySize, {
|
|
92
108
|
destroyOnLimitExceeded: false,
|
|
93
109
|
});
|
|
@@ -111,7 +127,7 @@ async function handleRequest(
|
|
|
111
127
|
|
|
112
128
|
const request = new Request(url, {
|
|
113
129
|
method: req.method,
|
|
114
|
-
headers: req.headers
|
|
130
|
+
headers: createRequestHeaders(req.headers),
|
|
115
131
|
body,
|
|
116
132
|
});
|
|
117
133
|
if (shouldValidateBody) {
|
|
@@ -119,6 +135,11 @@ async function handleRequest(
|
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
const response = await app.fetch(request);
|
|
138
|
+
const responseBody = await readWritableResponseBody(
|
|
139
|
+
req.method,
|
|
140
|
+
response,
|
|
141
|
+
reportError
|
|
142
|
+
);
|
|
122
143
|
|
|
123
144
|
response.headers.forEach((value, key) => {
|
|
124
145
|
if (key.toLowerCase() !== "set-cookie") {
|
|
@@ -130,20 +151,193 @@ async function handleRequest(
|
|
|
130
151
|
res.setHeader("set-cookie", cookies);
|
|
131
152
|
}
|
|
132
153
|
res.writeHead(response.status);
|
|
133
|
-
res.end(
|
|
154
|
+
res.end(responseBody);
|
|
134
155
|
} catch (error) {
|
|
135
156
|
reportError(error);
|
|
136
157
|
|
|
137
158
|
if (isRequestBodyLimitError(error)) {
|
|
138
|
-
writeDefaultErrorResponse(res, payloadTooLargeDefaultError,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
writeDefaultErrorResponse(res, payloadTooLargeDefaultError, {
|
|
160
|
+
method: req.method,
|
|
161
|
+
onFinished: () => {
|
|
162
|
+
void drainRequest(req, bodyLimitPolicy.maxBodySize, {
|
|
163
|
+
destroyOnLimitExceeded: true,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
142
166
|
});
|
|
143
167
|
return;
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
writeDefaultErrorResponse(res, internalServerErrorDefaultError
|
|
170
|
+
writeDefaultErrorResponse(res, internalServerErrorDefaultError, {
|
|
171
|
+
method: req.method,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createRequestUrl(req: IncomingMessage): URL | undefined {
|
|
177
|
+
const rawUrl = req.url ?? "/";
|
|
178
|
+
|
|
179
|
+
if (rawUrl === ASTERISK_FORM_REQUEST_TARGET) {
|
|
180
|
+
return createAsteriskFormRequestUrl(req);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (hasAuthorityLikeRequestTargetPrefix(rawUrl)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const url = new URL(rawUrl);
|
|
189
|
+
return isAbsoluteRequestHostAllowed(url, req) ? url : undefined;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (!(error instanceof TypeError) || !rawUrl.startsWith("/")) {
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
|
|
197
|
+
if (host === undefined) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return new URL(rawUrl, `${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createAsteriskFormRequestUrl(req: IncomingMessage): URL | undefined {
|
|
205
|
+
if (req.method !== "OPTIONS") {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
|
|
210
|
+
if (host === undefined) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return new URL(
|
|
215
|
+
ASTERISK_FORM_REQUEST_TARGET,
|
|
216
|
+
`${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}/`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hasAuthorityLikeRequestTargetPrefix(rawUrl: string): boolean {
|
|
221
|
+
return AUTHORITY_LIKE_REQUEST_TARGET_PREFIX.test(rawUrl);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isAbsoluteRequestHostAllowed(url: URL, req: IncomingMessage): boolean {
|
|
225
|
+
const host = parseRequestHostHeader(req, url.protocol);
|
|
226
|
+
if (host === undefined) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const urlAuthority = getUrlAuthority(url);
|
|
231
|
+
return (
|
|
232
|
+
host.hostname.toLowerCase() === urlAuthority.hostname.toLowerCase() &&
|
|
233
|
+
host.port === urlAuthority.port
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseRequestHostHeader(
|
|
238
|
+
req: IncomingMessage,
|
|
239
|
+
protocol: string
|
|
240
|
+
): ParsedAuthority | undefined {
|
|
241
|
+
if (!hasExactlyOneHostHeaderLine(req)) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return parseHostHeader(req.headers.host, protocol);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function hasExactlyOneHostHeaderLine(req: IncomingMessage): boolean {
|
|
249
|
+
const headersDistinctHostCount = getHeadersDistinctHostCount(req);
|
|
250
|
+
if (
|
|
251
|
+
headersDistinctHostCount !== undefined &&
|
|
252
|
+
headersDistinctHostCount !== 1
|
|
253
|
+
) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const rawHostHeaderCount = countRawHostHeaderLines(req.rawHeaders);
|
|
258
|
+
if (rawHostHeaderCount > 0) {
|
|
259
|
+
return rawHostHeaderCount === 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return headersDistinctHostCount === 1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getHeadersDistinctHostCount(req: IncomingMessage): number | undefined {
|
|
266
|
+
const hostHeader = req.headersDistinct?.host;
|
|
267
|
+
if (hostHeader === undefined) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return Array.isArray(hostHeader) ? hostHeader.length : 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function countRawHostHeaderLines(rawHeaders: readonly string[]): number {
|
|
275
|
+
let count = 0;
|
|
276
|
+
|
|
277
|
+
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
278
|
+
if (rawHeaders[index]?.toLowerCase() === "host") {
|
|
279
|
+
count += 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return count;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseHostHeader(
|
|
287
|
+
hostHeader: IncomingMessage["headers"]["host"],
|
|
288
|
+
protocol: string
|
|
289
|
+
): ParsedAuthority | undefined {
|
|
290
|
+
if (hostHeader === undefined || Array.isArray(hostHeader)) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const host = hostHeader.trim();
|
|
295
|
+
if (host === "" || host !== hostHeader) {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const parsed = new URL(`${protocol}//${host}`);
|
|
301
|
+
if (
|
|
302
|
+
parsed.username !== "" ||
|
|
303
|
+
parsed.password !== "" ||
|
|
304
|
+
parsed.pathname !== "/" ||
|
|
305
|
+
parsed.search !== "" ||
|
|
306
|
+
parsed.hash !== ""
|
|
307
|
+
) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return getUrlAuthority(parsed);
|
|
312
|
+
} catch {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getUrlAuthority(url: URL): ParsedAuthority {
|
|
318
|
+
return {
|
|
319
|
+
host: url.host,
|
|
320
|
+
hostname: url.hostname,
|
|
321
|
+
port: getEffectivePort(url),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getEffectivePort(url: URL): string {
|
|
326
|
+
return url.port === "" ? getDefaultPort(url.protocol) : url.port;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getDefaultPort(protocol: string): string {
|
|
330
|
+
switch (protocol) {
|
|
331
|
+
case "http:":
|
|
332
|
+
case "ws:":
|
|
333
|
+
return "80";
|
|
334
|
+
case "https:":
|
|
335
|
+
case "wss:":
|
|
336
|
+
return "443";
|
|
337
|
+
case "ftp:":
|
|
338
|
+
return "21";
|
|
339
|
+
default:
|
|
340
|
+
return "";
|
|
147
341
|
}
|
|
148
342
|
}
|
|
149
343
|
|
|
@@ -151,6 +345,86 @@ function shouldValidateRequestBody(method?: string): boolean {
|
|
|
151
345
|
return method !== "GET" && method !== "HEAD";
|
|
152
346
|
}
|
|
153
347
|
|
|
348
|
+
function shouldWriteResponseBody(
|
|
349
|
+
method: string | undefined,
|
|
350
|
+
status: number
|
|
351
|
+
): boolean {
|
|
352
|
+
return method !== "HEAD" && status !== 204 && status !== 304;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function readWritableResponseBody(
|
|
356
|
+
method: string | undefined,
|
|
357
|
+
response: Response,
|
|
358
|
+
reportError: (error: unknown) => void
|
|
359
|
+
): Promise<Buffer | undefined> {
|
|
360
|
+
if (shouldWriteResponseBody(method, response.status)) {
|
|
361
|
+
return Buffer.from(await response.arrayBuffer());
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
cancelSuppressedResponseBody(response, reportError);
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function cancelSuppressedResponseBody(
|
|
369
|
+
response: Response,
|
|
370
|
+
reportError: (error: unknown) => void
|
|
371
|
+
): void {
|
|
372
|
+
try {
|
|
373
|
+
void response.body?.cancel().catch(error => {
|
|
374
|
+
reportSuppressedResponseBodyCancelError(error, reportError);
|
|
375
|
+
});
|
|
376
|
+
} catch (error) {
|
|
377
|
+
reportSuppressedResponseBodyCancelError(error, reportError);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function reportSuppressedResponseBodyCancelError(
|
|
382
|
+
error: unknown,
|
|
383
|
+
reportError: (error: unknown) => void
|
|
384
|
+
): void {
|
|
385
|
+
try {
|
|
386
|
+
reportError(error);
|
|
387
|
+
} catch (onErrorFailure) {
|
|
388
|
+
console.error(
|
|
389
|
+
"TypeweaverApp: onError callback threw while handling error",
|
|
390
|
+
{ onErrorFailure, originalError: error }
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function hasReadableRequestBody(req: IncomingMessage): boolean {
|
|
396
|
+
return (
|
|
397
|
+
req.headers["content-length"] !== undefined ||
|
|
398
|
+
req.headers["transfer-encoding"] !== undefined
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function createRequestHeaders(headers: IncomingMessage["headers"]): Headers {
|
|
403
|
+
const requestHeaders = new Headers();
|
|
404
|
+
|
|
405
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
406
|
+
if (value === undefined) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (Array.isArray(value)) {
|
|
411
|
+
if (name.toLowerCase() === "cookie") {
|
|
412
|
+
requestHeaders.set(name, value.join("; "));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const item of value) {
|
|
417
|
+
requestHeaders.append(name, item);
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
requestHeaders.set(name, value);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return requestHeaders;
|
|
426
|
+
}
|
|
427
|
+
|
|
154
428
|
function isRequestBodyLimitError(
|
|
155
429
|
error: unknown
|
|
156
430
|
): error is PayloadTooLargeError | RequestBodyDrainTimeoutError {
|
|
@@ -177,9 +451,13 @@ function enforceContentLengthLimit(
|
|
|
177
451
|
function writeDefaultErrorResponse(
|
|
178
452
|
res: ServerResponse,
|
|
179
453
|
error:
|
|
454
|
+
| typeof badRequestDefaultError
|
|
180
455
|
| typeof payloadTooLargeDefaultError
|
|
181
456
|
| typeof internalServerErrorDefaultError,
|
|
182
|
-
|
|
457
|
+
options: {
|
|
458
|
+
readonly method?: string;
|
|
459
|
+
readonly onFinished?: () => void;
|
|
460
|
+
} = {}
|
|
183
461
|
): void {
|
|
184
462
|
if (!res.headersSent) {
|
|
185
463
|
res.writeHead(error.statusCode, {
|
|
@@ -187,11 +465,47 @@ function writeDefaultErrorResponse(
|
|
|
187
465
|
});
|
|
188
466
|
}
|
|
189
467
|
|
|
190
|
-
if (onFinished !== undefined) {
|
|
191
|
-
res.once("finish", onFinished);
|
|
468
|
+
if (options.onFinished !== undefined) {
|
|
469
|
+
res.once("finish", options.onFinished);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const body = shouldWriteResponseBody(options.method, error.statusCode)
|
|
473
|
+
? JSON.stringify(createDefaultErrorBody(error))
|
|
474
|
+
: undefined;
|
|
475
|
+
res.end(body);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function writeBadRequestResponse(
|
|
479
|
+
req: IncomingMessage,
|
|
480
|
+
res: ServerResponse,
|
|
481
|
+
maxBodySize: number
|
|
482
|
+
): void {
|
|
483
|
+
writeDefaultErrorResponse(res, badRequestDefaultError, {
|
|
484
|
+
method: req.method,
|
|
485
|
+
onFinished: createRejectedRequestBodyCleanup(req, maxBodySize),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function createRejectedRequestBodyCleanup(
|
|
490
|
+
req: IncomingMessage,
|
|
491
|
+
maxBodySize: number
|
|
492
|
+
): (() => void) | undefined {
|
|
493
|
+
if (!hasReadableRequestBody(req)) {
|
|
494
|
+
return undefined;
|
|
192
495
|
}
|
|
193
496
|
|
|
194
|
-
|
|
497
|
+
return () => {
|
|
498
|
+
const contentLength = parseContentLength(req.headers["content-length"]);
|
|
499
|
+
if (
|
|
500
|
+
contentLength !== undefined &&
|
|
501
|
+
isBodySizeOverLimit(contentLength, maxBodySize)
|
|
502
|
+
) {
|
|
503
|
+
req.destroy();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
void drainRequest(req, maxBodySize, { destroyOnLimitExceeded: true });
|
|
508
|
+
};
|
|
195
509
|
}
|
|
196
510
|
|
|
197
511
|
async function drainRequest(
|
|
@@ -336,12 +650,14 @@ function collectBody(
|
|
|
336
650
|
};
|
|
337
651
|
|
|
338
652
|
const handleAborted = (): void => {
|
|
339
|
-
rejectOnce(new
|
|
653
|
+
rejectOnce(new RequestBodyReadAbortedError(totalBytes, maxBodySize));
|
|
340
654
|
};
|
|
341
655
|
|
|
342
656
|
const handleClose = (): void => {
|
|
343
657
|
if (!req.readableEnded) {
|
|
344
|
-
rejectOnce(
|
|
658
|
+
rejectOnce(
|
|
659
|
+
new RequestBodyClosedBeforeEndError(totalBytes, maxBodySize)
|
|
660
|
+
);
|
|
345
661
|
}
|
|
346
662
|
};
|
|
347
663
|
|
package/dist/lib/PathMatcher.ts
CHANGED
|
@@ -9,13 +9,17 @@
|
|
|
9
9
|
* Creates a predicate that tests whether a request path matches a pattern.
|
|
10
10
|
*
|
|
11
11
|
* Supports three pattern types:
|
|
12
|
-
* - **Exact match**: `"/users"` matches
|
|
12
|
+
* - **Exact match**: `"/users"` matches `"/users"` and canonically
|
|
13
|
+
* equivalent rooted paths such as `"/users/"` and `"/users//"`
|
|
13
14
|
* - **Prefix match**: `"/users/*"` matches `"/users"` and any path beneath it
|
|
14
15
|
* (e.g. `"/users/123"`, `"/users/123/posts"`)
|
|
15
16
|
* - **Parameterized segments**: `"/users/:id"` matches paths where `:id` stands
|
|
16
17
|
* for exactly one segment (e.g. `"/users/123"` but not `"/users/123/posts"`)
|
|
17
18
|
*
|
|
18
19
|
* Uses the same `:paramName` syntax as typeweaver route definitions.
|
|
20
|
+
* Rooted request paths are canonicalized with the same empty-segment
|
|
21
|
+
* filtering used by the router, so duplicate and trailing slashes match the
|
|
22
|
+
* route they dispatch to. Unrooted request paths do not match rooted patterns.
|
|
19
23
|
*
|
|
20
24
|
* @example
|
|
21
25
|
* ```typescript
|
|
@@ -30,27 +34,67 @@
|
|
|
30
34
|
* ```
|
|
31
35
|
*/
|
|
32
36
|
export function pathMatcher(pattern: string): (path: string) => boolean {
|
|
37
|
+
const patternSegments = patternToSegments(pattern);
|
|
38
|
+
|
|
33
39
|
if (pattern.endsWith("/*")) {
|
|
34
|
-
const
|
|
35
|
-
|
|
40
|
+
const prefixSegments = patternToSegments(pattern.slice(0, -2));
|
|
41
|
+
|
|
42
|
+
return path => {
|
|
43
|
+
const pathSegments = toSegments(path);
|
|
44
|
+
if (pathSegments === undefined) return false;
|
|
45
|
+
if (pathSegments.length < prefixSegments.length) return false;
|
|
46
|
+
|
|
47
|
+
return prefixSegments.every(
|
|
48
|
+
(segment, index) => pathSegments[index] === segment
|
|
49
|
+
);
|
|
50
|
+
};
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
const
|
|
39
|
-
const hasParams = segments.some(s => s.startsWith(":"));
|
|
53
|
+
const hasParams = patternSegments.some(s => s.startsWith(":"));
|
|
40
54
|
|
|
41
55
|
if (!hasParams) {
|
|
42
|
-
return path =>
|
|
56
|
+
return path => {
|
|
57
|
+
const pathSegments = toSegments(path);
|
|
58
|
+
if (pathSegments === undefined) return false;
|
|
59
|
+
|
|
60
|
+
return segmentsEqual(patternSegments, pathSegments);
|
|
61
|
+
};
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
const segmentCount =
|
|
46
|
-
const matchers =
|
|
64
|
+
const segmentCount = patternSegments.length;
|
|
65
|
+
const matchers = patternSegments.map(s => (s.startsWith(":") ? null : s));
|
|
47
66
|
|
|
48
67
|
return path => {
|
|
49
|
-
const parts = path
|
|
68
|
+
const parts = toSegments(path);
|
|
69
|
+
if (parts === undefined) return false;
|
|
50
70
|
if (parts.length !== segmentCount) return false;
|
|
71
|
+
|
|
51
72
|
for (let i = 0; i < segmentCount; i++) {
|
|
52
|
-
if (matchers[i]
|
|
73
|
+
if (matchers[i] === null) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (matchers[i] !== parts[i]) return false;
|
|
53
78
|
}
|
|
54
79
|
return true;
|
|
55
80
|
};
|
|
56
81
|
}
|
|
82
|
+
|
|
83
|
+
function toSegments(path: string): readonly string[] | undefined {
|
|
84
|
+
if (!path.startsWith("/")) return undefined;
|
|
85
|
+
|
|
86
|
+
return patternToSegments(path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function patternToSegments(path: string): readonly string[] {
|
|
90
|
+
return path.split("/").filter(segment => segment.length > 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function segmentsEqual(
|
|
94
|
+
left: readonly string[],
|
|
95
|
+
right: readonly string[]
|
|
96
|
+
): boolean {
|
|
97
|
+
if (left.length !== right.length) return false;
|
|
98
|
+
|
|
99
|
+
return left.every((segment, index) => right[index] === segment);
|
|
100
|
+
}
|