@riddl/core 0.1.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 All Peep Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ import type { Handler } from "./types";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import express, { type Router, Application } from "express";
4
+ export declare const mountApi: (app: Application, prefix: string, apiHandler: Handler) => express.Application;
5
+ export declare const setResponse: (res: ServerResponse, response: Response) => Promise<void>;
6
+ export declare const rawAdapter: (apiHandler: Handler) => (req: IncomingMessage, res: ServerResponse) => Promise<void>;
7
+ export declare const expressAdapter: (apiHandler: Handler) => Router;
@@ -0,0 +1,73 @@
1
+ import express from "express";
2
+ import { toFetchRequest } from "./utils.js";
3
+ import * as set_cookie_parser from "set-cookie-parser";
4
+ export const mountApi = (app, prefix, apiHandler) => app.use(prefix, expressAdapter(apiHandler));
5
+ export const setResponse = async (res, response) => {
6
+ for (const [key, value] of response.headers) {
7
+ try {
8
+ res.setHeader(key, key === 'set-cookie'
9
+ ? set_cookie_parser.splitCookiesString(response.headers.get(key))
10
+ : value);
11
+ }
12
+ catch (error) {
13
+ res.getHeaderNames().forEach((name) => res.removeHeader(name));
14
+ res.writeHead(500).end(String(error));
15
+ return;
16
+ }
17
+ }
18
+ res.writeHead(response.status);
19
+ if (!response.body) {
20
+ res.end();
21
+ return;
22
+ }
23
+ if (response.body.locked) {
24
+ res.end('Fatal error: Response body is locked. ' +
25
+ "This can happen when the response was already read (for example through 'response.json()' or 'response.text()').");
26
+ return;
27
+ }
28
+ const reader = response.body.getReader();
29
+ if (res.destroyed) {
30
+ void reader.cancel();
31
+ return;
32
+ }
33
+ const cancel = (error) => {
34
+ res.off('close', cancel);
35
+ res.off('error', cancel);
36
+ // If the reader has already been interrupted with an error earlier,
37
+ // then it will appear here, it is useless, but it needs to be catch.
38
+ reader.cancel(error).catch(() => { });
39
+ if (error)
40
+ res.destroy(error);
41
+ };
42
+ res.on('close', cancel);
43
+ res.on('error', cancel);
44
+ void next();
45
+ async function next() {
46
+ try {
47
+ for (;;) {
48
+ const { done, value } = await reader.read();
49
+ if (done)
50
+ break;
51
+ if (!res.write(value)) {
52
+ res.once('drain', next);
53
+ return;
54
+ }
55
+ }
56
+ res.end();
57
+ }
58
+ catch (error) {
59
+ cancel(error instanceof Error ? error : new Error(String(error)));
60
+ }
61
+ }
62
+ };
63
+ export const rawAdapter = (apiHandler) => (async (req, res) => {
64
+ const response = await apiHandler(toFetchRequest(req));
65
+ res.statusCode = response.status;
66
+ res.setHeader("Content-Type", "application/json");
67
+ res.setHeader("Content-Length", "0");
68
+ response.headers.forEach((value, key) => {
69
+ res.setHeader(key, value);
70
+ });
71
+ await setResponse(res, response);
72
+ });
73
+ export const expressAdapter = (apiHandler) => express.Router().use(rawAdapter(apiHandler));
package/dist/api.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { APIOptions, API } from "./types";
2
+ export declare const api: ({ routeModuleMap, config, base, cors, registry, }: APIOptions) => Promise<API>;
package/dist/api.js ADDED
@@ -0,0 +1,128 @@
1
+ import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
2
+ import { json, recursiveAwait } from "./utils.js";
3
+ import { CORS_DEFAULT, METHOD } from "./constants.js";
4
+ import { log } from "./log.js";
5
+ import { parseBody, parseOutput, parseParam, parseQuery } from "./validation.js";
6
+ import { parseRouteId } from "./parser.js";
7
+ import { buildRouter } from "./routing.js";
8
+ import { openapiHandler, registerRoutes } from "./openapi.js";
9
+ import { error } from "./errors.js";
10
+ export const api = async ({ routeModuleMap, config, base = "/api", cors = true, registry = new OpenAPIRegistry(), }) => {
11
+ const { routes, middlewares } = await Object.entries(routeModuleMap)
12
+ .reduce(async (accPromise, [path, module]) => {
13
+ var _a, _b, _c;
14
+ const acc = await accPromise;
15
+ const parts = path.split("/");
16
+ const last = parts[parts.length - 1].split(".")[0];
17
+ parts[parts.length - 1] = last;
18
+ const id = parts.slice(0, -1).join("/");
19
+ if (METHOD.test(last)) {
20
+ const method = last.toUpperCase();
21
+ const endpoint = (await module()).apiEndpoint;
22
+ acc.routes[id] = {
23
+ matcher: parseRouteId(id),
24
+ handlers: {
25
+ ...((_c = (_b = (_a = acc.routes) === null || _a === void 0 ? void 0 : _a[id]) === null || _b === void 0 ? void 0 : _b.handlers) !== null && _c !== void 0 ? _c : {}),
26
+ [method]: endpoint,
27
+ }
28
+ };
29
+ }
30
+ if (last === "middleware") {
31
+ acc.middlewares[id] = (await module());
32
+ }
33
+ return acc;
34
+ }, Promise.resolve({ routes: {}, middlewares: {} }));
35
+ log("routes: %O", routes);
36
+ log("middlewares: %O", middlewares);
37
+ log("config: %O", config);
38
+ log("base: %s", base);
39
+ registerRoutes({ routes, registry, base });
40
+ const router = buildRouter(routes, middlewares);
41
+ const effectiveCorsSettings = cors ? typeof cors === "object" ? cors : CORS_DEFAULT : undefined;
42
+ const handler = async (request) => {
43
+ var _a, _b, _c;
44
+ try {
45
+ log("request: %O", request);
46
+ const event = {
47
+ request,
48
+ url: new URL(request.url),
49
+ context: {},
50
+ };
51
+ const { id, params, routeWithMiddlewares } = (_a = router(event.url.pathname)) !== null && _a !== void 0 ? _a : {};
52
+ if (!id || !params || !routeWithMiddlewares) {
53
+ throw error(404, "Route not found");
54
+ }
55
+ log("route id: %s", id);
56
+ // handle OPTIONS
57
+ if (request.method.toUpperCase() === "OPTIONS") {
58
+ return new Response(null, {
59
+ headers: (_b = effectiveCorsSettings) !== null && _b !== void 0 ? _b : {}
60
+ });
61
+ }
62
+ const endpoint = routeWithMiddlewares.handlers[request.method.toUpperCase()];
63
+ if (!(endpoint === null || endpoint === void 0 ? void 0 : endpoint.handle)) {
64
+ throw error(405, "Method not allowed");
65
+ }
66
+ const param = await parseParam(params !== null && params !== void 0 ? params : {}, endpoint);
67
+ const query = await parseQuery(request, endpoint);
68
+ const body = await parseBody(request, endpoint);
69
+ log("handler: %O", endpoint.handle);
70
+ const output = await endpoint.handle({ body, query, param, event });
71
+ log("output: %O", output);
72
+ if (output instanceof Response) {
73
+ if (effectiveCorsSettings) {
74
+ for (const [key, value] of Object.entries(effectiveCorsSettings)) {
75
+ output.headers.set(key, value);
76
+ }
77
+ }
78
+ return output;
79
+ }
80
+ // ReadableStream for Server-Sent Events
81
+ if (output instanceof ReadableStream) {
82
+ const res = new Response(output, {
83
+ headers: {
84
+ "Content-Type": "text/event-stream",
85
+ "Cache-Control": "no-cache",
86
+ Connection: "keep-alive",
87
+ ...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
88
+ },
89
+ });
90
+ return res;
91
+ }
92
+ if (output instanceof ArrayBuffer) {
93
+ const res = new Response(output, {
94
+ headers: {
95
+ "Content-Type": "application/octet-stream",
96
+ ...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
97
+ },
98
+ });
99
+ return res;
100
+ }
101
+ else {
102
+ const out = await recursiveAwait(await parseOutput(output, endpoint));
103
+ const res = json(out, {
104
+ headers: (_c = effectiveCorsSettings) !== null && _c !== void 0 ? _c : {},
105
+ });
106
+ return res;
107
+ }
108
+ }
109
+ catch (e) {
110
+ if ('__HTTP_ERROR__' in e) {
111
+ return json(e, {
112
+ status: e.status,
113
+ });
114
+ }
115
+ log("internal error: %O", e);
116
+ return json({ message: 'Internal server error', status: 500 }, {
117
+ status: 500,
118
+ });
119
+ }
120
+ };
121
+ return {
122
+ handler,
123
+ openapi: {
124
+ registry,
125
+ handler: openapiHandler(registry, config),
126
+ },
127
+ };
128
+ };
@@ -0,0 +1,6 @@
1
+ export declare const METHOD: RegExp;
2
+ 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";
6
+ };
@@ -0,0 +1,6 @@
1
+ export const METHOD = /^(GET|POST|PUT|DELETE|PATCH|OPTIONS)$/i;
2
+ 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",
6
+ };
@@ -0,0 +1,4 @@
1
+ import type { EndpointHandler, EndpointSpecification, APIEndpoint, ParamType, QueryType, InputType, OutputType, StreamType, ErrorType } from "./types";
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
+ handler: (handle: EndpointHandler<P, Q, I, O>) => APIEndpoint<P, Q, I, O, S, E>;
4
+ };
@@ -0,0 +1,3 @@
1
+ export const endpoint = (data = {}) => ({
2
+ handler: (handle) => ({ ...data, handle }),
3
+ });
@@ -0,0 +1,6 @@
1
+ export declare const error: (status: number, message: string, key?: string) => {
2
+ __HTTP_ERROR__: boolean;
3
+ status: number;
4
+ message: string;
5
+ key: string | undefined;
6
+ };
package/dist/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ export const error = (status, message, key) => ({
2
+ __HTTP_ERROR__: true,
3
+ status,
4
+ message,
5
+ key,
6
+ });
@@ -0,0 +1,6 @@
1
+ export * from "./api";
2
+ export * from "./openapi";
3
+ export * from "./zod";
4
+ export * from "./adapters";
5
+ export * from "./types";
6
+ export * from "./endpoint";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
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";
package/dist/log.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import debug from "debug";
2
+ export declare const log: debug.Debugger;
package/dist/log.js ADDED
@@ -0,0 +1,2 @@
1
+ import debug from "debug";
2
+ export const log = debug("riddl");
File without changes
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1,4 @@
1
+ import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
2
+ import type { OpenAPIOptions, OpenAPIObjectConfig } from "./types";
3
+ export declare const registerRoutes: ({ routes, registry, base, }: OpenAPIOptions) => void;
4
+ export declare const openapiHandler: (registry: OpenAPIRegistry, config: OpenAPIObjectConfig) => (request: Request) => Promise<Response>;
@@ -0,0 +1,113 @@
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("/")
6
+ .replace(/^\./, base)
7
+ .replace(/\[(\.{3})?(.+?)\]/g, "{$2}")
8
+ .replace(/\(.+\)\//g, "");
9
+ export const registerRoutes = ({ routes, registry, base = "/api", }) => {
10
+ for (const [id, { handlers }] of Object.entries(routes)) {
11
+ Object.entries(handlers).forEach(([methodString, module]) => {
12
+ var _a;
13
+ const modifier = module.Modifier || ((r) => r);
14
+ const query = module.Query || z.object({});
15
+ const params = module.Param || z.object({});
16
+ const body = module.Input || undefined;
17
+ const path = openApiPath(id, base);
18
+ const method = methodString.toLowerCase();
19
+ registry.registerPath(modifier({
20
+ method,
21
+ path,
22
+ request: {
23
+ params,
24
+ query,
25
+ body: body
26
+ ? {
27
+ description: "",
28
+ content: {
29
+ "application/json": {
30
+ schema: body,
31
+ },
32
+ "application/x-www-form-urlencoded": {
33
+ schema: body,
34
+ },
35
+ "multipart/form-data": {
36
+ schema: body,
37
+ },
38
+ },
39
+ }
40
+ : undefined,
41
+ },
42
+ 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),
67
+ ...(query || module.Param || body
68
+ ? {
69
+ "400": {
70
+ description: "Invalid input (path parameters, query string, or body)",
71
+ content: {
72
+ "application/json": {
73
+ schema: z.object({
74
+ message: z.string(),
75
+ }),
76
+ },
77
+ },
78
+ },
79
+ }
80
+ : undefined),
81
+ ...((_a = Object.fromEntries((module.Error ? Object.entries(module.Error) : []).map(([status, error]) => [
82
+ status,
83
+ {
84
+ description: error.message,
85
+ },
86
+ ]))) !== null && _a !== void 0 ? _a : {}),
87
+ },
88
+ }));
89
+ });
90
+ }
91
+ };
92
+ export const openapiHandler = (registry, config) => {
93
+ let document = undefined;
94
+ return async (request) => {
95
+ if (!document) {
96
+ const url = new URL(request.url).origin;
97
+ const generator = new OpenApiGeneratorV3(registry.definitions);
98
+ document = generator.generateDocument({
99
+ servers: [
100
+ {
101
+ url,
102
+ },
103
+ ],
104
+ ...config,
105
+ });
106
+ }
107
+ return new Response(JSON.stringify(document), {
108
+ headers: {
109
+ "Content-Type": "application/json",
110
+ },
111
+ });
112
+ };
113
+ };
@@ -0,0 +1,29 @@
1
+ import type { RouteParam } from "./types";
2
+ export declare const parseRouteId: (id: string) => {
3
+ pattern: RegExp;
4
+ params: RouteParam[];
5
+ };
6
+ /**
7
+ * Removes optional params from a route ID.
8
+ */
9
+ export declare const removeOptionalParams: (id: string) => string;
10
+ /**
11
+ * Splits a route id into its segments, removing segments that
12
+ * don't affect the path (i.e. groups). The root route is represented by `/`
13
+ * and will be returned as `['']`.
14
+ */
15
+ 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 ADDED
@@ -0,0 +1,143 @@
1
+ const paramPattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;
2
+ export const parseRouteId = (id) => {
3
+ const params = [];
4
+ const pattern = id === '/'
5
+ ? /^\/$/
6
+ : new RegExp(`^${getRouteSegments(id)
7
+ .map((segment) => {
8
+ // special case — /[...rest]/ could contain zero segments
9
+ const restMatch = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment);
10
+ if (restMatch) {
11
+ params.push({
12
+ name: restMatch[1],
13
+ matcher: restMatch[2],
14
+ optional: false,
15
+ rest: true,
16
+ chained: true
17
+ });
18
+ return '(?:/([^]*))?';
19
+ }
20
+ // special case — /[[optional]]/ could contain zero segments
21
+ const optionalMatch = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment);
22
+ if (optionalMatch) {
23
+ params.push({
24
+ name: optionalMatch[1],
25
+ matcher: optionalMatch[2],
26
+ optional: true,
27
+ rest: false,
28
+ chained: true
29
+ });
30
+ return '(?:/([^/]+))?';
31
+ }
32
+ if (!segment) {
33
+ return;
34
+ }
35
+ const parts = segment.split(/\[(.+?)\](?!\])/);
36
+ const result = parts
37
+ .map((content, i) => {
38
+ if (i % 2) {
39
+ if (content.startsWith('x+')) {
40
+ return escape(String.fromCharCode(parseInt(content.slice(2), 16)));
41
+ }
42
+ if (content.startsWith('u+')) {
43
+ return escape(String.fromCharCode(...content
44
+ .slice(2)
45
+ .split('-')
46
+ .map((code) => parseInt(code, 16))));
47
+ }
48
+ const match = (paramPattern.exec(content));
49
+ if (!match) {
50
+ throw new Error(`Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`);
51
+ }
52
+ const [, isOptional, isRest, name, matcher] = match;
53
+ // It's assumed that the following invalid route id cases are already checked
54
+ // - unbalanced brackets
55
+ // - optional param following rest param
56
+ params.push({
57
+ name,
58
+ matcher,
59
+ optional: !!isOptional,
60
+ rest: !!isRest,
61
+ chained: isRest ? i === 1 && parts[0] === '' : false
62
+ });
63
+ return isRest ? '([^]*?)' : isOptional ? '([^/]*)?' : '([^/]+?)';
64
+ }
65
+ return escape(content);
66
+ })
67
+ .join('');
68
+ return '/' + result;
69
+ })
70
+ .join('')}/?$`);
71
+ return { pattern, params };
72
+ };
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
+ function affectsPath(segment) {
84
+ return segment !== '' && !/^\([^)]+\)$/.test(segment);
85
+ }
86
+ /**
87
+ * Splits a route id into its segments, removing segments that
88
+ * don't affect the path (i.e. groups). The root route is represented by `/`
89
+ * and will be returned as `['']`.
90
+ */
91
+ export const getRouteSegments = (route) => {
92
+ return route.slice(1).split('/').filter(affectsPath);
93
+ };
94
+ const escape = (str) => {
95
+ return (str
96
+ .normalize()
97
+ // escape [ and ] before escaping other characters, since they are used in the replacements
98
+ .replace(/[[\]]/g, '\\$&')
99
+ // replace %, /, ? and # with their encoded versions because decode_pathname leaves them untouched
100
+ .replace(/%/g, '%25')
101
+ .replace(/\//g, '%2[Ff]')
102
+ .replace(/\?/g, '%3[Ff]')
103
+ .replace(/#/g, '%23')
104
+ // escape characters that have special meaning in regex
105
+ .replace(/[.*+?^${}()|\\]/g, '\\$&'));
106
+ };
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
+ };
@@ -0,0 +1,14 @@
1
+ import type { MiddlewareMap, RouteModuleMap, RouteParam } from "./types";
2
+ export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: MiddlewareMap) => (path: string) => {
3
+ id: string;
4
+ params: Record<string, string> | undefined;
5
+ routeWithMiddlewares: {
6
+ middlewares: import("./types").Middleware[];
7
+ matcher: {
8
+ pattern: RegExp;
9
+ params: RouteParam[];
10
+ };
11
+ handlers: Record<import("./types").Method, import("./types").GenericApiEndpoint>;
12
+ id: string;
13
+ };
14
+ } | undefined;
@@ -0,0 +1,78 @@
1
+ const exec = (match, params, matchers) => {
2
+ const result = {};
3
+ const values = match.slice(1);
4
+ const valuesNeedingMatch = values.filter((value) => value !== undefined);
5
+ let buffered = 0;
6
+ for (let i = 0; i < params.length; i += 1) {
7
+ const param = params[i];
8
+ let value = values[i - buffered];
9
+ // in the `[[a=b]]/.../[...rest]` case, if one or more optional parameters
10
+ // weren't matched, roll the skipped values into the rest
11
+ if (param.chained && param.rest && buffered) {
12
+ value = values
13
+ .slice(i - buffered, i + 1)
14
+ .filter((s) => s)
15
+ .join('/');
16
+ buffered = 0;
17
+ }
18
+ // if `value` is undefined, it means this is an optional or rest parameter
19
+ if (value === undefined) {
20
+ if (param.rest) {
21
+ // We need to allow the matcher to run so that it can decide if this optional rest param should be allowed to match
22
+ value = '';
23
+ }
24
+ else {
25
+ continue;
26
+ }
27
+ }
28
+ if (!param.matcher || matchers[param.matcher](value)) {
29
+ result[param.name] = value;
30
+ // Now that the params match, reset the buffer if the next param isn't the [...rest]
31
+ // and the next value is defined, otherwise the buffer will cause us to skip values
32
+ const nextParam = params[i + 1];
33
+ const nextValue = values[i + 1];
34
+ if (nextParam && !nextParam.rest && nextParam.optional && nextValue && param.chained) {
35
+ buffered = 0;
36
+ }
37
+ // There are no more params and no more values, but all non-empty values have been matched
38
+ if (!nextParam &&
39
+ !nextValue &&
40
+ Object.keys(result).length === valuesNeedingMatch.length) {
41
+ buffered = 0;
42
+ }
43
+ continue;
44
+ }
45
+ // in the `/[[a=b]]/...` case, if the value didn't satisfy the matcher,
46
+ // keep track of the number of skipped optional parameters and continue
47
+ if (param.optional && param.chained) {
48
+ buffered++;
49
+ continue;
50
+ }
51
+ // otherwise, if the matcher returns `false`, the route did not match
52
+ return;
53
+ }
54
+ if (buffered)
55
+ return;
56
+ return result;
57
+ };
58
+ const routeSorter = (a, b) => {
59
+ return a.id.length - b.id.length;
60
+ };
61
+ export const buildRouter = (routesMap, middlewaresMap) => {
62
+ const middlewareEntries = Object.entries(middlewaresMap).sort((a, b) => a[0].length - b[0].length);
63
+ const routesWithMiddlewaresList = Object.entries(routesMap).map(([id, route]) => ({
64
+ id,
65
+ ...route,
66
+ middlewares: middlewareEntries.filter(([middlewareId]) => id.startsWith(middlewareId)).map(([_, middleware]) => middleware),
67
+ })).sort(routeSorter);
68
+ return (path) => {
69
+ for (const route of routesWithMiddlewaresList) {
70
+ const { pattern } = route.matcher;
71
+ const matches = pattern.exec(path);
72
+ if (matches) {
73
+ return { id: route.id, params: exec(matches, route.matcher.params, {}), routeWithMiddlewares: route };
74
+ }
75
+ }
76
+ return undefined;
77
+ };
78
+ };
@@ -0,0 +1,147 @@
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";
7
+ export interface HttpError {
8
+ status: number;
9
+ key?: string;
10
+ message?: string;
11
+ properties?: Record<string, string>;
12
+ }
13
+ export interface RouteParam {
14
+ name: string;
15
+ matcher: string;
16
+ optional: boolean;
17
+ rest: boolean;
18
+ chained: boolean;
19
+ }
20
+ export type Middleware = {
21
+ pre: (event: RequestEvent) => Promise<RequestEvent>;
22
+ post: (event: RequestEvent) => Promise<RequestEvent>;
23
+ };
24
+ export interface ApiRoute {
25
+ matcher: {
26
+ pattern: RegExp;
27
+ params: RouteParam[];
28
+ };
29
+ handlers: Record<Method, GenericApiEndpoint>;
30
+ }
31
+ export interface ApiRouteWithMiddlewares extends ApiRoute {
32
+ middlewares: Middleware[];
33
+ }
34
+ export type RouteModuleMap = Record<string, {
35
+ matcher: {
36
+ pattern: RegExp;
37
+ params: RouteParam[];
38
+ };
39
+ handlers: Record<Method, GenericApiEndpoint>;
40
+ }>;
41
+ export type RouteWithMiddlewaresMap = Record<string, ApiRouteWithMiddlewares>;
42
+ export type MiddlewareMap = Record<string, Middleware>;
43
+ export type MatcherMap = Record<string, {
44
+ pattern: RegExp;
45
+ params: RouteParam[];
46
+ }>;
47
+ export interface APIContext {
48
+ routes: RouteWithMiddlewaresMap;
49
+ matchers: MatcherMap;
50
+ }
51
+ /**
52
+ * Modify route config after parsing input-output shapes.
53
+ * Useful for adding custom tags, etc.
54
+ *
55
+ * @param r Route config
56
+ * @returns Modified route config
57
+ * @example
58
+ * ```ts
59
+ * export const Modifier: RouteModifier = (r) => {
60
+ * r.tags = ["Tag"];
61
+ * r.operationId = "customOperationId";
62
+ * r.security = [{ bearerAuth: [] }];
63
+ * return r;
64
+ * };
65
+ * ```
66
+ */
67
+ export type EndpointModifier = (r: RouteConfig) => RouteConfig;
68
+ export interface CORS {
69
+ "Access-Control-Allow-Origin": string;
70
+ "Access-Control-Allow-Methods": string;
71
+ "Access-Control-Allow-Headers": string;
72
+ }
73
+ export interface APIOptions {
74
+ routeModuleMap: Record<string, () => Promise<unknown>>;
75
+ config: OpenAPIObjectConfig;
76
+ base?: string;
77
+ cors?: boolean | CORS;
78
+ registry?: OpenAPIRegistry;
79
+ }
80
+ export interface OpenAPIOptions {
81
+ base?: string;
82
+ routes: RouteModuleMap;
83
+ registry: OpenAPIRegistry;
84
+ }
85
+ export interface EndpointHandler<P, Q, I, O> {
86
+ ({ body, query, param, event }: {
87
+ body: z.infer<I>;
88
+ query: z.infer<Q>;
89
+ param: z.infer<P>;
90
+ event: RequestEvent;
91
+ }): Promise<z.input<O>>;
92
+ }
93
+ export type ParamType = z.ZodObject;
94
+ export type QueryType = z.ZodObject;
95
+ export type InputType = z.ZodType;
96
+ export type OutputType = z.ZodType;
97
+ export type StreamType = z.ZodType;
98
+ export type ErrorType = Record<string, HttpError>;
99
+ export interface EndpointSpecification<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
100
+ Param?: P;
101
+ Query?: Q;
102
+ Input?: I;
103
+ Output?: O;
104
+ Stream?: S;
105
+ Error?: E;
106
+ Modifier?: EndpointModifier;
107
+ }
108
+ export interface APIEndpoint<P extends ParamType = z.ZodObject<Record<never, never>>, Q extends QueryType = z.ZodObject<Record<never, never>>, I extends InputType = z.ZodObject<Record<never, never>>, O extends OutputType = z.ZodObject<Record<never, never>>, S extends StreamType = z.ZodObject<Record<never, never>>, E extends ErrorType = Record<string, HttpError>> extends EndpointSpecification<P, Q, I, O, S, E> {
109
+ /**
110
+ * Handler
111
+ */
112
+ handle?: EndpointHandler<P, Q, I, O>;
113
+ }
114
+ export interface EndpointBuilder<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
115
+ (spec: EndpointSpecification<P, Q, I, O, S, E>): ({
116
+ handler: () => APIEndpoint<P, Q, I, O, S, E>;
117
+ });
118
+ }
119
+ export interface EndpointStreamSetter<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
120
+ (data: APIEndpoint<P, Q, I, O, S, E>): EndpointBuilder<P, Q, I, O, S, E>;
121
+ }
122
+ export type GenericApiEndpoint = APIEndpoint<ParamType, QueryType, InputType, OutputType, StreamType, ErrorType>;
123
+ export interface GenericApiEndpointModule {
124
+ apiEndpoint: GenericApiEndpoint;
125
+ }
126
+ declare global {
127
+ namespace Riddl {
128
+ interface RequestContext {
129
+ }
130
+ }
131
+ }
132
+ export interface RequestEvent {
133
+ request: Request;
134
+ url: URL;
135
+ context: Riddl.RequestContext;
136
+ }
137
+ export interface Handler {
138
+ (request: Request): Promise<Response>;
139
+ }
140
+ export interface API {
141
+ handler: Handler;
142
+ openapi: {
143
+ registry: OpenAPIRegistry;
144
+ handler: Handler;
145
+ };
146
+ }
147
+ export type ParamMatcher = (param: string) => boolean;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { IncomingMessage } from "http";
2
+ import type { Request as ExpressRequest } from "express";
3
+ export declare const recursiveAwait: (obj: unknown) => unknown;
4
+ export declare const validateRouteId: (id: string) => void;
5
+ export declare const toFetchRequest: (req: IncomingMessage | ExpressRequest) => Request;
6
+ export declare function json(data: unknown, init?: ResponseInit): Response;
package/dist/utils.js ADDED
@@ -0,0 +1,135 @@
1
+ const textEncoder = new TextEncoder();
2
+ export const recursiveAwait = (obj) => {
3
+ if (obj instanceof Promise) {
4
+ return obj.then(recursiveAwait);
5
+ }
6
+ else if (obj instanceof Array) {
7
+ return Promise.all(obj.map(recursiveAwait));
8
+ }
9
+ else if (obj instanceof Object) {
10
+ const keys = Object.keys(obj);
11
+ const values = Object.values(obj);
12
+ return Promise.all(values.map(recursiveAwait)).then((values) => {
13
+ const returns = {};
14
+ for (let i = 0; i < keys.length; i++) {
15
+ returns[keys[i]] = values[i];
16
+ }
17
+ return returns;
18
+ });
19
+ }
20
+ else {
21
+ return obj;
22
+ }
23
+ };
24
+ const countOccurrences = (needle, haystack) => {
25
+ let count = 0;
26
+ for (let i = 0; i < haystack.length; i += 1) {
27
+ if (haystack[i] === needle)
28
+ count += 1;
29
+ }
30
+ return count;
31
+ };
32
+ export const validateRouteId = (id) => {
33
+ if (/\]\[/.test(id)) {
34
+ throw new Error(`Invalid route ${id} — parameters must be separated`);
35
+ }
36
+ if (countOccurrences('[', id) !== countOccurrences(']', id)) {
37
+ throw new Error(`Invalid route ${id} — brackets are unbalanced`);
38
+ }
39
+ if (/\[\.\.\.\w+\]\/\[\[/.test(id)) {
40
+ throw new Error(`Invalid route ${id} — an [[optional]] route segment cannot follow a [...rest] route segment`);
41
+ }
42
+ if (/\[\[\.\.\./.test(id)) {
43
+ throw new Error(`Invalid route ${id} — a rest route segment is always optional, remove the outer square brackets`);
44
+ }
45
+ };
46
+ const isNodeReadable = (value) => !!value && typeof value.pipe === "function";
47
+ const isBodyInit = (value) => {
48
+ if (typeof value === "string")
49
+ return true;
50
+ if (value instanceof ArrayBuffer)
51
+ return true;
52
+ if (ArrayBuffer.isView(value))
53
+ return true;
54
+ if (typeof Blob !== "undefined" && value instanceof Blob)
55
+ return true;
56
+ if (typeof FormData !== "undefined" && value instanceof FormData)
57
+ return true;
58
+ if (value instanceof URLSearchParams)
59
+ return true;
60
+ if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream)
61
+ return true;
62
+ if (isNodeReadable(value))
63
+ return true;
64
+ return false;
65
+ };
66
+ const coerceBody = (body, headers) => {
67
+ if (body == null)
68
+ return undefined;
69
+ if (isBodyInit(body))
70
+ return body;
71
+ const json = JSON.stringify(body);
72
+ if (!headers.has("content-type")) {
73
+ headers.set("content-type", "application/json; charset=utf-8");
74
+ }
75
+ return json;
76
+ };
77
+ const getRequestUrl = (req) => {
78
+ var _a;
79
+ const rawUrl = "originalUrl" in req ? req.originalUrl || req.url || "/" : req.url || "/";
80
+ if (/^https?:\/\//i.test(rawUrl))
81
+ return new URL(rawUrl);
82
+ if ("protocol" in req) {
83
+ return new URL(rawUrl, `${req.protocol}://${req.get("host")}`);
84
+ }
85
+ const host = (_a = req.headers.host) !== null && _a !== void 0 ? _a : "localhost";
86
+ const forwardedProto = req.headers["x-forwarded-proto"];
87
+ const proto = Array.isArray(forwardedProto)
88
+ ? forwardedProto[0]
89
+ : forwardedProto;
90
+ const protocol = (proto === null || proto === void 0 ? void 0 : proto.split(",")[0].trim()) || "http";
91
+ return new URL(rawUrl, `${protocol}://${host}`);
92
+ };
93
+ export const toFetchRequest = (req) => {
94
+ const headers = new Headers();
95
+ for (const [key, value] of Object.entries(req.headers)) {
96
+ if (Array.isArray(value))
97
+ headers.append(key, value.join(","));
98
+ else if (value != null)
99
+ headers.set(key, value);
100
+ }
101
+ const method = req.method || "GET";
102
+ const expressReq = req;
103
+ const parsedBody = "body" in expressReq ? expressReq.body : undefined;
104
+ const rawBody = "rawBody" in expressReq ? expressReq.rawBody : undefined;
105
+ let body;
106
+ if (method !== "GET" && method !== "HEAD") {
107
+ body = coerceBody(rawBody !== null && rawBody !== void 0 ? rawBody : parsedBody, headers);
108
+ if (body === undefined && isNodeReadable(req) && !req.readableEnded) {
109
+ body = req;
110
+ }
111
+ }
112
+ const init = {
113
+ method,
114
+ headers,
115
+ body: body,
116
+ };
117
+ if (isNodeReadable(body)) {
118
+ init.duplex = "half"; // required when body is a stream
119
+ }
120
+ return new Request(getRequestUrl(req).toString(), init);
121
+ };
122
+ export function json(data, init) {
123
+ const body = JSON.stringify(data);
124
+ const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
125
+ if (!headers.has('content-length')) {
126
+ headers.set('content-length', textEncoder.encode(body).byteLength.toString());
127
+ }
128
+ if (!headers.has('content-type')) {
129
+ headers.set('content-type', 'application/json');
130
+ }
131
+ return new Response(body, {
132
+ ...init,
133
+ headers,
134
+ });
135
+ }
@@ -0,0 +1,5 @@
1
+ import { GenericApiEndpoint } from "./types";
2
+ export declare const parseBody: (req: Request, module: GenericApiEndpoint) => 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>>;
5
+ export declare const parseOutput: (out: unknown, module: GenericApiEndpoint) => Promise<Record<string, unknown> | unknown[]>;
@@ -0,0 +1,130 @@
1
+ import { fromZodError } from "zod-validation-error";
2
+ import { log } from "./log.js";
3
+ import { z } from "./zod.js";
4
+ import { error } from "./errors.js";
5
+ const parseJsonBody = async (req) => {
6
+ var _a;
7
+ const clonedRequest = req.clone();
8
+ if ((_a = clonedRequest.headers.get("content-type")) === null || _a === void 0 ? void 0 : _a.startsWith("application/json")) {
9
+ try {
10
+ const json = await clonedRequest.json();
11
+ if (typeof json === "object") {
12
+ return json;
13
+ }
14
+ }
15
+ catch {
16
+ throw error(400, "Invalid JSON body");
17
+ }
18
+ }
19
+ };
20
+ export const parseBody = async (req, module) => {
21
+ var _a, _b, _c, _d, _e, _f;
22
+ let body = {};
23
+ // GET, HEAD, DELETE, OPTIONS have no body
24
+ const method = req.method.toUpperCase();
25
+ if (["GET", "HEAD", "DELETE", "OPTIONS"].includes(method)) {
26
+ return body;
27
+ }
28
+ const clonedRequest = req.clone();
29
+ // JSON body
30
+ if ((_a = clonedRequest.headers.get("content-type")) === null || _a === void 0 ? void 0 : _a.startsWith("application/json")) {
31
+ body = clonedRequest.json().catch(() => { throw error(400, "Invalid JSON body"); });
32
+ }
33
+ // Form body
34
+ else if (((_b = clonedRequest.headers
35
+ .get("content-type")) === null || _b === void 0 ? void 0 : _b.startsWith("application/x-www-form-urlencoded")) ||
36
+ ((_c = clonedRequest.headers.get("content-type")) === null || _c === void 0 ? void 0 : _c.startsWith("multipart/form-data"))) {
37
+ const formBody = {};
38
+ const form = await clonedRequest.formData();
39
+ for (const [key, value] of form.entries()) {
40
+ if (formBody[key]) {
41
+ const existing = formBody[key];
42
+ if (Array.isArray(existing)) {
43
+ existing.push(value);
44
+ }
45
+ else {
46
+ formBody[key] = [existing, value];
47
+ }
48
+ }
49
+ else {
50
+ formBody[key] = value;
51
+ }
52
+ }
53
+ body = formBody;
54
+ }
55
+ // Text body
56
+ else if ((_d = clonedRequest.headers.get("content-type")) === null || _d === void 0 ? void 0 : _d.startsWith("text/plain")) {
57
+ body = { text: await clonedRequest.text() };
58
+ }
59
+ // Binary body
60
+ else if ((_e = clonedRequest.headers.get("content-type")) === null || _e === void 0 ? void 0 : _e.startsWith("application/octet-stream")) {
61
+ const buffer = await clonedRequest.arrayBuffer();
62
+ body = { buffer };
63
+ }
64
+ log("body: %O", body);
65
+ const validator = (_f = module.Input) !== null && _f !== void 0 ? _f : z.object({});
66
+ const validation = validator.safeParse(body);
67
+ if (!validation.success) {
68
+ throw error(400, `Invalid body.\n${fromZodError(validation.error).message}`);
69
+ }
70
+ else {
71
+ body = validation.data;
72
+ }
73
+ return body;
74
+ };
75
+ export const parseQuery = async (request, module) => {
76
+ var _a;
77
+ let query = {};
78
+ for (const [key, value] of new URL(request.url).searchParams.entries()) {
79
+ if (query[key]) {
80
+ const existing = query[key];
81
+ if (Array.isArray(existing)) {
82
+ existing.push(value);
83
+ }
84
+ else {
85
+ query[key] = [existing, value];
86
+ }
87
+ }
88
+ else {
89
+ query[key] = value;
90
+ }
91
+ }
92
+ log("query: %O", query);
93
+ const validator = (_a = module.Query) !== null && _a !== void 0 ? _a : z.object({});
94
+ const validation = validator.safeParse(query);
95
+ if (!validation.success) {
96
+ throw error(400, `Invalid query.\n${fromZodError(validation.error).message}`);
97
+ }
98
+ else {
99
+ query = validation.data;
100
+ }
101
+ return query;
102
+ };
103
+ export const parseParam = async (params, module) => {
104
+ var _a;
105
+ log("param: %O", params);
106
+ const validator = (_a = module.Param) !== null && _a !== void 0 ? _a : z.object({});
107
+ const validation = validator.safeParse(params);
108
+ if (!validation.success) {
109
+ throw error(400, `Invalid param.\n${fromZodError(validation.error).message}`);
110
+ }
111
+ else {
112
+ return validation.data;
113
+ }
114
+ };
115
+ export const parseOutput = async (out, module) => {
116
+ var _a;
117
+ let output = Array.isArray(out)
118
+ ? out
119
+ : out && typeof out === "object" ? out : {};
120
+ const validator = (_a = module.Output) !== null && _a !== void 0 ? _a : z.object({});
121
+ const validation = await validator.spa(output);
122
+ if (!validation.success) {
123
+ log.extend("error")("output: %O failed validation: %O", output, validation.error);
124
+ throw error(500, "Output validation failed. Please report this error to the developer.");
125
+ }
126
+ else {
127
+ output = validation.data;
128
+ }
129
+ return output;
130
+ };
package/dist/zod.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { z } from "zod";
2
+ export { z };
package/dist/zod.js ADDED
@@ -0,0 +1,4 @@
1
+ import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
2
+ import { z } from "zod";
3
+ extendZodWithOpenApi(z);
4
+ export { z };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@riddl/core",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@asteasolutions/zod-to-openapi": "^8.4.0",
8
+ "debug": "^4.4.0",
9
+ "express": "^5.2.1",
10
+ "set-cookie-parser": "^3.0.1",
11
+ "sse.js": "^2.5.0",
12
+ "zod": "^4.3.6",
13
+ "zod-validation-error": "^5.0.0"
14
+ },
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "exports": {
18
+ ".": "./dist/index.js",
19
+ "./*": "./dist/*/index.js"
20
+ },
21
+ "files": [
22
+ "/dist"
23
+ ],
24
+ "devDependencies": {
25
+ "@types/debug": "^4.1.12",
26
+ "@types/express": "^5.0.6",
27
+ "@types/set-cookie-parser": "^2.4.10",
28
+ "@typescript-eslint/eslint-plugin": "^8.19.0",
29
+ "@typescript-eslint/parser": "^8.19.0",
30
+ "concurrently": "^9.1.2",
31
+ "eslint": "^9.17.0",
32
+ "eslint-config-prettier": "^9.1.0",
33
+ "openapi3-ts": "^4.5.0",
34
+ "prettier": "^3.4.2",
35
+ "tsc-alias": "^1.8.10",
36
+ "type-fest": "^4.31.0",
37
+ "typescript": "^5.7.2"
38
+ },
39
+ "scripts": {
40
+ "lint": "prettier --check . && eslint .",
41
+ "format": "prettier --write .",
42
+ "clean": "rm -f tsconfig.tsbuildinfo dist",
43
+ "build": "tsc && tsc-alias",
44
+ "build:watch": "tsc && tsc-alias && (concurrently \"tsc -w\" \"tsc-alias -w\")"
45
+ }
46
+ }