@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 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` | Error callback. Falls back to `console.error` if it throws. |
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
- catches the exception and falls through gracefully to the next handler.
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
  };
@@ -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 { compareRoutes, relative } from \"@rexeus/typeweaver-gen\";\nimport type {\n GeneratorContext,\n NormalizedOperation,\n NormalizedResource,\n} from \"@rexeus/typeweaver-gen\";\nimport { pascalCase } from \"polycase\";\n\ntype OperationData = {\n readonly operationId: string;\n readonly className: string;\n readonly handlerName: 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: GeneratorContext): 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: GeneratorContext\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\n return {\n operationId,\n className,\n handlerName: `handle${className}Request`,\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":";;;;;;;;;;;;;;;;;AA+BA,SAAgB,SAAS,SAAiC;CACxD,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;AAEzC,QAAO;EACL;EACA;EACA,aAAa,SAAS,UAAU;EAChC,QAAQ,UAAU;EAClB,MAAM,UAAU;EACjB;;;;ACrEH,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"}
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 contentLength = Number(rawValue);
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
- return JSON.stringify(body);
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",
@@ -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 Error("next() called multiple times");
56
+ throw new MiddlewareNextAlreadyCalledError(currentIndex);
56
57
  }
57
58
  called = true;
58
59
 
@@ -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
- } from "./Errors.js";
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 = new URL(req.url ?? "/", `http://${req.headers.host}`);
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 as Record<string, string>,
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(Buffer.from(await response.arrayBuffer()));
154
+ res.end(responseBody);
134
155
  } catch (error) {
135
156
  reportError(error);
136
157
 
137
158
  if (isRequestBodyLimitError(error)) {
138
- writeDefaultErrorResponse(res, payloadTooLargeDefaultError, () => {
139
- void drainRequest(req, bodyLimitPolicy.maxBodySize, {
140
- destroyOnLimitExceeded: true,
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
- onFinished?: () => void
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
- res.end(JSON.stringify(createDefaultErrorBody(error)));
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 Error("Request aborted while reading body"));
653
+ rejectOnce(new RequestBodyReadAbortedError(totalBytes, maxBodySize));
340
654
  };
341
655
 
342
656
  const handleClose = (): void => {
343
657
  if (!req.readableEnded) {
344
- rejectOnce(new Error("Request closed before body was fully read"));
658
+ rejectOnce(
659
+ new RequestBodyClosedBeforeEndError(totalBytes, maxBodySize)
660
+ );
345
661
  }
346
662
  };
347
663
 
@@ -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 only `"/users"`
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 prefix = pattern.slice(0, -2);
35
- return path => path === prefix || path.startsWith(prefix + "/");
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 segments = pattern.split("/");
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 => path === pattern;
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 = segments.length;
46
- const matchers = segments.map(s => (s.startsWith(":") ? null : s));
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.split("/");
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] !== null && matchers[i] !== parts[i]) return false;
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
+ }