@riddl/core 0.2.7 → 0.9.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.
@@ -1,6 +1,6 @@
1
- import type { Handler } from "./types";
2
- import type { IncomingMessage, ServerResponse } from "node:http";
3
- import express, { type Router, Application } from "express";
1
+ import type { Handler } from './types';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import express, { type Router, Application } from 'express';
4
4
  export declare const mountApi: (app: Application, prefix: string, apiHandler: Handler) => express.Application;
5
5
  export declare const setResponse: (res: ServerResponse, response: Response) => Promise<void>;
6
6
  export declare const rawAdapter: (apiHandler: Handler) => (req: IncomingMessage, res: ServerResponse) => Promise<void>;
package/dist/adapters.js CHANGED
@@ -1,7 +1,7 @@
1
- import express from "express";
2
- import { toFetchRequest } from "./utils.js";
3
- import * as set_cookie_parser from "set-cookie-parser";
4
- import { logError } from "./log.js";
1
+ import express from 'express';
2
+ import { toFetchRequest } from './utils.js';
3
+ import * as set_cookie_parser from 'set-cookie-parser';
4
+ import { logError } from './log.js';
5
5
  export const mountApi = (app, prefix, apiHandler) => app.use(prefix, expressAdapter(apiHandler));
6
6
  export const setResponse = async (res, response) => {
7
7
  for (const [key, value] of response.headers) {
@@ -11,7 +11,7 @@ export const setResponse = async (res, response) => {
11
11
  : value);
12
12
  }
13
13
  catch (error) {
14
- logError("failed to set response header", error);
14
+ logError('failed to set response header', error);
15
15
  res.getHeaderNames().forEach((name) => res.removeHeader(name));
16
16
  res.writeHead(500).end(String(error));
17
17
  return;
@@ -62,11 +62,11 @@ export const setResponse = async (res, response) => {
62
62
  }
63
63
  }
64
64
  };
65
- export const rawAdapter = (apiHandler) => (async (req, res) => {
65
+ export const rawAdapter = (apiHandler) => async (req, res) => {
66
66
  const response = await apiHandler(toFetchRequest(req));
67
67
  // Delegate entirely to setResponse. Do not preset Content-Length: 0 or
68
68
  // Content-Type: application/json — that breaks SSE and other streaming
69
69
  // bodies whose Response omits content-length (chunked transfer).
70
70
  await setResponse(res, response);
71
- });
71
+ };
72
72
  export const expressAdapter = (apiHandler) => express.Router().use(rawAdapter(apiHandler));
package/dist/api.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  import type { APIOptions, API } from './types';
2
- export declare const api: ({ routeModuleMap, config, base, cors, registry, onInternalError, }: APIOptions) => Promise<API>;
2
+ export declare const api: ({ routeModuleMap, config, base, cors, registry, onInternalError, matchers, maxBodySize, }: APIOptions) => Promise<API>;
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
2
- import { json, recursiveAwait } from './utils.js';
3
- import { CORS_DEFAULT, METHOD } from './constants.js';
2
+ import { json, recursiveAwait, validateRouteId } from './utils.js';
3
+ import { CORS_DEFAULT, METHOD, MAX_BODY_SIZE_DEFAULT } from './constants.js';
4
4
  import { log, logError } from './log.js';
5
5
  import { parseBody, parseOutput, parseParam, parseQuery } from './validation.js';
6
6
  import { cleanupTempFiles } from './multipart.js';
@@ -8,7 +8,7 @@ import { parseRouteId } from './parser.js';
8
8
  import { buildRouter } from './routing.js';
9
9
  import { openapiHandler, registerRoutes } from './openapi.js';
10
10
  import { error } from './errors.js';
11
- export const api = async ({ routeModuleMap, config, base = '', cors = true, registry = new OpenAPIRegistry(), onInternalError, }) => {
11
+ export const api = async ({ routeModuleMap, config, base = '', cors = true, registry = new OpenAPIRegistry(), onInternalError, matchers = {}, maxBodySize = MAX_BODY_SIZE_DEFAULT, }) => {
12
12
  const reportInternalError = (error) => {
13
13
  logError('internal error', error);
14
14
  onInternalError === null || onInternalError === void 0 ? void 0 : onInternalError(error);
@@ -22,8 +22,18 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
22
22
  const id = parts.slice(0, -1).join('/');
23
23
  if (METHOD.test(last)) {
24
24
  const method = last.toUpperCase();
25
- const endpoint = (await module())
26
- .apiEndpoint;
25
+ let endpointModule;
26
+ try {
27
+ endpointModule = (await module());
28
+ }
29
+ catch (e) {
30
+ throw new Error(`Failed to load route module "${path}": ${e instanceof Error ? e.message : String(e)}`);
31
+ }
32
+ const endpoint = endpointModule.apiEndpoint;
33
+ if (!endpoint) {
34
+ throw new Error(`Route module "${path}" does not export "apiEndpoint"`);
35
+ }
36
+ validateRouteId(id);
27
37
  acc.routes[id] = {
28
38
  matcher: parseRouteId(id),
29
39
  handlers: {
@@ -33,7 +43,12 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
33
43
  };
34
44
  }
35
45
  if (last === 'middleware') {
36
- acc.middlewares[id] = (await module());
46
+ try {
47
+ acc.middlewares[id] = (await module());
48
+ }
49
+ catch (e) {
50
+ throw new Error(`Failed to load middleware module "${path}": ${e instanceof Error ? e.message : String(e)}`);
51
+ }
37
52
  }
38
53
  return acc;
39
54
  }, Promise.resolve({
@@ -45,14 +60,14 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
45
60
  log('config: %O', config);
46
61
  log('base: %s', base);
47
62
  registerRoutes({ routes, registry, base });
48
- const router = buildRouter(routes, middlewares);
63
+ const router = buildRouter(routes, middlewares, matchers);
49
64
  const effectiveCorsSettings = cors
50
65
  ? typeof cors === 'object'
51
66
  ? cors
52
67
  : CORS_DEFAULT
53
68
  : undefined;
54
69
  const handler = async (request) => {
55
- var _a, _b, _c;
70
+ var _a, _b, _c, _d, _e, _f;
56
71
  // Temp files produced by streamed multipart uploads; removed once the
57
72
  // handler has finished reading them (it copies bytes to permanent
58
73
  // storage before returning).
@@ -75,7 +90,13 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
75
90
  headers: (_b = effectiveCorsSettings) !== null && _b !== void 0 ? _b : {},
76
91
  });
77
92
  }
78
- const endpoint = routeWithMiddlewares.handlers[request.method.toUpperCase()];
93
+ const isHead = request.method.toUpperCase() === 'HEAD';
94
+ // HEAD requests fall back to the GET handler
95
+ const reqMethod = (isHead ? 'GET' : request.method.toUpperCase());
96
+ let endpoint = routeWithMiddlewares.handlers[reqMethod];
97
+ if (!(endpoint === null || endpoint === void 0 ? void 0 : endpoint.handle) && isHead) {
98
+ endpoint = routeWithMiddlewares.handlers.GET;
99
+ }
79
100
  if (!(endpoint === null || endpoint === void 0 ? void 0 : endpoint.handle)) {
80
101
  throw error(405, 'Method not allowed');
81
102
  }
@@ -83,9 +104,9 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
83
104
  for (const mw of routeWithMiddlewares.middlewares) {
84
105
  ctxEvent = await mw.pre(ctxEvent);
85
106
  }
86
- const param = await parseParam(params !== null && params !== void 0 ? params : {}, endpoint);
87
- const query = await parseQuery(request, endpoint);
88
- const body = await parseBody(request, endpoint, tempFiles);
107
+ const param = parseParam(params !== null && params !== void 0 ? params : {}, endpoint);
108
+ const query = parseQuery(request, endpoint);
109
+ const body = await parseBody(request, endpoint, tempFiles, maxBodySize);
89
110
  log('handler: %O', endpoint.handle);
90
111
  const output = await endpoint.handle({
91
112
  body,
@@ -97,17 +118,17 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
97
118
  ctxEvent = await mw.post(ctxEvent);
98
119
  }
99
120
  log('output: %O', output);
121
+ let res;
100
122
  if (output instanceof Response) {
101
123
  if (effectiveCorsSettings) {
102
124
  for (const [key, value] of Object.entries(effectiveCorsSettings)) {
103
125
  output.headers.set(key, value);
104
126
  }
105
127
  }
106
- return output;
128
+ res = output;
107
129
  }
108
- // ReadableStream for Server-Sent Events
109
- if (output instanceof ReadableStream) {
110
- const res = new Response(output, {
130
+ else if (output instanceof ReadableStream) {
131
+ res = new Response(output, {
111
132
  headers: {
112
133
  'Content-Type': 'text/event-stream',
113
134
  'Cache-Control': 'no-cache',
@@ -115,38 +136,66 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
115
136
  ...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
116
137
  },
117
138
  });
118
- return res;
119
139
  }
120
- if (output instanceof ArrayBuffer) {
121
- const res = new Response(output, {
140
+ else if (output instanceof ArrayBuffer) {
141
+ res = new Response(output, {
122
142
  headers: {
123
143
  'Content-Type': 'application/octet-stream',
124
144
  ...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
125
145
  },
126
146
  });
127
- return res;
128
147
  }
129
148
  else {
130
- const out = await recursiveAwait(await parseOutput(output, endpoint));
131
- const res = json(out, {
149
+ // Skip recursiveAwait for primitives — it just returns them directly
150
+ // anyway, but avoids the unnecessary Promise wrapping.
151
+ const resolved = typeof output === 'object' &&
152
+ output !== null &&
153
+ !(output instanceof Response) &&
154
+ !(output instanceof ReadableStream) &&
155
+ !(output instanceof ArrayBuffer)
156
+ ? await recursiveAwait(output)
157
+ : output;
158
+ const out = await parseOutput(resolved, endpoint);
159
+ res = json(out, {
132
160
  headers: (_c = effectiveCorsSettings) !== null && _c !== void 0 ? _c : {},
133
161
  });
134
- return res;
135
162
  }
163
+ // For HEAD requests, strip the response body while preserving
164
+ // status code and headers (including Content-Length from the original).
165
+ if (isHead) {
166
+ return new Response(null, {
167
+ status: res.status,
168
+ headers: res.headers,
169
+ });
170
+ }
171
+ return res;
136
172
  }
137
173
  catch (e) {
138
174
  if (e && typeof e === 'object' && '__HTTP_ERROR__' in e) {
139
175
  const httpError = e;
140
- if (typeof httpError.status === 'number' && httpError.status >= 500) {
176
+ const status = typeof httpError.status === 'number' ? httpError.status : 500;
177
+ if (status >= 500) {
141
178
  reportInternalError(e);
179
+ // For server errors, return an opaque response — don't expose
180
+ // internal error details, keys, or the __HTTP_ERROR__ flag.
181
+ return json({ message: 'Internal server error', status: 500 }, {
182
+ status: 500,
183
+ headers: (_d = effectiveCorsSettings) !== null && _d !== void 0 ? _d : {},
184
+ });
142
185
  }
143
- return json(e, {
144
- status: httpError.status,
186
+ // For client errors (4xx), the error key and HTTP status code are
187
+ // intentional — they're part of the API contract. Strip only the
188
+ // internal __HTTP_ERROR__ flag so it doesn't leak to clients.
189
+ const { __HTTP_ERROR__, ...publicError } = e;
190
+ return json(publicError, {
191
+ status,
192
+ headers: (_e = effectiveCorsSettings) !== null && _e !== void 0 ? _e : {},
145
193
  });
146
194
  }
147
195
  reportInternalError(e);
148
196
  return json({ message: 'Internal server error', status: 500 }, {
149
197
  status: 500,
198
+ headers: (_f = effectiveCorsSettings) !== null && _f !== void 0 ? _f : {},
150
199
  });
151
200
  }
152
201
  finally {
@@ -1,6 +1,7 @@
1
1
  export declare const METHOD: RegExp;
2
+ export declare const MAX_BODY_SIZE_DEFAULT: number;
2
3
  export declare const CORS_DEFAULT: {
3
- readonly "Access-Control-Allow-Origin": "*";
4
- readonly "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS";
5
- readonly "Access-Control-Allow-Headers": "Content-Type, Authorization";
4
+ readonly 'Access-Control-Allow-Origin': "*";
5
+ readonly 'Access-Control-Allow-Methods': "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD";
6
+ readonly 'Access-Control-Allow-Headers': "Content-Type, Authorization";
6
7
  };
package/dist/constants.js CHANGED
@@ -1,6 +1,7 @@
1
- export const METHOD = /^(GET|POST|PUT|DELETE|PATCH|OPTIONS)$/i;
1
+ export const METHOD = /^(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)$/i;
2
+ export const MAX_BODY_SIZE_DEFAULT = 1024 * 1024 * 1024; // 1 GB
2
3
  export const CORS_DEFAULT = {
3
- "Access-Control-Allow-Origin": "*",
4
- "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
5
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
4
+ 'Access-Control-Allow-Origin': '*',
5
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD',
6
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
6
7
  };
@@ -1,4 +1,4 @@
1
- import type { EndpointHandler, EndpointSpecification, APIEndpoint, ParamType, QueryType, InputType, OutputType, StreamType, ErrorType } from "./types";
1
+ import type { EndpointHandler, EndpointSpecification, APIEndpoint, ParamType, QueryType, InputType, OutputType, StreamType, ErrorType } from './types';
2
2
  export declare const endpoint: <P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType>(data?: EndpointSpecification<P, Q, I, O, S, E>) => {
3
3
  handler: (handle: EndpointHandler<P, Q, I, O>) => APIEndpoint<P, Q, I, O, S, E>;
4
4
  };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
- export * from "./api";
2
- export * from "./openapi";
3
- export * from "./zod";
4
- export * from "./adapters";
5
- export * from "./types";
6
- export * from "./endpoint";
1
+ export * from './api';
2
+ export * from './openapi';
3
+ export * from './zod';
4
+ export * from './adapters';
5
+ export * from './types';
6
+ export * from './endpoint';
7
+ export * from './errors';
8
+ export * from './utils';
9
+ export * from './parser';
10
+ export * from './constants';
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
1
- export * from "./api.js";
2
- export * from "./openapi.js";
3
- export * from "./zod.js";
4
- export * from "./adapters.js";
5
- export * from "./types.js";
6
- export * from "./endpoint.js";
1
+ export * from './api.js';
2
+ export * from './openapi.js';
3
+ export * from './zod.js';
4
+ export * from './adapters.js';
5
+ export * from './types.js';
6
+ export * from './endpoint.js';
7
+ export * from './errors.js';
8
+ export * from './utils.js';
9
+ export * from './parser.js';
10
+ export * from './constants.js';
package/dist/log.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import debug from "debug";
1
+ import debug from 'debug';
2
2
  export declare const log: debug.Debugger;
3
3
  /** Always-on stderr log for internal errors (independent of DEBUG). */
4
4
  export declare const logError: (message: string, error: unknown) => void;
package/dist/log.js CHANGED
@@ -1,5 +1,5 @@
1
- import debug from "debug";
2
- export const log = debug("riddl");
1
+ import debug from 'debug';
2
+ export const log = debug('riddl');
3
3
  /** Always-on stderr log for internal errors (independent of DEBUG). */
4
4
  export const logError = (message, error) => {
5
5
  console.error(`riddl: ${message}`, error);
@@ -18,8 +18,13 @@ export type FormBody = Record<string, FormValue | FormValue[]>;
18
18
  * Returns the parsed body together with the temp paths the caller must delete
19
19
  * once the handler has finished reading the files — see
20
20
  * {@link cleanupTempFiles}.
21
+ *
22
+ * @param req The incoming Request.
23
+ * @param maxBodySize Maximum total bytes to accept (content-length is checked
24
+ * in the caller; individual file sizes are also bounded by
25
+ * busboy's `fileSize` limit as a backstop).
21
26
  */
22
- export declare const parseMultipart: (req: Request) => Promise<{
27
+ export declare const parseMultipart: (req: Request, maxBodySize?: number) => Promise<{
23
28
  body: FormBody;
24
29
  tempFiles: string[];
25
30
  }>;
package/dist/multipart.js CHANGED
@@ -1,11 +1,13 @@
1
- import busboy from "busboy";
2
- import { randomBytes } from "node:crypto";
3
- import { createWriteStream, openAsBlob } from "node:fs";
4
- import { unlink } from "node:fs/promises";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import { Readable } from "node:stream";
8
- import { pipeline } from "node:stream/promises";
1
+ import busboy from 'busboy';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { createWriteStream, openAsBlob } from 'node:fs';
4
+ import { unlink } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { Readable } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { MAX_BODY_SIZE_DEFAULT } from './constants.js';
10
+ import { error } from './errors.js';
9
11
  const addEntry = (body, name, value) => {
10
12
  const existing = body[name];
11
13
  if (existing === undefined) {
@@ -27,32 +29,54 @@ const addEntry = (body, name, value) => {
27
29
  * Returns the parsed body together with the temp paths the caller must delete
28
30
  * once the handler has finished reading the files — see
29
31
  * {@link cleanupTempFiles}.
32
+ *
33
+ * @param req The incoming Request.
34
+ * @param maxBodySize Maximum total bytes to accept (content-length is checked
35
+ * in the caller; individual file sizes are also bounded by
36
+ * busboy's `fileSize` limit as a backstop).
30
37
  */
31
- export const parseMultipart = async (req) => {
32
- const contentType = req.headers.get("content-type");
38
+ export const parseMultipart = async (req, maxBodySize = MAX_BODY_SIZE_DEFAULT) => {
39
+ const contentType = req.headers.get('content-type');
33
40
  if (!req.body || !contentType) {
34
41
  return { body: {}, tempFiles: [] };
35
42
  }
43
+ // Check Content-Length first to reject oversized requests early
44
+ const contentLength = req.headers.get('content-length');
45
+ if (contentLength) {
46
+ const size = parseInt(contentLength, 10);
47
+ if (!Number.isNaN(size) && size > maxBodySize) {
48
+ throw error(413, 'Request body too large');
49
+ }
50
+ }
36
51
  const tempFiles = [];
37
52
  const body = {};
38
53
  await new Promise((resolve, reject) => {
39
- const bb = busboy({ headers: { "content-type": contentType } });
54
+ const bb = busboy({
55
+ headers: { 'content-type': contentType },
56
+ limits: { fileSize: maxBodySize },
57
+ });
40
58
  // File parts finish writing asynchronously; `close` can fire before the
41
59
  // last temp file is fully flushed, so we wait on these before resolving.
42
60
  const pending = [];
43
- bb.on("field", (name, value) => addEntry(body, name, value));
44
- bb.on("file", (name, stream, info) => {
45
- const tempPath = join(tmpdir(), `riddl-upload-${randomBytes(16).toString("hex")}`);
61
+ bb.on('field', (name, value) => addEntry(body, name, value));
62
+ bb.on('file', (name, stream, info) => {
63
+ const tempPath = join(tmpdir(), `riddl-upload-${randomBytes(16).toString('hex')}`);
46
64
  tempFiles.push(tempPath);
47
- pending.push(pipeline(stream, createWriteStream(tempPath)).then(async () => {
65
+ pending.push(pipeline(stream, createWriteStream(tempPath))
66
+ .then(async () => {
48
67
  const blob = await openAsBlob(tempPath, {
49
68
  type: info.mimeType,
50
69
  });
51
70
  addEntry(body, name, Object.assign(blob, { name: info.filename }));
71
+ })
72
+ .catch((err) => {
73
+ // Abort the whole parse on oversized file — clean up
74
+ // any temp files already created.
75
+ Promise.all(tempFiles.map((p) => unlink(p).catch(() => undefined))).finally(() => reject(err));
52
76
  }));
53
77
  });
54
- bb.on("error", reject);
55
- bb.on("close", () => {
78
+ bb.on('error', reject);
79
+ bb.on('close', () => {
56
80
  Promise.all(pending)
57
81
  .then(() => resolve())
58
82
  .catch(reject);
package/dist/openapi.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
2
- import type { OpenAPIOptions, OpenAPIObjectConfig } from "./types";
1
+ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
2
+ import type { OpenAPIOptions, OpenAPIObjectConfig } from './types';
3
3
  export declare const registerRoutes: ({ routes, registry, base, }: OpenAPIOptions) => void;
4
4
  export declare const openapiHandler: (registry: OpenAPIRegistry, config: OpenAPIObjectConfig) => (request: Request) => Promise<Response>;
package/dist/openapi.js CHANGED
@@ -1,12 +1,10 @@
1
- import { OpenApiGeneratorV3, } from "@asteasolutions/zod-to-openapi";
2
- import { z } from "./zod.js";
3
- const openApiPath = (id, base) => id.split("/")
4
- .slice(0, -1)
5
- .join("/")
1
+ import { OpenApiGeneratorV3, } from '@asteasolutions/zod-to-openapi';
2
+ import { z } from './zod.js';
3
+ const openApiPath = (id, base) => id
6
4
  .replace(/^\./, base)
7
- .replace(/\[(\.{3})?(.+?)\]/g, "{$2}")
8
- .replace(/\(.+\)\//g, "");
9
- export const registerRoutes = ({ routes, registry, base = "", }) => {
5
+ .replace(/\[(\.{3})?(.+?)\]/g, '{$2}')
6
+ .replace(/\(.+\)\//g, '');
7
+ export const registerRoutes = ({ routes, registry, base = '', }) => {
10
8
  for (const [id, { handlers }] of Object.entries(routes)) {
11
9
  Object.entries(handlers).forEach(([methodString, module]) => {
12
10
  var _a;
@@ -16,6 +14,31 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
16
14
  const body = module.Input || undefined;
17
15
  const path = openApiPath(id, base);
18
16
  const method = methodString.toLowerCase();
17
+ // Merge Output and Stream into a single 200 response with both
18
+ // content types rather than overwriting one with the other.
19
+ const hasOutput = !!module.Output;
20
+ const hasStream = !!module.Stream;
21
+ const response200 = hasOutput || hasStream
22
+ ? {
23
+ description: '',
24
+ content: {
25
+ ...(hasOutput
26
+ ? {
27
+ 'application/json': {
28
+ schema: module.Output,
29
+ },
30
+ }
31
+ : undefined),
32
+ ...(hasStream
33
+ ? {
34
+ 'text/event-stream': {
35
+ schema: module.Stream,
36
+ },
37
+ }
38
+ : undefined),
39
+ },
40
+ }
41
+ : undefined;
19
42
  registry.registerPath(modifier({
20
43
  method,
21
44
  path,
@@ -24,15 +47,15 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
24
47
  query,
25
48
  body: body
26
49
  ? {
27
- description: "",
50
+ description: '',
28
51
  content: {
29
- "application/json": {
52
+ 'application/json': {
30
53
  schema: body,
31
54
  },
32
- "application/x-www-form-urlencoded": {
55
+ 'application/x-www-form-urlencoded': {
33
56
  schema: body,
34
57
  },
35
- "multipart/form-data": {
58
+ 'multipart/form-data': {
36
59
  schema: body,
37
60
  },
38
61
  },
@@ -40,36 +63,13 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
40
63
  : undefined,
41
64
  },
42
65
  responses: {
43
- ...(module.Output
44
- ? {
45
- "200": {
46
- description: "",
47
- content: {
48
- "application/json": {
49
- schema: module.Output,
50
- },
51
- },
52
- },
53
- }
54
- : undefined),
55
- ...(module.Stream
56
- ? {
57
- "200": {
58
- description: "",
59
- content: {
60
- "text/event-stream": {
61
- schema: module.Stream,
62
- },
63
- },
64
- },
65
- }
66
- : undefined),
66
+ ...(response200 ? { '200': response200 } : undefined),
67
67
  ...(query || module.Param || body
68
68
  ? {
69
- "400": {
70
- description: "Invalid input (path parameters, query string, or body)",
69
+ '400': {
70
+ description: 'Invalid input (path parameters, query string, or body)',
71
71
  content: {
72
- "application/json": {
72
+ 'application/json': {
73
73
  schema: z.object({
74
74
  message: z.string(),
75
75
  }),
@@ -91,23 +91,37 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
91
91
  };
92
92
  export const openapiHandler = (registry, config) => {
93
93
  let document = undefined;
94
+ let cachedOrigin = undefined;
94
95
  return async (request) => {
95
- if (!document) {
96
- const url = new URL(request.url).origin;
96
+ const origin = new URL(request.url).origin;
97
+ // Regenerate the document if the origin changes (e.g., behind a
98
+ // load balancer with different hostnames over time).
99
+ if (!document || cachedOrigin !== origin) {
97
100
  const generator = new OpenApiGeneratorV3(registry.definitions);
98
101
  document = generator.generateDocument({
99
102
  servers: [
100
103
  {
101
- url,
104
+ url: origin,
102
105
  },
103
106
  ],
104
107
  ...config,
105
108
  });
109
+ cachedOrigin = origin;
110
+ }
111
+ try {
112
+ return new Response(JSON.stringify(document), {
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ },
116
+ });
117
+ }
118
+ catch {
119
+ return new Response(JSON.stringify({ error: 'Failed to serialize OpenAPI document' }), {
120
+ status: 500,
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ },
124
+ });
106
125
  }
107
- return new Response(JSON.stringify(document), {
108
- headers: {
109
- "Content-Type": "application/json",
110
- },
111
- });
112
126
  };
113
127
  };
package/dist/parser.d.ts CHANGED
@@ -1,29 +1,11 @@
1
- import type { RouteParam } from "./types";
1
+ import type { RouteParam } from './types';
2
2
  export declare const parseRouteId: (id: string) => {
3
3
  pattern: RegExp;
4
4
  params: RouteParam[];
5
5
  };
6
- /**
7
- * Removes optional params from a route ID.
8
- */
9
- export declare const removeOptionalParams: (id: string) => string;
10
6
  /**
11
7
  * Splits a route id into its segments, removing segments that
12
8
  * don't affect the path (i.e. groups). The root route is represented by `/`
13
9
  * and will be returned as `['']`.
14
10
  */
15
11
  export declare const getRouteSegments: (route: string) => string[];
16
- /**
17
- * Populate a route ID with params to resolve a pathname.
18
- * @example
19
- * ```js
20
- * resolveRoute(
21
- * `/blog/[slug]/[...somethingElse]`,
22
- * {
23
- * slug: 'hello-world',
24
- * somethingElse: 'something/else'
25
- * }
26
- * ); // `/blog/hello-world/something/else`
27
- * ```
28
- */
29
- export declare const resolveRoute: (id: string, params: Record<string, string | undefined>) => string;
package/dist/parser.js CHANGED
@@ -13,7 +13,7 @@ export const parseRouteId = (id) => {
13
13
  matcher: restMatch[2],
14
14
  optional: false,
15
15
  rest: true,
16
- chained: true
16
+ chained: true,
17
17
  });
18
18
  return '(?:/([^]*))?';
19
19
  }
@@ -25,7 +25,7 @@ export const parseRouteId = (id) => {
25
25
  matcher: optionalMatch[2],
26
26
  optional: true,
27
27
  rest: false,
28
- chained: true
28
+ chained: true,
29
29
  });
30
30
  return '(?:/([^/]+))?';
31
31
  }
@@ -45,7 +45,7 @@ export const parseRouteId = (id) => {
45
45
  .split('-')
46
46
  .map((code) => parseInt(code, 16))));
47
47
  }
48
- const match = (paramPattern.exec(content));
48
+ const match = paramPattern.exec(content);
49
49
  if (!match) {
50
50
  throw new Error(`Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`);
51
51
  }
@@ -58,9 +58,13 @@ export const parseRouteId = (id) => {
58
58
  matcher,
59
59
  optional: !!isOptional,
60
60
  rest: !!isRest,
61
- chained: isRest ? i === 1 && parts[0] === '' : false
61
+ chained: isRest ? i === 1 && parts[0] === '' : false,
62
62
  });
63
- return isRest ? '([^]*?)' : isOptional ? '([^/]*)?' : '([^/]+?)';
63
+ return isRest
64
+ ? '([^]*?)'
65
+ : isOptional
66
+ ? '([^/]*)?'
67
+ : '([^/]+?)';
64
68
  }
65
69
  return escape(content);
66
70
  })
@@ -70,16 +74,6 @@ export const parseRouteId = (id) => {
70
74
  .join('')}/?$`);
71
75
  return { pattern, params };
72
76
  };
73
- const optionalParamRegex = /\/\[\[\w+?(?:=\w+)?\]\]/;
74
- /**
75
- * Removes optional params from a route ID.
76
- */
77
- export const removeOptionalParams = (id) => {
78
- return id.replace(optionalParamRegex, '');
79
- };
80
- /**
81
- * Returns `false` for `(group)` segments
82
- */
83
77
  function affectsPath(segment) {
84
78
  return segment !== '' && !/^\([^)]+\)$/.test(segment);
85
79
  }
@@ -104,40 +98,3 @@ const escape = (str) => {
104
98
  // escape characters that have special meaning in regex
105
99
  .replace(/[.*+?^${}()|\\]/g, '\\$&'));
106
100
  };
107
- const basicParamPattern = /\[(\[)?(\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g;
108
- /**
109
- * Populate a route ID with params to resolve a pathname.
110
- * @example
111
- * ```js
112
- * resolveRoute(
113
- * `/blog/[slug]/[...somethingElse]`,
114
- * {
115
- * slug: 'hello-world',
116
- * somethingElse: 'something/else'
117
- * }
118
- * ); // `/blog/hello-world/something/else`
119
- * ```
120
- */
121
- export const resolveRoute = (id, params) => {
122
- const segments = getRouteSegments(id);
123
- const hasIdTrailingSlash = id != '/' && id.endsWith('/');
124
- return ('/' +
125
- segments
126
- .map((segment) => segment.replace(basicParamPattern, (_, optional, rest, name) => {
127
- const paramValue = params[name];
128
- // This is nested so TS correctly narrows the type
129
- if (!paramValue) {
130
- if (optional)
131
- return '';
132
- if (rest && paramValue !== undefined)
133
- return '';
134
- throw new Error(`Missing parameter '${name}' in route ${id}`);
135
- }
136
- if (paramValue.startsWith('/') || paramValue.endsWith('/'))
137
- throw new Error(`Parameter '${name}' in route ${id} cannot start or end with a slash -- this would cause an invalid route like foo//bar`);
138
- return paramValue;
139
- }))
140
- .filter(Boolean)
141
- .join('/') +
142
- (hasIdTrailingSlash ? '/' : ''));
143
- };
package/dist/routing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { MiddlewareMap, RouteModuleMap, RouteParam } from "./types";
2
- export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: MiddlewareMap) => (path: string) => {
1
+ import type { MiddlewareMap, ParamMatcher, RouteModuleMap, RouteParam } from './types';
2
+ export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: MiddlewareMap, matchers?: Record<string, ParamMatcher>) => (path: string) => {
3
3
  id: string;
4
4
  params: Record<string, string> | undefined;
5
5
  routeWithMiddlewares: {
@@ -8,7 +8,7 @@ export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: Mi
8
8
  pattern: RegExp;
9
9
  params: RouteParam[];
10
10
  };
11
- handlers: Record<import("./types").Method, import("./types").GenericApiEndpoint>;
11
+ handlers: Partial<Record<import("./types").Method, import("./types").GenericApiEndpoint>>;
12
12
  id: string;
13
13
  };
14
14
  } | undefined;
package/dist/routing.js CHANGED
@@ -25,13 +25,18 @@ const exec = (match, params, matchers) => {
25
25
  continue;
26
26
  }
27
27
  }
28
- if (!param.matcher || matchers[param.matcher](value)) {
28
+ if (!param.matcher ||
29
+ (matchers[param.matcher] && matchers[param.matcher](value))) {
29
30
  result[param.name] = value;
30
31
  // Now that the params match, reset the buffer if the next param isn't the [...rest]
31
32
  // and the next value is defined, otherwise the buffer will cause us to skip values
32
33
  const nextParam = params[i + 1];
33
34
  const nextValue = values[i + 1];
34
- if (nextParam && !nextParam.rest && nextParam.optional && nextValue && param.chained) {
35
+ if (nextParam &&
36
+ !nextParam.rest &&
37
+ nextParam.optional &&
38
+ nextValue &&
39
+ param.chained) {
35
40
  buffered = 0;
36
41
  }
37
42
  // There are no more params and no more values, but all non-empty values have been matched
@@ -67,19 +72,27 @@ const routeSorter = (a, b) => {
67
72
  return byParams;
68
73
  return b.id.length - a.id.length;
69
74
  };
70
- export const buildRouter = (routesMap, middlewaresMap) => {
75
+ export const buildRouter = (routesMap, middlewaresMap, matchers = {}) => {
71
76
  const middlewareEntries = Object.entries(middlewaresMap).sort((a, b) => a[0].length - b[0].length);
72
- const routesWithMiddlewaresList = Object.entries(routesMap).map(([id, route]) => ({
77
+ const routesWithMiddlewaresList = Object.entries(routesMap)
78
+ .map(([id, route]) => ({
73
79
  id,
74
80
  ...route,
75
- middlewares: middlewareEntries.filter(([middlewareId]) => id.startsWith(middlewareId)).map(([_, middleware]) => middleware),
76
- })).sort(routeSorter);
81
+ middlewares: middlewareEntries
82
+ .filter(([middlewareId]) => id.startsWith(middlewareId))
83
+ .map(([, middleware]) => middleware),
84
+ }))
85
+ .sort(routeSorter);
77
86
  return (path) => {
78
87
  for (const route of routesWithMiddlewaresList) {
79
88
  const { pattern } = route.matcher;
80
89
  const matches = pattern.exec(path);
81
90
  if (matches) {
82
- return { id: route.id, params: exec(matches, route.matcher.params, {}), routeWithMiddlewares: route };
91
+ return {
92
+ id: route.id,
93
+ params: exec(matches, route.matcher.params, matchers),
94
+ routeWithMiddlewares: route,
95
+ };
83
96
  }
84
97
  }
85
98
  return undefined;
package/dist/types.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { RouteConfig, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
2
- import type { OpenAPIObjectConfig } from "@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator";
3
- import type { z } from "./zod";
4
- export type { OpenAPIObject } from "openapi3-ts/oas30";
5
- export type { OpenAPIObjectConfig } from "@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator";
6
- export type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
1
+ import type { RouteConfig, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
2
+ import type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator';
3
+ import type { z } from './zod';
4
+ export type { OpenAPIObject } from 'openapi3-ts/oas30';
5
+ export type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator';
6
+ export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
7
7
  export interface HttpError {
8
8
  status: number;
9
9
  key?: string;
@@ -26,7 +26,7 @@ export interface ApiRoute {
26
26
  pattern: RegExp;
27
27
  params: RouteParam[];
28
28
  };
29
- handlers: Record<Method, GenericApiEndpoint>;
29
+ handlers: Partial<Record<Method, GenericApiEndpoint>>;
30
30
  }
31
31
  export interface ApiRouteWithMiddlewares extends ApiRoute {
32
32
  middlewares: Middleware[];
@@ -36,7 +36,7 @@ export type RouteModuleMap = Record<string, {
36
36
  pattern: RegExp;
37
37
  params: RouteParam[];
38
38
  };
39
- handlers: Record<Method, GenericApiEndpoint>;
39
+ handlers: Partial<Record<Method, GenericApiEndpoint>>;
40
40
  }>;
41
41
  export type RouteWithMiddlewaresMap = Record<string, ApiRouteWithMiddlewares>;
42
42
  export type MiddlewareMap = Record<string, Middleware>;
@@ -66,9 +66,9 @@ export interface APIContext {
66
66
  */
67
67
  export type EndpointModifier = (r: RouteConfig) => RouteConfig;
68
68
  export interface CORS {
69
- "Access-Control-Allow-Origin": string;
70
- "Access-Control-Allow-Methods": string;
71
- "Access-Control-Allow-Headers": string;
69
+ 'Access-Control-Allow-Origin': string;
70
+ 'Access-Control-Allow-Methods': string;
71
+ 'Access-Control-Allow-Headers': string;
72
72
  }
73
73
  export interface APIOptions {
74
74
  routeModuleMap: Record<string, () => Promise<unknown>>;
@@ -78,6 +78,10 @@ export interface APIOptions {
78
78
  registry?: OpenAPIRegistry;
79
79
  /** Called for unexpected throws and HTTP errors with status >= 500. */
80
80
  onInternalError?: (error: unknown) => void;
81
+ /** Custom parameter matchers, keyed by name. Used in `[param=matcher]` route syntax. */
82
+ matchers?: Record<string, ParamMatcher>;
83
+ /** Maximum request body size in bytes. Defaults to 1 GB. */
84
+ maxBodySize?: number;
81
85
  }
82
86
  export interface OpenAPIOptions {
83
87
  base?: string;
@@ -85,7 +89,7 @@ export interface OpenAPIOptions {
85
89
  registry: OpenAPIRegistry;
86
90
  }
87
91
  export interface EndpointHandler<P, Q, I, O> {
88
- ({ body, query, param, event }: {
92
+ ({ body, query, param, event, }: {
89
93
  body: z.infer<I>;
90
94
  query: z.infer<Q>;
91
95
  param: z.infer<P>;
@@ -114,9 +118,9 @@ export interface APIEndpoint<P extends ParamType = z.ZodObject<Record<never, nev
114
118
  handle?: EndpointHandler<P, Q, I, O>;
115
119
  }
116
120
  export interface EndpointBuilder<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
117
- (spec: EndpointSpecification<P, Q, I, O, S, E>): ({
121
+ (spec: EndpointSpecification<P, Q, I, O, S, E>): {
118
122
  handler: () => APIEndpoint<P, Q, I, O, S, E>;
119
- });
123
+ };
120
124
  }
121
125
  export interface EndpointStreamSetter<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
122
126
  (data: APIEndpoint<P, Q, I, O, S, E>): EndpointBuilder<P, Q, I, O, S, E>;
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { IncomingMessage } from "http";
2
- import type { Request as ExpressRequest } from "express";
1
+ import type { IncomingMessage } from 'http';
2
+ import type { Request as ExpressRequest } from 'express';
3
3
  export declare const recursiveAwait: (obj: unknown) => unknown;
4
4
  export declare const validateRouteId: (id: string) => void;
5
5
  export declare const toFetchRequest: (req: IncomingMessage | ExpressRequest) => Request;
package/dist/utils.js CHANGED
@@ -8,6 +8,20 @@ export const recursiveAwait = (obj) => {
8
8
  // dates become `{}` in API responses after the plain-object walk below.
9
9
  return obj;
10
10
  }
11
+ else if (obj instanceof Map) {
12
+ // `Map` extends `Object`, but `Object.keys(new Map())` is [] — without this branch,
13
+ // maps become `{}` in API responses after the plain-object walk below.
14
+ const entries = Array.from(obj.entries());
15
+ if (entries.length === 0)
16
+ return {};
17
+ return Promise.all(entries.map(async ([k, v]) => [await recursiveAwait(k), await recursiveAwait(v)])).then((resolved) => Object.fromEntries(resolved));
18
+ }
19
+ else if (obj instanceof Set) {
20
+ // `Set` extends `Object`, but `Object.keys(new Set())` is [] — without this branch,
21
+ // sets become `{}` in API responses after the plain-object walk below.
22
+ const values = Array.from(obj.values());
23
+ return Promise.all(values.map(recursiveAwait));
24
+ }
11
25
  else if (obj instanceof Array) {
12
26
  return Promise.all(obj.map(recursiveAwait));
13
27
  }
@@ -48,21 +62,21 @@ export const validateRouteId = (id) => {
48
62
  throw new Error(`Invalid route ${id} — a rest route segment is always optional, remove the outer square brackets`);
49
63
  }
50
64
  };
51
- const isNodeReadable = (value) => !!value && typeof value.pipe === "function";
65
+ const isNodeReadable = (value) => !!value && typeof value.pipe === 'function';
52
66
  const isBodyInit = (value) => {
53
- if (typeof value === "string")
67
+ if (typeof value === 'string')
54
68
  return true;
55
69
  if (value instanceof ArrayBuffer)
56
70
  return true;
57
71
  if (ArrayBuffer.isView(value))
58
72
  return true;
59
- if (typeof Blob !== "undefined" && value instanceof Blob)
73
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
60
74
  return true;
61
- if (typeof FormData !== "undefined" && value instanceof FormData)
75
+ if (typeof FormData !== 'undefined' && value instanceof FormData)
62
76
  return true;
63
77
  if (value instanceof URLSearchParams)
64
78
  return true;
65
- if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream)
79
+ if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
66
80
  return true;
67
81
  if (isNodeReadable(value))
68
82
  return true;
@@ -73,42 +87,52 @@ const coerceBody = (body, headers) => {
73
87
  return undefined;
74
88
  if (isBodyInit(body))
75
89
  return body;
90
+ // At this point, `body` is a plain object (e.g., from Express's body
91
+ // parser). We JSON-stringify it so the Fetch Request can carry it as
92
+ // a JSON string body. Note: if Express's body parser was used, this
93
+ // means the body is serialized here and re-parsed in `parseBody` —
94
+ // a minor double-processing overhead. To avoid this, don't use
95
+ // Express body-parser middleware on riddl-mounted routes.
76
96
  const json = JSON.stringify(body);
77
- if (!headers.has("content-type")) {
78
- headers.set("content-type", "application/json; charset=utf-8");
97
+ if (!headers.has('content-type')) {
98
+ headers.set('content-type', 'application/json; charset=utf-8');
79
99
  }
80
100
  return json;
81
101
  };
82
102
  const getRequestUrl = (req) => {
83
103
  var _a;
84
- const rawUrl = "originalUrl" in req ? req.originalUrl || req.url || "/" : req.url || "/";
104
+ const rawUrl = 'originalUrl' in req ? req.originalUrl || req.url || '/' : req.url || '/';
85
105
  if (/^https?:\/\//i.test(rawUrl))
86
106
  return new URL(rawUrl);
87
- if ("protocol" in req) {
88
- return new URL(rawUrl, `${req.protocol}://${req.get("host")}`);
107
+ if ('protocol' in req) {
108
+ return new URL(rawUrl, `${req.protocol}://${req.get('host')}`);
89
109
  }
90
- const host = (_a = req.headers.host) !== null && _a !== void 0 ? _a : "localhost";
91
- const forwardedProto = req.headers["x-forwarded-proto"];
110
+ const host = (_a = req.headers.host) !== null && _a !== void 0 ? _a : 'localhost';
111
+ const forwardedProto = req.headers['x-forwarded-proto'];
92
112
  const proto = Array.isArray(forwardedProto)
93
113
  ? forwardedProto[0]
94
114
  : forwardedProto;
95
- const protocol = (proto === null || proto === void 0 ? void 0 : proto.split(",")[0].trim()) || "http";
115
+ const protocol = (proto === null || proto === void 0 ? void 0 : proto.split(',')[0].trim()) || 'http';
96
116
  return new URL(rawUrl, `${protocol}://${host}`);
97
117
  };
98
118
  export const toFetchRequest = (req) => {
99
119
  const headers = new Headers();
100
120
  for (const [key, value] of Object.entries(req.headers)) {
101
121
  if (Array.isArray(value))
102
- headers.append(key, value.join(","));
122
+ headers.append(key, value.join(','));
103
123
  else if (value != null)
104
124
  headers.set(key, value);
105
125
  }
106
- const method = req.method || "GET";
126
+ const method = req.method || 'GET';
127
+ // `rawBody` is set by some Express body-parsing middleware (e.g.,
128
+ // express.json()). If the middleware isn't present, `rawBody` is
129
+ // undefined and we fall through to using the parsed body or the raw
130
+ // stream itself.
107
131
  const expressReq = req;
108
- const parsedBody = "body" in expressReq ? expressReq.body : undefined;
109
- const rawBody = "rawBody" in expressReq ? expressReq.rawBody : undefined;
132
+ const parsedBody = 'body' in expressReq ? expressReq.body : undefined;
133
+ const rawBody = 'rawBody' in expressReq ? expressReq.rawBody : undefined;
110
134
  let body;
111
- if (method !== "GET" && method !== "HEAD") {
135
+ if (method !== 'GET' && method !== 'HEAD') {
112
136
  body = coerceBody(rawBody !== null && rawBody !== void 0 ? rawBody : parsedBody, headers);
113
137
  if (body === undefined && isNodeReadable(req) && !req.readableEnded) {
114
138
  body = req;
@@ -120,7 +144,7 @@ export const toFetchRequest = (req) => {
120
144
  body: body,
121
145
  };
122
146
  if (isNodeReadable(body)) {
123
- init.duplex = "half"; // required when body is a stream
147
+ init.duplex = 'half'; // required when body is a stream
124
148
  }
125
149
  return new Request(getRequestUrl(req).toString(), init);
126
150
  };
@@ -1,5 +1,5 @@
1
- import { GenericApiEndpoint } from './types';
2
- export declare const parseBody: (req: Request, module: GenericApiEndpoint, tempFiles?: string[]) => Promise<unknown>;
3
- export declare const parseQuery: (request: Request, module: GenericApiEndpoint) => Promise<Record<string, string | string[]>>;
4
- export declare const parseParam: (params: Record<string, string>, module: GenericApiEndpoint) => Promise<Record<string, string | never>>;
1
+ import type { GenericApiEndpoint } from './types';
2
+ export declare const parseBody: (req: Request, module: GenericApiEndpoint, tempFiles?: string[], maxBodySize?: number) => Promise<unknown>;
3
+ export declare const parseQuery: (request: Request, module: GenericApiEndpoint) => Record<string, string | string[]>;
4
+ export declare const parseParam: (params: Record<string, string>, module: GenericApiEndpoint) => Record<string, string | never>;
5
5
  export declare const parseOutput: (out: unknown, module: GenericApiEndpoint) => Promise<Record<string, unknown> | unknown[]>;
@@ -3,22 +3,10 @@ import { log } from './log.js';
3
3
  import { z } from './zod.js';
4
4
  import { error } from './errors.js';
5
5
  import { parseMultipart } from './multipart.js';
6
- const parseJsonBody = async (req) => {
7
- var _a;
8
- const clonedRequest = req.clone();
9
- if ((_a = clonedRequest.headers.get('content-type')) === null || _a === void 0 ? void 0 : _a.startsWith('application/json')) {
10
- try {
11
- const json = await clonedRequest.json();
12
- if (typeof json === 'object') {
13
- return json;
14
- }
15
- }
16
- catch {
17
- throw error(400, 'Invalid JSON body');
18
- }
19
- }
20
- };
21
- export const parseBody = async (req, module, tempFiles = []) => {
6
+ import { MAX_BODY_SIZE_DEFAULT } from './constants.js';
7
+ /** Cached default schema — avoids recreating z.object({}) on every request. */
8
+ const EMPTY_SCHEMA = z.object({});
9
+ export const parseBody = async (req, module, tempFiles = [], maxBodySize = MAX_BODY_SIZE_DEFAULT) => {
22
10
  var _a, _b;
23
11
  let body = {};
24
12
  // GET, HEAD, DELETE, OPTIONS have no body
@@ -27,27 +15,32 @@ export const parseBody = async (req, module, tempFiles = []) => {
27
15
  return body;
28
16
  }
29
17
  const contentType = (_a = req.headers.get('content-type')) !== null && _a !== void 0 ? _a : '';
18
+ // Enforce body size limit via Content-Length when available
19
+ const contentLength = req.headers.get('content-length');
20
+ if (contentLength) {
21
+ const size = parseInt(contentLength, 10);
22
+ if (!Number.isNaN(size) && size > maxBodySize) {
23
+ throw error(413, 'Request body too large');
24
+ }
25
+ }
26
+ // parseBody is the sole consumer of the request body — no clone() needed.
30
27
  // JSON body
31
28
  if (contentType.startsWith('application/json')) {
32
- body = await req
33
- .clone()
34
- .json()
35
- .catch(() => {
29
+ body = await req.json().catch(() => {
36
30
  throw error(400, 'Invalid JSON body');
37
31
  });
38
32
  }
39
33
  // Multipart form body: stream uploads to disk rather than buffering the
40
- // whole payload in memory. Consume `req` directly (no `clone()` — a tee
41
- // would re-buffer the unread branch, defeating the point).
34
+ // whole payload in memory. Consume `req` directly.
42
35
  else if (contentType.startsWith('multipart/form-data')) {
43
- const result = await parseMultipart(req);
36
+ const result = await parseMultipart(req, maxBodySize);
44
37
  tempFiles.push(...result.tempFiles);
45
38
  body = result.body;
46
39
  }
47
40
  // URL-encoded form body (always small — buffering is fine)
48
41
  else if (contentType.startsWith('application/x-www-form-urlencoded')) {
49
42
  const formBody = {};
50
- const form = await req.clone().formData();
43
+ const form = await req.formData();
51
44
  for (const [key, value] of form.entries()) {
52
45
  if (formBody[key]) {
53
46
  const existing = formBody[key];
@@ -66,15 +59,15 @@ export const parseBody = async (req, module, tempFiles = []) => {
66
59
  }
67
60
  // Text body
68
61
  else if (contentType.startsWith('text/plain')) {
69
- body = { text: await req.clone().text() };
62
+ body = { text: await req.text() };
70
63
  }
71
64
  // Binary body
72
65
  else if (contentType.startsWith('application/octet-stream')) {
73
- const buffer = await req.clone().arrayBuffer();
66
+ const buffer = await req.arrayBuffer();
74
67
  body = { buffer };
75
68
  }
76
69
  log('body: %O', body);
77
- const validator = (_b = module.Input) !== null && _b !== void 0 ? _b : z.object({});
70
+ const validator = (_b = module.Input) !== null && _b !== void 0 ? _b : EMPTY_SCHEMA;
78
71
  const validation = validator.safeParse(body);
79
72
  if (!validation.success) {
80
73
  throw error(400, `Invalid body.\n${fromZodError(validation.error).message}`);
@@ -84,7 +77,7 @@ export const parseBody = async (req, module, tempFiles = []) => {
84
77
  }
85
78
  return body;
86
79
  };
87
- export const parseQuery = async (request, module) => {
80
+ export const parseQuery = (request, module) => {
88
81
  var _a;
89
82
  let query = {};
90
83
  for (const [key, value] of new URL(request.url).searchParams.entries()) {
@@ -102,7 +95,7 @@ export const parseQuery = async (request, module) => {
102
95
  }
103
96
  }
104
97
  log('query: %O', query);
105
- const validator = (_a = module.Query) !== null && _a !== void 0 ? _a : z.object({});
98
+ const validator = (_a = module.Query) !== null && _a !== void 0 ? _a : EMPTY_SCHEMA;
106
99
  const validation = validator.safeParse(query);
107
100
  if (!validation.success) {
108
101
  throw error(400, `Invalid query.\n${fromZodError(validation.error).message}`);
@@ -112,10 +105,10 @@ export const parseQuery = async (request, module) => {
112
105
  }
113
106
  return query;
114
107
  };
115
- export const parseParam = async (params, module) => {
108
+ export const parseParam = (params, module) => {
116
109
  var _a;
117
110
  log('param: %O', params);
118
- const validator = (_a = module.Param) !== null && _a !== void 0 ? _a : z.object({});
111
+ const validator = (_a = module.Param) !== null && _a !== void 0 ? _a : EMPTY_SCHEMA;
119
112
  const validation = validator.safeParse(params);
120
113
  if (!validation.success) {
121
114
  throw error(400, `Invalid param.\n${fromZodError(validation.error).message}`);
@@ -131,8 +124,8 @@ export const parseOutput = async (out, module) => {
131
124
  : out && typeof out === 'object'
132
125
  ? out
133
126
  : {};
134
- const validator = (_a = module.Output) !== null && _a !== void 0 ? _a : z.object({});
135
- const validation = await validator.spa(output);
127
+ const validator = (_a = module.Output) !== null && _a !== void 0 ? _a : EMPTY_SCHEMA;
128
+ const validation = validator.safeParse(output);
136
129
  if (!validation.success) {
137
130
  const details = fromZodError(validation.error).message;
138
131
  log.extend('error')('output: %O failed validation: %O', output, validation.error);
package/dist/zod.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { z } from "zod";
1
+ import { z } from 'zod';
2
2
  export { z };
package/dist/zod.js CHANGED
@@ -1,4 +1,4 @@
1
- import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
2
- import { z } from "zod";
1
+ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
2
+ import { z } from 'zod';
3
3
  extendZodWithOpenApi(z);
4
4
  export { z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riddl/core",
3
- "version": "0.2.7",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -9,7 +9,6 @@
9
9
  "debug": "^4.4.0",
10
10
  "express": "^5.2.1",
11
11
  "set-cookie-parser": "^3.0.1",
12
- "sse.js": "^2.5.0",
13
12
  "zod": "^4.4.3",
14
13
  "zod-validation-error": "^5.0.0"
15
14
  },
@@ -32,17 +31,23 @@
32
31
  "concurrently": "^9.1.2",
33
32
  "eslint": "^9.17.0",
34
33
  "eslint-config-prettier": "^9.1.0",
34
+ "@eslint/js": "^9.17.0",
35
+ "eslint-plugin-prettier": "^5.5.1",
35
36
  "openapi3-ts": "^4.5.0",
36
37
  "prettier": "^3.4.2",
37
38
  "tsc-alias": "^1.8.10",
38
39
  "type-fest": "^4.31.0",
39
- "typescript": "^5.7.2"
40
+ "typescript": "^5.7.2",
41
+ "vitest": "^5.0.4"
40
42
  },
41
43
  "scripts": {
42
44
  "lint": "prettier --check . && eslint .",
43
45
  "format": "prettier --write .",
44
46
  "clean": "rm -f tsconfig.tsbuildinfo dist",
45
47
  "build": "tsc && tsc-alias",
46
- "build:watch": "tsc && tsc-alias && (concurrently \"tsc -w\" \"tsc-alias -w\")"
48
+ "build:watch": "tsc && tsc-alias && (concurrently \"tsc -w\" \"tsc-alias -w\")",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "prepublishOnly": "pnpm build"
47
52
  }
48
- }
53
+ }
File without changes
@@ -1 +0,0 @@
1
- "use strict";