@rexeus/typeweaver-server 0.10.3 → 0.10.4

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.
@@ -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 { compareRoutes, relative } 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 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\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":";;;;;;;;;;;;;;;;;AAwCA,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;AAEzC,QAAO;EACL;EACA;EACA,aAAa,SAAS,UAAU;EAChC,QAAQ,UAAU;EAClB,MAAM,UAAU;EACjB;;;;AC9EH,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 {
9
+ badRequestDefaultError,
9
10
  createDefaultErrorBody,
10
11
  internalServerErrorDefaultError,
11
12
  payloadTooLargeDefaultError,
@@ -39,6 +40,15 @@ type DrainRequestOptions = {
39
40
  };
40
41
 
41
42
  const REQUEST_DRAIN_TIMEOUT_MS = 5_000;
43
+ const ORIGIN_FORM_BASE_URL_PROTOCOL = "http:";
44
+ const AUTHORITY_LIKE_REQUEST_TARGET_PREFIX = /^[\\/]{2}/;
45
+ const ASTERISK_FORM_REQUEST_TARGET = "*";
46
+
47
+ type ParsedAuthority = {
48
+ readonly host: string;
49
+ readonly hostname: string;
50
+ readonly port: string;
51
+ };
42
52
 
43
53
  /**
44
54
  * Adapts a `TypeweaverApp` to Node.js `http.createServer`.
@@ -82,12 +92,16 @@ async function handleRequest(
82
92
  reportError: (error: unknown) => void
83
93
  ): Promise<void> {
84
94
  try {
85
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
95
+ const url = createRequestUrl(req);
96
+ if (url === undefined) {
97
+ writeBadRequestResponse(req, res, bodyLimitPolicy.maxBodySize);
98
+ return;
99
+ }
86
100
  const shouldValidateBody = shouldValidateRequestBody(req.method);
87
101
 
88
102
  enforceContentLengthLimit(req, bodyLimitPolicy.maxBodySize);
89
103
 
90
- if (!shouldValidateBody) {
104
+ if (!shouldValidateBody && hasReadableRequestBody(req)) {
91
105
  const drainResult = await drainRequest(req, bodyLimitPolicy.maxBodySize, {
92
106
  destroyOnLimitExceeded: false,
93
107
  });
@@ -111,7 +125,7 @@ async function handleRequest(
111
125
 
112
126
  const request = new Request(url, {
113
127
  method: req.method,
114
- headers: req.headers as Record<string, string>,
128
+ headers: createRequestHeaders(req.headers),
115
129
  body,
116
130
  });
117
131
  if (shouldValidateBody) {
@@ -119,6 +133,11 @@ async function handleRequest(
119
133
  }
120
134
 
121
135
  const response = await app.fetch(request);
136
+ const responseBody = await readWritableResponseBody(
137
+ req.method,
138
+ response,
139
+ reportError
140
+ );
122
141
 
123
142
  response.headers.forEach((value, key) => {
124
143
  if (key.toLowerCase() !== "set-cookie") {
@@ -130,20 +149,193 @@ async function handleRequest(
130
149
  res.setHeader("set-cookie", cookies);
131
150
  }
132
151
  res.writeHead(response.status);
133
- res.end(Buffer.from(await response.arrayBuffer()));
152
+ res.end(responseBody);
134
153
  } catch (error) {
135
154
  reportError(error);
136
155
 
137
156
  if (isRequestBodyLimitError(error)) {
138
- writeDefaultErrorResponse(res, payloadTooLargeDefaultError, () => {
139
- void drainRequest(req, bodyLimitPolicy.maxBodySize, {
140
- destroyOnLimitExceeded: true,
141
- });
157
+ writeDefaultErrorResponse(res, payloadTooLargeDefaultError, {
158
+ method: req.method,
159
+ onFinished: () => {
160
+ void drainRequest(req, bodyLimitPolicy.maxBodySize, {
161
+ destroyOnLimitExceeded: true,
162
+ });
163
+ },
142
164
  });
143
165
  return;
144
166
  }
145
167
 
146
- writeDefaultErrorResponse(res, internalServerErrorDefaultError);
168
+ writeDefaultErrorResponse(res, internalServerErrorDefaultError, {
169
+ method: req.method,
170
+ });
171
+ }
172
+ }
173
+
174
+ function createRequestUrl(req: IncomingMessage): URL | undefined {
175
+ const rawUrl = req.url ?? "/";
176
+
177
+ if (rawUrl === ASTERISK_FORM_REQUEST_TARGET) {
178
+ return createAsteriskFormRequestUrl(req);
179
+ }
180
+
181
+ if (hasAuthorityLikeRequestTargetPrefix(rawUrl)) {
182
+ return undefined;
183
+ }
184
+
185
+ try {
186
+ const url = new URL(rawUrl);
187
+ return isAbsoluteRequestHostAllowed(url, req) ? url : undefined;
188
+ } catch (error) {
189
+ if (!(error instanceof TypeError) || !rawUrl.startsWith("/")) {
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
195
+ if (host === undefined) {
196
+ return undefined;
197
+ }
198
+
199
+ return new URL(rawUrl, `${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}`);
200
+ }
201
+
202
+ function createAsteriskFormRequestUrl(req: IncomingMessage): URL | undefined {
203
+ if (req.method !== "OPTIONS") {
204
+ return undefined;
205
+ }
206
+
207
+ const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
208
+ if (host === undefined) {
209
+ return undefined;
210
+ }
211
+
212
+ return new URL(
213
+ ASTERISK_FORM_REQUEST_TARGET,
214
+ `${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}/`
215
+ );
216
+ }
217
+
218
+ function hasAuthorityLikeRequestTargetPrefix(rawUrl: string): boolean {
219
+ return AUTHORITY_LIKE_REQUEST_TARGET_PREFIX.test(rawUrl);
220
+ }
221
+
222
+ function isAbsoluteRequestHostAllowed(url: URL, req: IncomingMessage): boolean {
223
+ const host = parseRequestHostHeader(req, url.protocol);
224
+ if (host === undefined) {
225
+ return false;
226
+ }
227
+
228
+ const urlAuthority = getUrlAuthority(url);
229
+ return (
230
+ host.hostname.toLowerCase() === urlAuthority.hostname.toLowerCase() &&
231
+ host.port === urlAuthority.port
232
+ );
233
+ }
234
+
235
+ function parseRequestHostHeader(
236
+ req: IncomingMessage,
237
+ protocol: string
238
+ ): ParsedAuthority | undefined {
239
+ if (!hasExactlyOneHostHeaderLine(req)) {
240
+ return undefined;
241
+ }
242
+
243
+ return parseHostHeader(req.headers.host, protocol);
244
+ }
245
+
246
+ function hasExactlyOneHostHeaderLine(req: IncomingMessage): boolean {
247
+ const headersDistinctHostCount = getHeadersDistinctHostCount(req);
248
+ if (
249
+ headersDistinctHostCount !== undefined &&
250
+ headersDistinctHostCount !== 1
251
+ ) {
252
+ return false;
253
+ }
254
+
255
+ const rawHostHeaderCount = countRawHostHeaderLines(req.rawHeaders);
256
+ if (rawHostHeaderCount > 0) {
257
+ return rawHostHeaderCount === 1;
258
+ }
259
+
260
+ return headersDistinctHostCount === 1;
261
+ }
262
+
263
+ function getHeadersDistinctHostCount(req: IncomingMessage): number | undefined {
264
+ const hostHeader = req.headersDistinct?.host;
265
+ if (hostHeader === undefined) {
266
+ return undefined;
267
+ }
268
+
269
+ return Array.isArray(hostHeader) ? hostHeader.length : 1;
270
+ }
271
+
272
+ function countRawHostHeaderLines(rawHeaders: readonly string[]): number {
273
+ let count = 0;
274
+
275
+ for (let index = 0; index < rawHeaders.length; index += 2) {
276
+ if (rawHeaders[index]?.toLowerCase() === "host") {
277
+ count += 1;
278
+ }
279
+ }
280
+
281
+ return count;
282
+ }
283
+
284
+ function parseHostHeader(
285
+ hostHeader: IncomingMessage["headers"]["host"],
286
+ protocol: string
287
+ ): ParsedAuthority | undefined {
288
+ if (hostHeader === undefined || Array.isArray(hostHeader)) {
289
+ return undefined;
290
+ }
291
+
292
+ const host = hostHeader.trim();
293
+ if (host === "" || host !== hostHeader) {
294
+ return undefined;
295
+ }
296
+
297
+ try {
298
+ const parsed = new URL(`${protocol}//${host}`);
299
+ if (
300
+ parsed.username !== "" ||
301
+ parsed.password !== "" ||
302
+ parsed.pathname !== "/" ||
303
+ parsed.search !== "" ||
304
+ parsed.hash !== ""
305
+ ) {
306
+ return undefined;
307
+ }
308
+
309
+ return getUrlAuthority(parsed);
310
+ } catch {
311
+ return undefined;
312
+ }
313
+ }
314
+
315
+ function getUrlAuthority(url: URL): ParsedAuthority {
316
+ return {
317
+ host: url.host,
318
+ hostname: url.hostname,
319
+ port: getEffectivePort(url),
320
+ };
321
+ }
322
+
323
+ function getEffectivePort(url: URL): string {
324
+ return url.port === "" ? getDefaultPort(url.protocol) : url.port;
325
+ }
326
+
327
+ function getDefaultPort(protocol: string): string {
328
+ switch (protocol) {
329
+ case "http:":
330
+ case "ws:":
331
+ return "80";
332
+ case "https:":
333
+ case "wss:":
334
+ return "443";
335
+ case "ftp:":
336
+ return "21";
337
+ default:
338
+ return "";
147
339
  }
148
340
  }
149
341
 
@@ -151,6 +343,86 @@ function shouldValidateRequestBody(method?: string): boolean {
151
343
  return method !== "GET" && method !== "HEAD";
152
344
  }
153
345
 
346
+ function shouldWriteResponseBody(
347
+ method: string | undefined,
348
+ status: number
349
+ ): boolean {
350
+ return method !== "HEAD" && status !== 204 && status !== 304;
351
+ }
352
+
353
+ async function readWritableResponseBody(
354
+ method: string | undefined,
355
+ response: Response,
356
+ reportError: (error: unknown) => void
357
+ ): Promise<Buffer | undefined> {
358
+ if (shouldWriteResponseBody(method, response.status)) {
359
+ return Buffer.from(await response.arrayBuffer());
360
+ }
361
+
362
+ cancelSuppressedResponseBody(response, reportError);
363
+ return undefined;
364
+ }
365
+
366
+ function cancelSuppressedResponseBody(
367
+ response: Response,
368
+ reportError: (error: unknown) => void
369
+ ): void {
370
+ try {
371
+ void response.body?.cancel().catch(error => {
372
+ reportSuppressedResponseBodyCancelError(error, reportError);
373
+ });
374
+ } catch (error) {
375
+ reportSuppressedResponseBodyCancelError(error, reportError);
376
+ }
377
+ }
378
+
379
+ function reportSuppressedResponseBodyCancelError(
380
+ error: unknown,
381
+ reportError: (error: unknown) => void
382
+ ): void {
383
+ try {
384
+ reportError(error);
385
+ } catch (onErrorFailure) {
386
+ console.error(
387
+ "TypeweaverApp: onError callback threw while handling error",
388
+ { onErrorFailure, originalError: error }
389
+ );
390
+ }
391
+ }
392
+
393
+ function hasReadableRequestBody(req: IncomingMessage): boolean {
394
+ return (
395
+ req.headers["content-length"] !== undefined ||
396
+ req.headers["transfer-encoding"] !== undefined
397
+ );
398
+ }
399
+
400
+ function createRequestHeaders(headers: IncomingMessage["headers"]): Headers {
401
+ const requestHeaders = new Headers();
402
+
403
+ for (const [name, value] of Object.entries(headers)) {
404
+ if (value === undefined) {
405
+ continue;
406
+ }
407
+
408
+ if (Array.isArray(value)) {
409
+ if (name.toLowerCase() === "cookie") {
410
+ requestHeaders.set(name, value.join("; "));
411
+ continue;
412
+ }
413
+
414
+ for (const item of value) {
415
+ requestHeaders.append(name, item);
416
+ }
417
+ continue;
418
+ }
419
+
420
+ requestHeaders.set(name, value);
421
+ }
422
+
423
+ return requestHeaders;
424
+ }
425
+
154
426
  function isRequestBodyLimitError(
155
427
  error: unknown
156
428
  ): error is PayloadTooLargeError | RequestBodyDrainTimeoutError {
@@ -177,9 +449,13 @@ function enforceContentLengthLimit(
177
449
  function writeDefaultErrorResponse(
178
450
  res: ServerResponse,
179
451
  error:
452
+ | typeof badRequestDefaultError
180
453
  | typeof payloadTooLargeDefaultError
181
454
  | typeof internalServerErrorDefaultError,
182
- onFinished?: () => void
455
+ options: {
456
+ readonly method?: string;
457
+ readonly onFinished?: () => void;
458
+ } = {}
183
459
  ): void {
184
460
  if (!res.headersSent) {
185
461
  res.writeHead(error.statusCode, {
@@ -187,11 +463,47 @@ function writeDefaultErrorResponse(
187
463
  });
188
464
  }
189
465
 
190
- if (onFinished !== undefined) {
191
- res.once("finish", onFinished);
466
+ if (options.onFinished !== undefined) {
467
+ res.once("finish", options.onFinished);
468
+ }
469
+
470
+ const body = shouldWriteResponseBody(options.method, error.statusCode)
471
+ ? JSON.stringify(createDefaultErrorBody(error))
472
+ : undefined;
473
+ res.end(body);
474
+ }
475
+
476
+ function writeBadRequestResponse(
477
+ req: IncomingMessage,
478
+ res: ServerResponse,
479
+ maxBodySize: number
480
+ ): void {
481
+ writeDefaultErrorResponse(res, badRequestDefaultError, {
482
+ method: req.method,
483
+ onFinished: createRejectedRequestBodyCleanup(req, maxBodySize),
484
+ });
485
+ }
486
+
487
+ function createRejectedRequestBodyCleanup(
488
+ req: IncomingMessage,
489
+ maxBodySize: number
490
+ ): (() => void) | undefined {
491
+ if (!hasReadableRequestBody(req)) {
492
+ return undefined;
192
493
  }
193
494
 
194
- res.end(JSON.stringify(createDefaultErrorBody(error)));
495
+ return () => {
496
+ const contentLength = parseContentLength(req.headers["content-length"]);
497
+ if (
498
+ contentLength !== undefined &&
499
+ isBodySizeOverLimit(contentLength, maxBodySize)
500
+ ) {
501
+ req.destroy();
502
+ return;
503
+ }
504
+
505
+ void drainRequest(req, maxBodySize, { destroyOnLimitExceeded: true });
506
+ };
195
507
  }
196
508
 
197
509
  async function drainRequest(
@@ -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
+ }
@@ -135,6 +135,11 @@ export class Router {
135
135
  * Register a route in the radix tree.
136
136
  */
137
137
  public add(definition: RouteDefinition): void {
138
+ const method = definition.method.toUpperCase();
139
+ const normalizedDefinition =
140
+ definition.method === method
141
+ ? definition
142
+ : { ...definition, method: method as HttpMethod };
138
143
  const segments = Router.toSegments(definition.path);
139
144
 
140
145
  let current = this.root;
@@ -161,13 +166,13 @@ export class Router {
161
166
  }
162
167
  }
163
168
 
164
- if (current.methods.has(definition.method)) {
169
+ if (current.methods.has(method)) {
165
170
  throw new Error(
166
- `Route conflict: ${definition.method} ${definition.path} is already registered`
171
+ `Route conflict: ${method} ${definition.path} is already registered`
167
172
  );
168
173
  }
169
174
 
170
- current.methods.set(definition.method, definition);
175
+ current.methods.set(method, normalizedDefinition);
171
176
  }
172
177
 
173
178
  /**
@@ -280,7 +285,14 @@ export class Router {
280
285
  private static decodePathSegment(segment: string): string {
281
286
  try {
282
287
  const decoded = decodeURIComponent(segment);
283
- if (decoded === ".." || decoded === ".") return segment;
288
+ if (
289
+ decoded === ".." ||
290
+ decoded === "." ||
291
+ decoded.includes("/") ||
292
+ decoded.includes("\\")
293
+ ) {
294
+ return segment;
295
+ }
284
296
  return decoded;
285
297
  } catch {
286
298
  return segment;
@@ -13,9 +13,11 @@ import {
13
13
  internalServerErrorDefaultError,
14
14
  isTypedHttpResponse,
15
15
  methodNotAllowedDefaultError,
16
+ normalizeHttpResponse,
16
17
  notFoundDefaultError,
17
18
  payloadTooLargeDefaultError,
18
19
  RequestValidationError,
20
+ toHttpResponse,
19
21
  validationDefaultError,
20
22
  } from "@rexeus/typeweaver-core";
21
23
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
@@ -240,13 +242,21 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
240
242
  const routeCtx = this.withPathParams(ctx, match.params);
241
243
  try {
242
244
  const response = await this.executeHandler(routeCtx, match.route);
243
- return await this.validateResponse(match.route, response, routeCtx);
245
+ return await this.validateResponse(
246
+ match.route,
247
+ normalizeHttpResponse(response),
248
+ routeCtx
249
+ );
244
250
  } catch (error) {
245
251
  if (
246
252
  isTypedHttpResponse(error) &&
247
253
  match.route.routerConfig.validateResponses
248
254
  ) {
249
- return await this.validateResponse(match.route, error, routeCtx);
255
+ return await this.validateResponse(
256
+ match.route,
257
+ toHttpResponse(error),
258
+ routeCtx
259
+ );
250
260
  }
251
261
  return this.handleError(error, routeCtx, match.route);
252
262
  }
@@ -289,7 +299,7 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
289
299
  * - `validateResponses: true` (default) → runs validation:
290
300
  * - Valid response → returns the stripped response (extra fields removed).
291
301
  * - Invalid response + handler configured → calls the handler safely.
292
- * If the handler throws, falls back to the original response.
302
+ * If the handler throws, fails closed with a sanitized 500 response.
293
303
  * - Invalid response + `handleResponseValidationErrors: false` → returns
294
304
  * the original (invalid) response as-is.
295
305
  *
@@ -307,7 +317,9 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
307
317
 
308
318
  const result = route.responseValidator.safeValidate(response);
309
319
 
310
- if (result.isValid) return result.data;
320
+ if (result.isValid) {
321
+ return normalizeHttpResponse(result.data);
322
+ }
311
323
 
312
324
  const handler = this.resolveErrorHandler<ResponseValidationErrorHandler>(
313
325
  route.routerConfig.handleResponseValidationErrors,
@@ -319,6 +331,11 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
319
331
  handler(result.error, response, ctx)
320
332
  );
321
333
  if (handlerResponse) return handlerResponse;
334
+ return TypeweaverApp.defaultResponseValidationHandler(
335
+ result.error,
336
+ response,
337
+ ctx
338
+ );
322
339
  }
323
340
 
324
341
  return response;
@@ -461,7 +478,7 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
461
478
 
462
479
  private static defaultHttpResponseHandler: HttpResponseErrorHandler = (
463
480
  err
464
- ): IHttpResponse => err;
481
+ ): IHttpResponse => toHttpResponse(err);
465
482
 
466
483
  private readonly defaultUnknownHandler: UnknownErrorHandler = (
467
484
  error
@@ -4,6 +4,7 @@ import {
4
4
  } from "@rexeus/typeweaver-core";
5
5
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
6
6
  import { defineMiddleware } from "../TypedMiddleware.js";
7
+ import { readSingletonHeader } from "./header.js";
7
8
  import type { ServerContext } from "../ServerContext.js";
8
9
 
9
10
  export type BasicAuthOptions = {
@@ -31,10 +32,18 @@ export function basicAuth(options: BasicAuthOptions) {
31
32
  options.onUnauthorized?.(ctx) ?? defaultResponse;
32
33
 
33
34
  return defineMiddleware<{ username: string }>(async (ctx, next) => {
34
- const authorization = ctx.request.header?.["authorization"];
35
+ const authorization = readSingletonHeader(
36
+ ctx.request.header,
37
+ "authorization"
38
+ );
35
39
  if (typeof authorization !== "string") return deny(ctx);
36
40
 
37
- if (!authorization.startsWith(BASIC_PREFIX)) return deny(ctx);
41
+ if (
42
+ authorization.slice(0, BASIC_PREFIX.length).toLowerCase() !==
43
+ BASIC_PREFIX.toLowerCase()
44
+ ) {
45
+ return deny(ctx);
46
+ }
38
47
 
39
48
  const encoded = authorization.slice(BASIC_PREFIX.length);
40
49
  if (encoded.length === 0) return deny(ctx);
@@ -4,6 +4,7 @@ import {
4
4
  } from "@rexeus/typeweaver-core";
5
5
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
6
6
  import { defineMiddleware } from "../TypedMiddleware.js";
7
+ import { readSingletonHeader } from "./header.js";
7
8
  import type { ServerContext } from "../ServerContext.js";
8
9
 
9
10
  export type BearerAuthOptions = {
@@ -30,10 +31,18 @@ export function bearerAuth(options: BearerAuthOptions) {
30
31
  options.onUnauthorized?.(ctx) ?? defaultResponse;
31
32
 
32
33
  return defineMiddleware<{ token: string }>(async (ctx, next) => {
33
- const authorization = ctx.request.header?.["authorization"];
34
+ const authorization = readSingletonHeader(
35
+ ctx.request.header,
36
+ "authorization"
37
+ );
34
38
  if (typeof authorization !== "string") return deny(ctx);
35
39
 
36
- if (!authorization.startsWith(BEARER_PREFIX)) return deny(ctx);
40
+ if (
41
+ authorization.slice(0, BEARER_PREFIX.length).toLowerCase() !==
42
+ BEARER_PREFIX.toLowerCase()
43
+ ) {
44
+ return deny(ctx);
45
+ }
37
46
 
38
47
  const token = authorization.slice(BEARER_PREFIX.length);
39
48
  if (token.length === 0) return deny(ctx);
@@ -1,5 +1,10 @@
1
1
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
2
2
  import { defineMiddleware } from "../TypedMiddleware.js";
3
+ import {
4
+ hasHeaderName,
5
+ readHeaderValues,
6
+ readSingletonHeader,
7
+ } from "./header.js";
3
8
 
4
9
  export type CorsOptions = {
5
10
  readonly origin?:
@@ -22,13 +27,22 @@ const DEFAULT_METHODS = [
22
27
  "DELETE",
23
28
  ] as const;
24
29
 
30
+ const POLICY_CONTROLLED_CORS_HEADERS = new Set([
31
+ "access-control-allow-origin",
32
+ "access-control-allow-credentials",
33
+ "access-control-expose-headers",
34
+ "access-control-allow-methods",
35
+ "access-control-allow-headers",
36
+ "access-control-max-age",
37
+ ]);
38
+
25
39
  function resolveOrigin(
26
40
  configOrigin: CorsOptions["origin"],
27
41
  requestOrigin: string | undefined,
28
42
  credentials: boolean
29
43
  ): string | undefined {
30
44
  if (configOrigin === undefined || configOrigin === "*") {
31
- if (credentials && requestOrigin) return requestOrigin;
45
+ if (credentials) return undefined;
32
46
  return "*";
33
47
  }
34
48
 
@@ -48,21 +62,107 @@ function resolveOrigin(
48
62
  function getRequestOrigin(
49
63
  header: Record<string, string | string[]> | undefined
50
64
  ): string | undefined {
51
- const origin = header?.["origin"];
52
- return typeof origin === "string" ? origin : undefined;
65
+ return readSingletonHeader(header, "origin");
66
+ }
67
+
68
+ function isOriginDependentWithoutRequestOrigin(
69
+ configOrigin: CorsOptions["origin"],
70
+ credentials: boolean
71
+ ): boolean {
72
+ return (
73
+ typeof configOrigin === "function" ||
74
+ Array.isArray(configOrigin) ||
75
+ ((configOrigin === undefined || configOrigin === "*") && credentials)
76
+ );
77
+ }
78
+
79
+ function splitHeaderValues(values: readonly string[]): readonly string[] {
80
+ return values.flatMap(value =>
81
+ value
82
+ .split(",")
83
+ .map(item => item.trim())
84
+ .filter(item => item.length > 0)
85
+ );
86
+ }
87
+
88
+ function mergeVary(existing: readonly string[], value: string): string {
89
+ const values = splitHeaderValues(existing);
90
+ if (values.length === 0) return value;
91
+
92
+ const hasValue = values.some(
93
+ item => item.toLowerCase() === value.toLowerCase()
94
+ );
95
+
96
+ return hasValue ? values.join(", ") : [...values, value].join(", ");
97
+ }
98
+
99
+ function removePolicyControlledCorsHeaders(
100
+ responseHeaders: Record<string, string | string[]> | undefined
101
+ ): Record<string, string | string[]> {
102
+ const result: Record<string, string | string[]> = {};
103
+
104
+ for (const [key, value] of Object.entries(responseHeaders ?? {})) {
105
+ if (POLICY_CONTROLLED_CORS_HEADERS.has(key.toLowerCase())) continue;
106
+
107
+ result[key] = value;
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ function mergeResponseHeaders(
114
+ responseHeaders: Record<string, string | string[]> | undefined,
115
+ corsHeaders: Record<string, string>
116
+ ): Record<string, string | string[]> {
117
+ const result = removePolicyControlledCorsHeaders(responseHeaders);
118
+
119
+ const mergedCorsHeaders = { ...corsHeaders };
120
+ if (corsHeaders.vary !== undefined) {
121
+ for (const key of Object.keys(result)) {
122
+ if (key.toLowerCase() === "vary") delete result[key];
123
+ }
124
+
125
+ mergedCorsHeaders.vary = mergeVary(
126
+ readHeaderValues(responseHeaders, "vary"),
127
+ corsHeaders.vary
128
+ );
129
+ }
130
+
131
+ return { ...result, ...mergedCorsHeaders };
53
132
  }
54
133
 
55
134
  export function cors(options?: CorsOptions) {
56
135
  const credentials = options?.credentials ?? false;
136
+
57
137
  const methods = (options?.allowMethods ?? DEFAULT_METHODS).join(", ");
58
138
  const exposeHeaders = options?.exposeHeaders?.join(", ");
59
139
  const maxAge = options?.maxAge?.toString();
60
140
 
61
141
  return defineMiddleware(async (ctx, next) => {
62
142
  const requestOrigin = getRequestOrigin(ctx.request.header);
63
- const origin = resolveOrigin(options?.origin, requestOrigin, credentials);
143
+ const hasOrigin = hasHeaderName(ctx.request.header, "origin");
144
+ const resolvedOrigin =
145
+ hasOrigin && requestOrigin === undefined
146
+ ? undefined
147
+ : resolveOrigin(options?.origin, requestOrigin, credentials);
148
+ const origin =
149
+ credentials && resolvedOrigin === "*" ? undefined : resolvedOrigin;
150
+
151
+ if (origin === undefined) {
152
+ const response = await next();
153
+
154
+ if (
155
+ !hasOrigin &&
156
+ !isOriginDependentWithoutRequestOrigin(options?.origin, credentials)
157
+ ) {
158
+ return response;
159
+ }
64
160
 
65
- if (origin === undefined) return next();
161
+ return {
162
+ ...response,
163
+ header: mergeResponseHeaders(response.header, { vary: "Origin" }),
164
+ } satisfies IHttpResponse;
165
+ }
66
166
 
67
167
  const corsHeaders: Record<string, string> = {
68
168
  "access-control-allow-origin": origin,
@@ -82,18 +182,26 @@ export function cors(options?: CorsOptions) {
82
182
 
83
183
  const isPreflight =
84
184
  ctx.request.method === "OPTIONS" &&
85
- ctx.request.header?.["access-control-request-method"] !== undefined;
185
+ requestOrigin !== undefined &&
186
+ readSingletonHeader(
187
+ ctx.request.header,
188
+ "access-control-request-method"
189
+ ) !== undefined;
86
190
 
87
191
  if (isPreflight) {
88
192
  corsHeaders["access-control-allow-methods"] = methods;
89
193
 
90
194
  const configuredHeaders = options?.allowHeaders;
91
- if (configuredHeaders && configuredHeaders.length > 0) {
92
- corsHeaders["access-control-allow-headers"] =
93
- configuredHeaders.join(", ");
195
+ if (configuredHeaders !== undefined) {
196
+ if (configuredHeaders.length > 0) {
197
+ corsHeaders["access-control-allow-headers"] =
198
+ configuredHeaders.join(", ");
199
+ }
94
200
  } else {
95
- const requestedHeaders =
96
- ctx.request.header?.["access-control-request-headers"];
201
+ const requestedHeaders = readSingletonHeader(
202
+ ctx.request.header,
203
+ "access-control-request-headers"
204
+ );
97
205
  if (typeof requestedHeaders === "string") {
98
206
  corsHeaders["access-control-allow-headers"] = requestedHeaders;
99
207
  }
@@ -113,7 +221,7 @@ export function cors(options?: CorsOptions) {
113
221
 
114
222
  return {
115
223
  ...response,
116
- header: { ...corsHeaders, ...response.header },
224
+ header: mergeResponseHeaders(response.header, corsHeaders),
117
225
  } satisfies IHttpResponse;
118
226
  });
119
227
  }
@@ -0,0 +1,59 @@
1
+ export type HeaderMap = Record<string, string | string[]> | undefined;
2
+
3
+ export function readSingletonHeader(
4
+ header: HeaderMap,
5
+ name: string
6
+ ): string | undefined {
7
+ const normalizedName = name.toLowerCase();
8
+ let foundValue: string | undefined;
9
+
10
+ for (const [key, value] of Object.entries(header ?? {})) {
11
+ if (key.toLowerCase() !== normalizedName) continue;
12
+ if (foundValue !== undefined || typeof value !== "string") {
13
+ return undefined;
14
+ }
15
+
16
+ foundValue = value;
17
+ }
18
+
19
+ return foundValue;
20
+ }
21
+
22
+ export function hasHeaderName(header: HeaderMap, name: string): boolean {
23
+ const normalizedName = name.toLowerCase();
24
+
25
+ return Object.keys(header ?? {}).some(
26
+ key => key.toLowerCase() === normalizedName
27
+ );
28
+ }
29
+
30
+ export function readHeaderValues(
31
+ header: HeaderMap,
32
+ name: string
33
+ ): readonly string[] {
34
+ const normalizedName = name.toLowerCase();
35
+ const values: string[] = [];
36
+
37
+ for (const [key, value] of Object.entries(header ?? {})) {
38
+ if (key.toLowerCase() !== normalizedName) continue;
39
+
40
+ values.push(...(Array.isArray(value) ? value : [value]));
41
+ }
42
+
43
+ return values;
44
+ }
45
+
46
+ export function omitHeaders(
47
+ header: HeaderMap,
48
+ names: readonly string[]
49
+ ): Record<string, string | string[]> {
50
+ const normalizedNames = new Set(names.map(name => name.toLowerCase()));
51
+ const headers: Record<string, string | string[]> = {};
52
+
53
+ for (const [key, value] of Object.entries(header ?? {})) {
54
+ if (normalizedNames.has(key.toLowerCase())) continue;
55
+ headers[key] = value;
56
+ }
57
+
58
+ return headers;
59
+ }
@@ -10,6 +10,7 @@ export type LogData = {
10
10
  export type LoggerOptions = {
11
11
  readonly logFn?: (message: string) => void;
12
12
  readonly format?: (data: LogData) => string;
13
+ readonly nowMs?: () => number;
13
14
  };
14
15
 
15
16
  const defaultFormat = (data: LogData): string =>
@@ -18,11 +19,12 @@ const defaultFormat = (data: LogData): string =>
18
19
  export function logger(options?: LoggerOptions) {
19
20
  const logFn = options?.logFn ?? console.log;
20
21
  const format = options?.format ?? defaultFormat;
22
+ const nowMs = options?.nowMs ?? (() => performance.now());
21
23
 
22
24
  return defineMiddleware(async (ctx, next) => {
23
- const start = performance.now();
25
+ const start = nowMs();
24
26
  const response = await next();
25
- const durationMs = Math.round(performance.now() - start);
27
+ const durationMs = Math.round(nowMs() - start);
26
28
 
27
29
  logFn(
28
30
  format({
@@ -10,10 +10,15 @@ export function poweredBy(options?: PoweredByOptions) {
10
10
 
11
11
  return defineMiddleware(async (_ctx, next) => {
12
12
  const response = await next();
13
+ const responseHeaders = Object.fromEntries(
14
+ Object.entries(response.header ?? {}).filter(
15
+ ([headerName]) => headerName.toLowerCase() !== "x-powered-by"
16
+ )
17
+ );
13
18
 
14
19
  return {
15
20
  ...response,
16
- header: { ...response.header, "x-powered-by": value },
21
+ header: { ...responseHeaders, "x-powered-by": value },
17
22
  } satisfies IHttpResponse;
18
23
  });
19
24
  }
@@ -1,29 +1,29 @@
1
1
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
2
2
  import { defineMiddleware } from "../TypedMiddleware.js";
3
+ import { omitHeaders, readSingletonHeader } from "./header.js";
3
4
 
4
5
  export type RequestIdOptions = {
5
6
  readonly headerName?: string;
6
7
  readonly generator?: () => string;
7
8
  };
8
9
 
10
+ const isValidRequestId = (value: string | undefined): value is string =>
11
+ value !== undefined && value.length > 0 && !/[\r\n]/.test(value);
12
+
9
13
  export function requestId(options?: RequestIdOptions) {
10
14
  const headerName = (options?.headerName ?? "x-request-id").toLowerCase();
11
15
  const generator = options?.generator ?? (() => crypto.randomUUID());
12
16
 
13
17
  return defineMiddleware<{ requestId: string }>(async (ctx, next) => {
14
- const existing = ctx.request.header?.[headerName];
15
- const id =
16
- typeof existing === "string"
17
- ? existing
18
- : Array.isArray(existing)
19
- ? (existing[0] ?? generator())
20
- : generator();
18
+ const existing = readSingletonHeader(ctx.request.header, headerName);
19
+ const id = isValidRequestId(existing) ? existing : generator();
21
20
 
22
21
  const response = await next({ requestId: id });
22
+ const header = omitHeaders(response.header, [headerName]);
23
23
 
24
24
  return {
25
25
  ...response,
26
- header: { ...response.header, [headerName]: id },
26
+ header: { ...header, [headerName]: id },
27
27
  } satisfies IHttpResponse;
28
28
  });
29
29
  }
@@ -2,55 +2,70 @@ import { pathMatcher } from "../PathMatcher.js";
2
2
  import { defineMiddleware } from "../TypedMiddleware.js";
3
3
  import type { TypedMiddleware } from "../TypedMiddleware.js";
4
4
 
5
+ type NoProvidedKeys<TProvides extends Record<string, unknown>> = [
6
+ keyof TProvides,
7
+ ] extends [never]
8
+ ? unknown
9
+ : never;
10
+
5
11
  /**
6
12
  * Restricts a middleware to only run on paths matching the given patterns.
7
13
  *
8
14
  * Accepts the same pattern syntax as {@link pathMatcher}: exact (`"/users"`),
9
15
  * prefix (`"/api/*"`), and parameterized (`"/users/:id"`).
10
16
  *
11
- * Only accepts non-state middleware (`TypedMiddleware<{}, {}>`) to preserve
17
+ * Only accepts non-state-providing middleware to preserve
12
18
  * TypeWeaver's compile-time state guarantees — skipping a state-providing
13
19
  * middleware would leave downstream consumers with missing state.
20
+ * Any upstream state requirements declared by the wrapped middleware are
21
+ * preserved on the returned middleware descriptor.
14
22
  *
15
23
  * @example
16
24
  * ```typescript
17
25
  * app.use(scoped(["/api/*"], cors({ origin: "https://app.com" })));
18
26
  * ```
19
27
  */
20
- export function scoped(
28
+ export function scoped<
29
+ TProvides extends Record<string, unknown>,
30
+ TRequires extends Record<string, unknown>,
31
+ >(
21
32
  paths: readonly string[],
22
- middleware: TypedMiddleware<{}, {}>
23
- ): TypedMiddleware<{}, {}> {
33
+ middleware: TypedMiddleware<TProvides, TRequires> & NoProvidedKeys<TProvides>
34
+ ): TypedMiddleware<TProvides, TRequires> {
24
35
  const matchers = paths.map(pathMatcher);
25
36
 
26
- return defineMiddleware(async (ctx, next) => {
37
+ return defineMiddleware<{}, TRequires>(async (ctx, next) => {
27
38
  if (!matchers.some(match => match(ctx.request.path))) {
28
39
  return next();
29
40
  }
30
41
  return middleware.handler(ctx, next);
31
- });
42
+ }) as TypedMiddleware<TProvides, TRequires>;
32
43
  }
33
44
 
34
45
  /**
35
46
  * Runs a middleware on all paths *except* those matching the given patterns.
36
47
  *
37
- * The inverse of {@link scoped}. Same pattern syntax and type constraint.
48
+ * The inverse of {@link scoped}. Same pattern syntax and type constraints,
49
+ * including preservation of wrapped middleware state requirements.
38
50
  *
39
51
  * @example
40
52
  * ```typescript
41
53
  * app.use(except(["/health", "/ready"], logger()));
42
54
  * ```
43
55
  */
44
- export function except(
56
+ export function except<
57
+ TProvides extends Record<string, unknown>,
58
+ TRequires extends Record<string, unknown>,
59
+ >(
45
60
  paths: readonly string[],
46
- middleware: TypedMiddleware<{}, {}>
47
- ): TypedMiddleware<{}, {}> {
61
+ middleware: TypedMiddleware<TProvides, TRequires> & NoProvidedKeys<TProvides>
62
+ ): TypedMiddleware<TProvides, TRequires> {
48
63
  const matchers = paths.map(pathMatcher);
49
64
 
50
- return defineMiddleware(async (ctx, next) => {
65
+ return defineMiddleware<{}, TRequires>(async (ctx, next) => {
51
66
  if (matchers.some(match => match(ctx.request.path))) {
52
67
  return next();
53
68
  }
54
69
  return middleware.handler(ctx, next);
55
- });
70
+ }) as TypedMiddleware<TProvides, TRequires>;
56
71
  }
@@ -1,5 +1,6 @@
1
1
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
2
2
  import { defineMiddleware } from "../TypedMiddleware.js";
3
+ import { omitHeaders } from "./header.js";
3
4
 
4
5
  export type SecureHeadersOptions = {
5
6
  readonly contentTypeOptions?: string | false;
@@ -48,10 +49,11 @@ export function secureHeaders(options?: SecureHeadersOptions) {
48
49
 
49
50
  return defineMiddleware(async (_ctx, next) => {
50
51
  const response = await next();
52
+ const header = omitHeaders(response.header, Object.keys(headers));
51
53
 
52
54
  return {
53
55
  ...response,
54
- header: { ...headers, ...response.header },
56
+ header: { ...header, ...headers },
55
57
  } satisfies IHttpResponse;
56
58
  });
57
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rexeus/typeweaver-server",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "Generates a lightweight, dependency-free server with built-in routing and middleware from your API definitions. Powered by Typeweaver.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -47,15 +47,15 @@
47
47
  },
48
48
  "homepage": "https://github.com/rexeus/typeweaver#readme",
49
49
  "peerDependencies": {
50
- "@rexeus/typeweaver-core": "^0.10.3",
51
- "@rexeus/typeweaver-gen": "^0.10.3"
50
+ "@rexeus/typeweaver-core": "^0.10.4",
51
+ "@rexeus/typeweaver-gen": "^0.10.4"
52
52
  },
53
53
  "devDependencies": {
54
54
  "get-port": "^7.2.0",
55
55
  "test-utils": "file:../test-utils",
56
56
  "tsx": "^4.21.0",
57
- "@rexeus/typeweaver-core": "^0.10.3",
58
- "@rexeus/typeweaver-gen": "^0.10.3"
57
+ "@rexeus/typeweaver-core": "^0.10.4",
58
+ "@rexeus/typeweaver-gen": "^0.10.4"
59
59
  },
60
60
  "dependencies": {
61
61
  "polycase": "^1.1.0"