@rexeus/typeweaver-server 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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\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"}
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"}
@@ -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
 
@@ -19,8 +19,10 @@ import {
19
19
  } from "./BodyLimitPolicy.js";
20
20
  import {
21
21
  PayloadTooLargeError,
22
+ RequestBodyClosedBeforeEndError,
22
23
  RequestBodyDrainTimeoutError,
23
- } from "./Errors.js";
24
+ RequestBodyReadAbortedError,
25
+ } from "./errors/index.js";
24
26
  import {
25
27
  getTypeweaverAppErrorReporter,
26
28
  getTypeweaverAppRuntimeContext,
@@ -648,12 +650,14 @@ function collectBody(
648
650
  };
649
651
 
650
652
  const handleAborted = (): void => {
651
- rejectOnce(new Error("Request aborted while reading body"));
653
+ rejectOnce(new RequestBodyReadAbortedError(totalBytes, maxBodySize));
652
654
  };
653
655
 
654
656
  const handleClose = (): void => {
655
657
  if (!req.readableEnded) {
656
- rejectOnce(new Error("Request closed before body was fully read"));
658
+ rejectOnce(
659
+ new RequestBodyClosedBeforeEndError(totalBytes, maxBodySize)
660
+ );
657
661
  }
658
662
  };
659
663
 
@@ -14,6 +14,10 @@ import type {
14
14
  RequestValidationError,
15
15
  ResponseValidationError,
16
16
  } from "@rexeus/typeweaver-core";
17
+ import {
18
+ ConflictingPathParameterNameError,
19
+ DuplicateRouteRegistrationError,
20
+ } from "./errors/index.js";
17
21
  import type { RequestHandler } from "./RequestHandler.js";
18
22
  import type { ServerContext } from "./ServerContext.js";
19
23
 
@@ -148,8 +152,10 @@ export class Router {
148
152
  if (segment.startsWith(":")) {
149
153
  const paramName = segment.slice(1);
150
154
  if (current.paramChild && current.paramChild.name !== paramName) {
151
- throw new Error(
152
- `Conflicting path parameter names at "${definition.path}": ":${current.paramChild.name}" vs ":${paramName}"`
155
+ throw new ConflictingPathParameterNameError(
156
+ definition.path,
157
+ current.paramChild.name,
158
+ paramName
153
159
  );
154
160
  }
155
161
  if (!current.paramChild) {
@@ -167,9 +173,7 @@ export class Router {
167
173
  }
168
174
 
169
175
  if (current.methods.has(method)) {
170
- throw new Error(
171
- `Route conflict: ${method} ${definition.path} is already registered`
172
- );
176
+ throw new DuplicateRouteRegistrationError(method, definition.path);
173
177
  }
174
178
 
175
179
  current.methods.set(method, normalizedDefinition);
@@ -21,7 +21,11 @@ import {
21
21
  validationDefaultError,
22
22
  } from "@rexeus/typeweaver-core";
23
23
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
24
- import { BodyParseError, PayloadTooLargeError } from "./Errors.js";
24
+ import {
25
+ BodyParseError,
26
+ MissingRouterForPrefixedMountError,
27
+ PayloadTooLargeError,
28
+ } from "./errors/index.js";
25
29
  import { executeMiddlewarePipeline } from "./Middleware.js";
26
30
  import { Router } from "./Router.js";
27
31
  import { StateMap } from "./StateMap.js";
@@ -154,7 +158,7 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
154
158
  ): this {
155
159
  if (typeof prefixOrRouter === "string") {
156
160
  if (!router) {
157
- throw new Error("Router is required when mounting with a prefix");
161
+ throw new MissingRouterForPrefixedMountError(prefixOrRouter);
158
162
  }
159
163
  return this.mountRouter(router, prefixOrRouter);
160
164
  }
@@ -248,16 +252,6 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
248
252
  routeCtx
249
253
  );
250
254
  } catch (error) {
251
- if (
252
- isTypedHttpResponse(error) &&
253
- match.route.routerConfig.validateResponses
254
- ) {
255
- return await this.validateResponse(
256
- match.route,
257
- toHttpResponse(error),
258
- routeCtx
259
- );
260
- }
261
255
  return this.handleError(error, routeCtx, match.route);
262
256
  }
263
257
  }
@@ -395,7 +389,13 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
395
389
  const response = await this.safelyExecuteErrorHandler(() =>
396
390
  handler(error, ctx)
397
391
  );
398
- if (response) return response;
392
+ if (response) {
393
+ return await this.validateResponse(
394
+ route,
395
+ normalizeHttpResponse(response),
396
+ ctx
397
+ );
398
+ }
399
399
  }
400
400
  }
401
401
 
@@ -407,7 +407,10 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
407
407
  const response = await this.safelyExecuteErrorHandler(() =>
408
408
  handler(error, ctx)
409
409
  );
410
- if (response) return response;
410
+ if (response) {
411
+ this.safeOnError(error);
412
+ return response;
413
+ }
411
414
  }
412
415
 
413
416
  throw error;
@@ -481,9 +484,8 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
481
484
  ): IHttpResponse => toHttpResponse(err);
482
485
 
483
486
  private readonly defaultUnknownHandler: UnknownErrorHandler = (
484
- error
487
+ _error
485
488
  ): IHttpResponse => {
486
- this.safeOnError(error);
487
489
  return {
488
490
  statusCode: internalServerErrorDefaultError.statusCode,
489
491
  body: TypeweaverApp.INTERNAL_SERVER_ERROR_BODY,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ export class ConflictingPathParameterNameError extends Error {
9
+ public override readonly name = "ConflictingPathParameterNameError";
10
+
11
+ public constructor(
12
+ public readonly path: string,
13
+ public readonly existingParameterName: string,
14
+ public readonly conflictingParameterName: string
15
+ ) {
16
+ super(
17
+ `Conflicting path parameter names in '${path}': existing parameter ':${existingParameterName}' conflicts with ':${conflictingParameterName}' at the same route position.`
18
+ );
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ export class DuplicateRouteRegistrationError extends Error {
9
+ public override readonly name = "DuplicateRouteRegistrationError";
10
+
11
+ public constructor(
12
+ public readonly method: string,
13
+ public readonly path: string
14
+ ) {
15
+ super(
16
+ `Duplicate route registration refused: ${method} ${path} is already registered.`
17
+ );
18
+ }
19
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ export class MiddlewareNextAlreadyCalledError extends Error {
9
+ public override readonly name = "MiddlewareNextAlreadyCalledError";
10
+
11
+ public constructor(public readonly middlewareIndex: number) {
12
+ super(
13
+ `Middleware at index ${middlewareIndex} called next() more than once.`
14
+ );
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ export class MissingRouterForPrefixedMountError extends Error {
9
+ public override readonly name = "MissingRouterForPrefixedMountError";
10
+
11
+ public constructor(public readonly prefix: string) {
12
+ super(
13
+ `Router is required when mounting with prefix '${prefix}'. Pass both a prefix and a Typeweaver router instance.`
14
+ );
15
+ }
16
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ export class RequestBodyClosedBeforeEndError extends Error {
9
+ public override readonly name = "RequestBodyClosedBeforeEndError";
10
+
11
+ public constructor(
12
+ public readonly bytesRead: number,
13
+ public readonly maxBodySize: number
14
+ ) {
15
+ super(
16
+ `Request closed before the body finished reading after ${bytesRead} bytes; maximum allowed body size is ${maxBodySize} bytes.`
17
+ );
18
+ }
19
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ export class RequestBodyReadAbortedError extends Error {
9
+ public override readonly name = "RequestBodyReadAbortedError";
10
+
11
+ public constructor(
12
+ public readonly bytesRead: number,
13
+ public readonly maxBodySize: number
14
+ ) {
15
+ super(
16
+ `Request was aborted while reading the body after ${bytesRead} bytes; maximum allowed body size is ${maxBodySize} bytes.`
17
+ );
18
+ }
19
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ export {
9
+ BodyParseError,
10
+ PayloadTooLargeError,
11
+ RequestBodyDrainTimeoutError,
12
+ ResponseSerializationError,
13
+ } from "../Errors.js";
14
+ export { ConflictingPathParameterNameError } from "./ConflictingPathParameterNameError.js";
15
+ export { DuplicateRouteRegistrationError } from "./DuplicateRouteRegistrationError.js";
16
+ export { MiddlewareNextAlreadyCalledError } from "./MiddlewareNextAlreadyCalledError.js";
17
+ export { MissingRouterForPrefixedMountError } from "./MissingRouterForPrefixedMountError.js";
18
+ export { RequestBodyClosedBeforeEndError } from "./RequestBodyClosedBeforeEndError.js";
19
+ export { RequestBodyReadAbortedError } from "./RequestBodyReadAbortedError.js";
@@ -1,4 +1,4 @@
1
- import type { IHttpResponse } from "@rexeus/typeweaver-core";
1
+ import type { IHttpRequest, IHttpResponse } from "@rexeus/typeweaver-core";
2
2
  import { defineMiddleware } from "../TypedMiddleware.js";
3
3
  import {
4
4
  hasHeaderName,
@@ -36,6 +36,33 @@ const POLICY_CONTROLLED_CORS_HEADERS = new Set([
36
36
  "access-control-max-age",
37
37
  ]);
38
38
 
39
+ type NormalizedCorsOptions = {
40
+ readonly origin: CorsOptions["origin"];
41
+ readonly allowMethods: string;
42
+ readonly allowHeaders: readonly string[] | undefined;
43
+ readonly exposeHeaders: string | undefined;
44
+ readonly maxAge: string | undefined;
45
+ readonly credentials: boolean;
46
+ };
47
+
48
+ type CorsRequest = {
49
+ readonly header: IHttpRequest["header"];
50
+ readonly method: IHttpRequest["method"];
51
+ readonly hasOrigin: boolean;
52
+ readonly origin: string | undefined;
53
+ };
54
+
55
+ function normalizeCorsOptions(options?: CorsOptions): NormalizedCorsOptions {
56
+ return {
57
+ origin: options?.origin,
58
+ allowMethods: (options?.allowMethods ?? DEFAULT_METHODS).join(", "),
59
+ allowHeaders: options?.allowHeaders,
60
+ exposeHeaders: options?.exposeHeaders?.join(", "),
61
+ maxAge: options?.maxAge?.toString(),
62
+ credentials: options?.credentials ?? false,
63
+ };
64
+ }
65
+
39
66
  function resolveOrigin(
40
67
  configOrigin: CorsOptions["origin"],
41
68
  requestOrigin: string | undefined,
@@ -65,6 +92,15 @@ function getRequestOrigin(
65
92
  return readSingletonHeader(header, "origin");
66
93
  }
67
94
 
95
+ function readCorsRequest(request: IHttpRequest): CorsRequest {
96
+ return {
97
+ header: request.header,
98
+ method: request.method,
99
+ hasOrigin: hasHeaderName(request.header, "origin"),
100
+ origin: getRequestOrigin(request.header),
101
+ };
102
+ }
103
+
68
104
  function isOriginDependentWithoutRequestOrigin(
69
105
  configOrigin: CorsOptions["origin"],
70
106
  credentials: boolean
@@ -76,6 +112,97 @@ function isOriginDependentWithoutRequestOrigin(
76
112
  );
77
113
  }
78
114
 
115
+ function resolveRequestOrigin(
116
+ options: NormalizedCorsOptions,
117
+ request: CorsRequest
118
+ ): string | undefined {
119
+ if (request.hasOrigin && request.origin === undefined) {
120
+ return undefined;
121
+ }
122
+
123
+ const resolvedOrigin = resolveOrigin(
124
+ options.origin,
125
+ request.origin,
126
+ options.credentials
127
+ );
128
+
129
+ return options.credentials && resolvedOrigin === "*"
130
+ ? undefined
131
+ : resolvedOrigin;
132
+ }
133
+
134
+ function shouldVaryDeniedCorsResponse(
135
+ options: NormalizedCorsOptions,
136
+ request: CorsRequest
137
+ ): boolean {
138
+ return (
139
+ request.hasOrigin ||
140
+ isOriginDependentWithoutRequestOrigin(options.origin, options.credentials)
141
+ );
142
+ }
143
+
144
+ function buildSimpleCorsHeaders(
145
+ options: NormalizedCorsOptions,
146
+ origin: string
147
+ ): Record<string, string> {
148
+ const corsHeaders: Record<string, string> = {
149
+ "access-control-allow-origin": origin,
150
+ };
151
+
152
+ if (options.credentials) {
153
+ corsHeaders["access-control-allow-credentials"] = "true";
154
+ }
155
+
156
+ if (origin !== "*") {
157
+ corsHeaders["vary"] = "Origin";
158
+ }
159
+
160
+ if (options.exposeHeaders) {
161
+ corsHeaders["access-control-expose-headers"] = options.exposeHeaders;
162
+ }
163
+
164
+ return corsHeaders;
165
+ }
166
+
167
+ function isPreflightCorsRequest(request: CorsRequest): boolean {
168
+ return (
169
+ request.method === "OPTIONS" &&
170
+ request.origin !== undefined &&
171
+ readSingletonHeader(request.header, "access-control-request-method") !==
172
+ undefined
173
+ );
174
+ }
175
+
176
+ function buildPreflightCorsHeaders(
177
+ options: NormalizedCorsOptions,
178
+ request: CorsRequest,
179
+ simpleCorsHeaders: Record<string, string>
180
+ ): Record<string, string> {
181
+ const corsHeaders = { ...simpleCorsHeaders };
182
+ corsHeaders["access-control-allow-methods"] = options.allowMethods;
183
+
184
+ if (options.allowHeaders !== undefined) {
185
+ if (options.allowHeaders.length > 0) {
186
+ corsHeaders["access-control-allow-headers"] =
187
+ options.allowHeaders.join(", ");
188
+ }
189
+ } else {
190
+ const requestedHeaders = readSingletonHeader(
191
+ request.header,
192
+ "access-control-request-headers"
193
+ );
194
+ if (typeof requestedHeaders === "string") {
195
+ corsHeaders["access-control-allow-headers"] = requestedHeaders;
196
+ }
197
+ }
198
+
199
+ if (options.maxAge !== undefined) {
200
+ corsHeaders["access-control-max-age"] = options.maxAge;
201
+ }
202
+
203
+ return corsHeaders;
204
+ }
205
+
79
206
  function splitHeaderValues(values: readonly string[]): readonly string[] {
80
207
  return values.flatMap(value =>
81
208
  value
@@ -131,97 +258,50 @@ function mergeResponseHeaders(
131
258
  return { ...result, ...mergedCorsHeaders };
132
259
  }
133
260
 
134
- export function cors(options?: CorsOptions) {
135
- const credentials = options?.credentials ?? false;
261
+ function mergeCorsHeadersIntoResponse(
262
+ response: IHttpResponse,
263
+ corsHeaders: Record<string, string>
264
+ ): IHttpResponse {
265
+ return {
266
+ ...response,
267
+ header: mergeResponseHeaders(response.header, corsHeaders),
268
+ };
269
+ }
136
270
 
137
- const methods = (options?.allowMethods ?? DEFAULT_METHODS).join(", ");
138
- const exposeHeaders = options?.exposeHeaders?.join(", ");
139
- const maxAge = options?.maxAge?.toString();
271
+ export function cors(options?: CorsOptions) {
272
+ const normalizedOptions = normalizeCorsOptions(options);
140
273
 
141
274
  return defineMiddleware(async (ctx, next) => {
142
- const requestOrigin = getRequestOrigin(ctx.request.header);
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;
275
+ const corsRequest = readCorsRequest(ctx.request);
276
+ const origin = resolveRequestOrigin(normalizedOptions, corsRequest);
150
277
 
151
278
  if (origin === undefined) {
152
279
  const response = await next();
153
280
 
154
- if (
155
- !hasOrigin &&
156
- !isOriginDependentWithoutRequestOrigin(options?.origin, credentials)
157
- ) {
281
+ if (!shouldVaryDeniedCorsResponse(normalizedOptions, corsRequest)) {
158
282
  return response;
159
283
  }
160
284
 
161
- return {
162
- ...response,
163
- header: mergeResponseHeaders(response.header, { vary: "Origin" }),
164
- } satisfies IHttpResponse;
165
- }
166
-
167
- const corsHeaders: Record<string, string> = {
168
- "access-control-allow-origin": origin,
169
- };
170
-
171
- if (credentials) {
172
- corsHeaders["access-control-allow-credentials"] = "true";
173
- }
174
-
175
- if (origin !== "*") {
176
- corsHeaders["vary"] = "Origin";
285
+ return mergeCorsHeadersIntoResponse(response, { vary: "Origin" });
177
286
  }
178
287
 
179
- if (exposeHeaders) {
180
- corsHeaders["access-control-expose-headers"] = exposeHeaders;
181
- }
288
+ const corsHeaders = buildSimpleCorsHeaders(normalizedOptions, origin);
182
289
 
183
- const isPreflight =
184
- ctx.request.method === "OPTIONS" &&
185
- requestOrigin !== undefined &&
186
- readSingletonHeader(
187
- ctx.request.header,
188
- "access-control-request-method"
189
- ) !== undefined;
190
-
191
- if (isPreflight) {
192
- corsHeaders["access-control-allow-methods"] = methods;
193
-
194
- const configuredHeaders = options?.allowHeaders;
195
- if (configuredHeaders !== undefined) {
196
- if (configuredHeaders.length > 0) {
197
- corsHeaders["access-control-allow-headers"] =
198
- configuredHeaders.join(", ");
199
- }
200
- } else {
201
- const requestedHeaders = readSingletonHeader(
202
- ctx.request.header,
203
- "access-control-request-headers"
204
- );
205
- if (typeof requestedHeaders === "string") {
206
- corsHeaders["access-control-allow-headers"] = requestedHeaders;
207
- }
208
- }
209
-
210
- if (maxAge) {
211
- corsHeaders["access-control-max-age"] = maxAge;
212
- }
290
+ if (isPreflightCorsRequest(corsRequest)) {
291
+ const preflightHeaders = buildPreflightCorsHeaders(
292
+ normalizedOptions,
293
+ corsRequest,
294
+ corsHeaders
295
+ );
213
296
 
214
297
  return {
215
298
  statusCode: 204,
216
- header: corsHeaders,
299
+ header: preflightHeaders,
217
300
  } satisfies IHttpResponse;
218
301
  }
219
302
 
220
303
  const response = await next();
221
304
 
222
- return {
223
- ...response,
224
- header: mergeResponseHeaders(response.header, corsHeaders),
225
- } satisfies IHttpResponse;
305
+ return mergeCorsHeadersIntoResponse(response, corsHeaders);
226
306
  });
227
307
  }
@@ -2,12 +2,22 @@ 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>> = [
5
+ /** Rejects middleware that would provide state from a conditional branch. */
6
+ type RejectsProvidedState<TProvides extends Record<string, unknown>> = [
6
7
  keyof TProvides,
7
8
  ] extends [never]
8
9
  ? unknown
9
10
  : never;
10
11
 
12
+ /**
13
+ * scoped/except middleware may be skipped, so it cannot safely provide
14
+ * downstream state; any upstream state requirements remain part of its type.
15
+ */
16
+ type StateNeutralMiddleware<
17
+ TProvides extends Record<string, unknown>,
18
+ TRequires extends Record<string, unknown>,
19
+ > = TypedMiddleware<TProvides, TRequires> & RejectsProvidedState<TProvides>;
20
+
11
21
  /**
12
22
  * Restricts a middleware to only run on paths matching the given patterns.
13
23
  *
@@ -30,7 +40,7 @@ export function scoped<
30
40
  TRequires extends Record<string, unknown>,
31
41
  >(
32
42
  paths: readonly string[],
33
- middleware: TypedMiddleware<TProvides, TRequires> & NoProvidedKeys<TProvides>
43
+ middleware: StateNeutralMiddleware<TProvides, TRequires>
34
44
  ): TypedMiddleware<TProvides, TRequires> {
35
45
  const matchers = paths.map(pathMatcher);
36
46
 
@@ -58,7 +68,7 @@ export function except<
58
68
  TRequires extends Record<string, unknown>,
59
69
  >(
60
70
  paths: readonly string[],
61
- middleware: TypedMiddleware<TProvides, TRequires> & NoProvidedKeys<TProvides>
71
+ middleware: StateNeutralMiddleware<TProvides, TRequires>
62
72
  ): TypedMiddleware<TProvides, TRequires> {
63
73
  const matchers = paths.map(pathMatcher);
64
74
 
@@ -18,7 +18,8 @@ export type Server<%- pascalCaseEntityName %>ApiHandler<
18
18
  TState extends Record<string, unknown> = Record<string, unknown>,
19
19
  > = {
20
20
  <% for (const operation of operations) { %>
21
- <%- operation.handlerName %>: RequestHandler<I<%- operation.className %>Request, <%- operation.className %>Response, TState>;
21
+ <% if (operation.jsDoc) { %><%- operation.jsDoc %>
22
+ <% } %> <%- operation.handlerName %>: RequestHandler<I<%- operation.className %>Request, <%- operation.className %>Response, TState>;
22
23
  <% } %>
23
24
  };
24
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rexeus/typeweaver-server",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
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.4",
51
- "@rexeus/typeweaver-gen": "^0.10.4"
50
+ "@rexeus/typeweaver-core": "^0.11.0",
51
+ "@rexeus/typeweaver-gen": "^0.11.0"
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.4",
58
- "@rexeus/typeweaver-gen": "^0.10.4"
57
+ "@rexeus/typeweaver-core": "^0.11.0",
58
+ "@rexeus/typeweaver-gen": "^0.11.0"
59
59
  },
60
60
  "dependencies": {
61
61
  "polycase": "^1.1.0"