@databehave/server 0.1.4 → 0.2.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/CHANGELOG.md +17 -0
- package/README.md +85 -83
- package/dist/cors.d.ts +1 -1
- package/dist/cors.js +1 -1
- package/dist/cors.js.map +1 -1
- package/dist/http.d.ts +94 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +23 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +10 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -18
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +9 -49
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +16 -92
- package/dist/log.js.map +1 -1
- package/dist/node-authority.d.ts +12 -0
- package/dist/node-authority.d.ts.map +1 -0
- package/dist/node-authority.js +72 -0
- package/dist/node-authority.js.map +1 -0
- package/dist/node-body.d.ts +11 -0
- package/dist/node-body.d.ts.map +1 -0
- package/dist/node-body.js +61 -0
- package/dist/node-body.js.map +1 -0
- package/dist/node-http.d.ts +19 -0
- package/dist/node-http.d.ts.map +1 -0
- package/dist/node-http.js +210 -0
- package/dist/node-http.js.map +1 -0
- package/dist/plugins/access-log.d.ts +11 -0
- package/dist/plugins/access-log.d.ts.map +1 -0
- package/dist/plugins/access-log.js +27 -0
- package/dist/plugins/access-log.js.map +1 -0
- package/dist/plugins/cors.d.ts +5 -0
- package/dist/plugins/cors.d.ts.map +1 -0
- package/dist/plugins/cors.js +35 -0
- package/dist/plugins/cors.js.map +1 -0
- package/dist/plugins/routes.d.ts +5 -0
- package/dist/plugins/routes.d.ts.map +1 -0
- package/dist/plugins/routes.js +10 -0
- package/dist/plugins/routes.js.map +1 -0
- package/dist/request.d.ts +2 -2
- package/dist/request.d.ts.map +1 -1
- package/dist/request.js +6 -4
- package/dist/request.js.map +1 -1
- package/dist/route-key.d.ts +2 -0
- package/dist/route-key.d.ts.map +1 -1
- package/dist/route-key.js +5 -1
- package/dist/route-key.js.map +1 -1
- package/dist/server.d.ts +7 -59
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +289 -466
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +10 -156
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/package.json +9 -22
- package/dist/admin/admin-routes.d.ts +0 -42
- package/dist/admin/admin-routes.d.ts.map +0 -1
- package/dist/admin/admin-routes.js +0 -472
- package/dist/admin/admin-routes.js.map +0 -1
- package/dist/admin/admin-types.d.ts +0 -170
- package/dist/admin/admin-types.d.ts.map +0 -1
- package/dist/admin/admin-types.js +0 -30
- package/dist/admin/admin-types.js.map +0 -1
- package/dist/admin/banner.d.ts +0 -11
- package/dist/admin/banner.d.ts.map +0 -1
- package/dist/admin/banner.js +0 -5
- package/dist/admin/banner.js.map +0 -1
- package/dist/admin/header-parser.d.ts +0 -14
- package/dist/admin/header-parser.d.ts.map +0 -1
- package/dist/admin/header-parser.js +0 -171
- package/dist/admin/header-parser.js.map +0 -1
- package/dist/admin/inject.d.ts +0 -48
- package/dist/admin/inject.d.ts.map +0 -1
- package/dist/admin/inject.js +0 -167
- package/dist/admin/inject.js.map +0 -1
- package/dist/admin/overrides-store.d.ts +0 -18
- package/dist/admin/overrides-store.d.ts.map +0 -1
- package/dist/admin/overrides-store.js +0 -100
- package/dist/admin/overrides-store.js.map +0 -1
- package/dist/admin/resolve-cors.d.ts +0 -22
- package/dist/admin/resolve-cors.d.ts.map +0 -1
- package/dist/admin/resolve-cors.js +0 -28
- package/dist/admin/resolve-cors.js.map +0 -1
- package/dist/admin/scenarios-store.d.ts +0 -15
- package/dist/admin/scenarios-store.d.ts.map +0 -1
- package/dist/admin/scenarios-store.js +0 -109
- package/dist/admin/scenarios-store.js.map +0 -1
- package/dist/admin/ui.css +0 -1
- package/dist/admin/ui.html +0 -13
- package/dist/admin/ui.js +0 -240
- package/dist/bin-helpers.d.ts +0 -46
- package/dist/bin-helpers.d.ts.map +0 -1
- package/dist/bin-helpers.js +0 -61
- package/dist/bin-helpers.js.map +0 -1
- package/dist/bin.d.ts +0 -3
- package/dist/bin.d.ts.map +0 -1
- package/dist/bin.js +0 -86
- package/dist/bin.js.map +0 -1
- package/dist/define.d.ts +0 -26
- package/dist/define.d.ts.map +0 -1
- package/dist/define.js +0 -55
- package/dist/define.js.map +0 -1
- package/dist/json-config.d.ts +0 -170
- package/dist/json-config.d.ts.map +0 -1
- package/dist/json-config.js +0 -684
- package/dist/json-config.js.map +0 -1
- package/dist/jsonc.d.ts +0 -24
- package/dist/jsonc.d.ts.map +0 -1
- package/dist/jsonc.js +0 -125
- package/dist/jsonc.js.map +0 -1
- package/dist/middleware/request-validation.d.ts +0 -58
- package/dist/middleware/request-validation.d.ts.map +0 -1
- package/dist/middleware/request-validation.js +0 -328
- package/dist/middleware/request-validation.js.map +0 -1
- package/dist/mock-mode.d.ts +0 -95
- package/dist/mock-mode.d.ts.map +0 -1
- package/dist/mock-mode.js +0 -95
- package/dist/mock-mode.js.map +0 -1
- package/dist/openapi/auto-schema.d.ts +0 -83
- package/dist/openapi/auto-schema.d.ts.map +0 -1
- package/dist/openapi/auto-schema.js +0 -232
- package/dist/openapi/auto-schema.js.map +0 -1
- package/dist/openapi/generate.d.ts +0 -20
- package/dist/openapi/generate.d.ts.map +0 -1
- package/dist/openapi/generate.js +0 -121
- package/dist/openapi/generate.js.map +0 -1
- package/dist/openapi/loader.d.ts +0 -18
- package/dist/openapi/loader.d.ts.map +0 -1
- package/dist/openapi/loader.js +0 -25
- package/dist/openapi/loader.js.map +0 -1
- package/dist/openapi/register.d.ts +0 -58
- package/dist/openapi/register.d.ts.map +0 -1
- package/dist/openapi/register.js +0 -166
- package/dist/openapi/register.js.map +0 -1
- package/dist/openapi/seed.d.ts +0 -16
- package/dist/openapi/seed.d.ts.map +0 -1
- package/dist/openapi/seed.js +0 -58
- package/dist/openapi/seed.js.map +0 -1
- package/dist/openapi/translate.d.ts +0 -55
- package/dist/openapi/translate.d.ts.map +0 -1
- package/dist/openapi/translate.js +0 -319
- package/dist/openapi/translate.js.map +0 -1
- package/dist/openapi/walker.d.ts +0 -44
- package/dist/openapi/walker.d.ts.map +0 -1
- package/dist/openapi/walker.js +0 -15
- package/dist/openapi/walker.js.map +0 -1
- package/dist/run.d.ts +0 -27
- package/dist/run.d.ts.map +0 -1
- package/dist/run.js +0 -87
- package/dist/run.js.map +0 -1
- package/dist/validation/validate.d.ts +0 -38
- package/dist/validation/validate.d.ts.map +0 -1
- package/dist/validation/validate.js +0 -372
- package/dist/validation/validate.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,499 +1,322 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Readable } from 'node:stream';
|
|
3
|
-
import { createAdminRoutes } from './admin/admin-routes.js';
|
|
4
|
-
import { AdminDestroySocketSignal, } from './admin/admin-types.js';
|
|
5
|
-
import { emitAdminBanner } from './admin/banner.js';
|
|
6
|
-
import { createInjectHook, isDestroySocketSentinel } from './admin/inject.js';
|
|
7
|
-
import { createOverridesStore } from './admin/overrides-store.js';
|
|
8
|
-
import { resolveAdminCors } from './admin/resolve-cors.js';
|
|
9
|
-
import { createScenariosStore } from './admin/scenarios-store.js';
|
|
10
|
-
import { buildCorsResponseHeaders, buildPreflightResponse, mergeVary } from './cors.js';
|
|
11
|
-
import { createAccessLogger } from './log.js';
|
|
12
|
-
import { createRequestValidationContext, validateRequest, } from './middleware/request-validation.js';
|
|
13
|
-
import { parseOpenApi } from './openapi/loader.js';
|
|
14
|
-
import { buildOpenApiRoutes } from './openapi/register.js';
|
|
1
|
+
import { httpCapability, HttpTransportTermination, UnsupportedTransportError, } from './http.js';
|
|
15
2
|
import { buildRequest } from './request.js';
|
|
16
3
|
import { buildResponse } from './response.js';
|
|
17
4
|
import { matchPattern, parseRouteKey, parseRoutePattern } from './route-key.js';
|
|
18
|
-
const
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
scenariosDir: cfg.scenariosDir ?? `${process.cwd()}/mock-scenarios`,
|
|
5
|
+
const genericError = () => ({ status: 500, json: { error: 'internal_error', message: 'Internal server error' } });
|
|
6
|
+
/** Provide an instance-local HTTP pipeline; no listener or feature is installed implicitly. */
|
|
7
|
+
export const httpPlugin = (options = {}) => {
|
|
8
|
+
const drainTimeoutMs = options.drainTimeoutMs ?? 1000;
|
|
9
|
+
if (!Number.isFinite(drainTimeoutMs) || drainTimeoutMs < 0)
|
|
10
|
+
throw new Error('drainTimeoutMs must be a finite non-negative number');
|
|
11
|
+
let drainDeadline;
|
|
12
|
+
let accepting = false;
|
|
13
|
+
const pending = new Set();
|
|
14
|
+
const sources = [];
|
|
15
|
+
const middleware = [];
|
|
16
|
+
const guards = [];
|
|
17
|
+
let sealed = false;
|
|
18
|
+
let active = false;
|
|
19
|
+
let lifetime;
|
|
20
|
+
let routes = Object.freeze([]);
|
|
21
|
+
let indexed = [];
|
|
22
|
+
const exact = new Map();
|
|
23
|
+
const assertRegistration = () => {
|
|
24
|
+
if (sealed)
|
|
25
|
+
throw new Error('HTTP registration is sealed');
|
|
40
26
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
export const composeAdminOnRequest = (userHook, adminHook) => {
|
|
48
|
-
if (!userHook)
|
|
49
|
-
return adminHook;
|
|
50
|
-
return async (req) => {
|
|
51
|
-
const first = await userHook(req);
|
|
52
|
-
if (first)
|
|
53
|
-
return first;
|
|
54
|
-
return adminHook(req);
|
|
27
|
+
const assertActive = () => {
|
|
28
|
+
if (!active)
|
|
29
|
+
throw new Error('HTTP runtime has not activated');
|
|
30
|
+
if (lifetime?.aborted)
|
|
31
|
+
throw lifetime.reason;
|
|
55
32
|
};
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const entries = Object.entries(config.routes ?? {});
|
|
63
|
-
for (const [key, handler] of entries) {
|
|
64
|
-
if (!handler)
|
|
65
|
-
continue;
|
|
66
|
-
const { method, path } = parseRouteKey(key);
|
|
67
|
-
const pattern = parseRoutePattern(path);
|
|
68
|
-
const dispatchKey = `${method} ${path}`;
|
|
69
|
-
if (declared.has(dispatchKey)) {
|
|
70
|
-
throw new Error(`@databehave/server: duplicate route declared: ${dispatchKey}`);
|
|
71
|
-
}
|
|
72
|
-
declared.add(dispatchKey);
|
|
73
|
-
declaredKeys.add(`${method.toUpperCase()} ${path}`);
|
|
74
|
-
if (pattern.isStatic) {
|
|
75
|
-
statik.set(dispatchKey, handler);
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
dynamic.push({ method, pattern, handler });
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
// OpenAPI walker fills only the gaps the user did NOT declare.
|
|
82
|
-
// Declared (hand-written) routes always win — no error, just skip.
|
|
83
|
-
if (config.openapi !== undefined) {
|
|
84
|
-
const doc = parseOpenApi(config.openapi);
|
|
85
|
-
const oasRoutes = buildOpenApiRoutes(doc, {
|
|
86
|
-
skip: declaredKeys,
|
|
87
|
-
...(config.onOpenApiWalkError !== undefined
|
|
88
|
-
? { onWalkError: config.onOpenApiWalkError }
|
|
89
|
-
: {}),
|
|
90
|
-
...(config.onOpenApiEmptySchema !== undefined
|
|
91
|
-
? { onEmptySchema: config.onOpenApiEmptySchema }
|
|
92
|
-
: {}),
|
|
93
|
-
});
|
|
94
|
-
for (const [key, handler] of oasRoutes) {
|
|
95
|
-
const { method, path } = parseRouteKey(key);
|
|
96
|
-
const pattern = parseRoutePattern(path);
|
|
97
|
-
const dispatchKey = `${method} ${path}`;
|
|
98
|
-
if (declared.has(dispatchKey))
|
|
33
|
+
const findRoute = (method, path) => {
|
|
34
|
+
const route = exact.get(`${method} ${path}`);
|
|
35
|
+
if (route)
|
|
36
|
+
return { route, params: Object.create(null) };
|
|
37
|
+
for (const entry of indexed) {
|
|
38
|
+
if (entry.route.method !== method)
|
|
99
39
|
continue;
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
dynamic.push({ method, pattern, handler });
|
|
106
|
-
}
|
|
40
|
+
const params = matchPattern(entry.pattern, path);
|
|
41
|
+
if (params)
|
|
42
|
+
return { route: entry.route, params };
|
|
107
43
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return { handler: route.handler, params };
|
|
121
|
-
}
|
|
122
|
-
return null;
|
|
123
|
-
};
|
|
124
|
-
/**
|
|
125
|
-
* Build a @databehave/server server.
|
|
126
|
-
*
|
|
127
|
-
* Construction validates every route key and pre-computes a dispatch
|
|
128
|
-
* index (static lookups are O(1), `:param` patterns are linear). The
|
|
129
|
-
* returned object exposes:
|
|
130
|
-
*
|
|
131
|
-
* - `fetch(Request)` for in-process testing — no socket is opened.
|
|
132
|
-
* - `listen({port, host})` to bind a Node HTTP server. Port `0`
|
|
133
|
-
* asks the OS for a free port; the resolved port/host come back in
|
|
134
|
-
* the handle.
|
|
135
|
-
*
|
|
136
|
-
* Request flow per call:
|
|
137
|
-
* 1. CORS preflight (OPTIONS) → short-circuit when `cors` is set
|
|
138
|
-
* 2. `hooks.onRequest` → may short-circuit
|
|
139
|
-
* 3. Route dispatch → handler / OAS walker / 404
|
|
140
|
-
* 4. `hooks.onResponse` → may replace the result
|
|
141
|
-
* 5. CORS response headers merged on top (don't override the user)
|
|
142
|
-
* 6. Handler errors funnel through `hooks.onError` or fall back
|
|
143
|
-
* to a 500 JSON envelope.
|
|
144
|
-
*/
|
|
145
|
-
/**
|
|
146
|
-
* Route a runtime `'error'` event from a bound Node `http.Server` to
|
|
147
|
-
* the user-supplied `onServerError` hook (when present), with a
|
|
148
|
-
* `console.error` fallback. Exported so the branching is unit-testable
|
|
149
|
-
* without standing up a real socket and provoking EMFILE/EACCES.
|
|
150
|
-
*
|
|
151
|
-
* Branch contract:
|
|
152
|
-
* - no hook → log via `console.error`
|
|
153
|
-
* - hook returns → silent (hook owns reporting)
|
|
154
|
-
* - hook itself throws → log the *hook* failure AND the original
|
|
155
|
-
* error, so a misbehaving logger never
|
|
156
|
-
* silently swallows a real server fault.
|
|
157
|
-
*/
|
|
158
|
-
export const handleRuntimeServerError = (err, onServerError) => {
|
|
159
|
-
if (onServerError) {
|
|
160
|
-
try {
|
|
161
|
-
onServerError(err);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
catch (hookErr) {
|
|
165
|
-
// eslint-disable-next-line no-console
|
|
166
|
-
console.error('[@databehave/server] onServerError hook threw:', hookErr);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// eslint-disable-next-line no-console
|
|
170
|
-
console.error('[@databehave/server] server error:', err);
|
|
171
|
-
};
|
|
172
|
-
export const createServer = (config) => {
|
|
173
|
-
const admin = resolveAdminMode(config.admin);
|
|
174
|
-
let effectiveConfig = config;
|
|
175
|
-
if (admin) {
|
|
176
|
-
const store = createOverridesStore({
|
|
177
|
-
warn: (m) => console.warn(m),
|
|
178
|
-
});
|
|
179
|
-
const corsHeaders = resolveAdminCors(admin.bind, admin.cors);
|
|
180
|
-
const scenarios = createScenariosStore({ dir: admin.scenariosDir });
|
|
181
|
-
// Surface hand-written routes to the admin UI so handler-only
|
|
182
|
-
// endpoints (those not in the OAS doc, or projects without one)
|
|
183
|
-
// still appear in the operation list. OAS entries win on dedupe
|
|
184
|
-
// inside `buildAdminRoutesList`; admin's own routes are filtered
|
|
185
|
-
// there too. OAS-walker output is already covered by `openapiBody`.
|
|
186
|
-
const handlerRoutes = [];
|
|
187
|
-
for (const key of Object.keys(config.routes ?? {})) {
|
|
44
|
+
return undefined;
|
|
45
|
+
};
|
|
46
|
+
const suppressHead = async (response, method) => {
|
|
47
|
+
if (method !== 'head')
|
|
48
|
+
return response;
|
|
49
|
+
await response.body?.cancel();
|
|
50
|
+
return new Response(null, { status: response.status, headers: response.headers });
|
|
51
|
+
};
|
|
52
|
+
const handleError = async (request, error) => {
|
|
53
|
+
for (const entry of middleware) {
|
|
54
|
+
if (entry.stage !== 'error')
|
|
55
|
+
continue;
|
|
188
56
|
try {
|
|
189
|
-
const
|
|
190
|
-
|
|
57
|
+
const response = await entry.handle(request, error);
|
|
58
|
+
if (response)
|
|
59
|
+
return response;
|
|
191
60
|
}
|
|
192
61
|
catch {
|
|
193
|
-
|
|
62
|
+
return genericError();
|
|
194
63
|
}
|
|
195
64
|
}
|
|
196
|
-
|
|
197
|
-
store,
|
|
198
|
-
basePath: admin.path,
|
|
199
|
-
ui: admin.ui,
|
|
200
|
-
corsHeaders,
|
|
201
|
-
scenarios,
|
|
202
|
-
handlerRoutes,
|
|
203
|
-
...(admin.openapiBody !== undefined
|
|
204
|
-
? { openapiBody: admin.openapiBody }
|
|
205
|
-
: {}),
|
|
206
|
-
});
|
|
207
|
-
// Fail-fast on conflict between admin routes and user routes —
|
|
208
|
-
// duplicates would silently shadow each other otherwise.
|
|
209
|
-
const userRoutes = config.routes ?? {};
|
|
210
|
-
for (const key of Object.keys(adminRouteEntries)) {
|
|
211
|
-
if (Object.prototype.hasOwnProperty.call(userRoutes, key)) {
|
|
212
|
-
throw new Error(`@databehave/server: admin route ${key} collides with a user-declared route. ` +
|
|
213
|
-
`Move admin.path to a free prefix or rename the user route.`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
// Inject hook is only registered when `headers` is true OR there
|
|
217
|
-
// can be sticky overrides — and stickies require the admin REST
|
|
218
|
-
// endpoints, which are always present, so the hook is always
|
|
219
|
-
// useful here. The `headers` flag is honored inside the inject
|
|
220
|
-
// hook by feeding it an empty-header view when disabled.
|
|
221
|
-
const adminHookCore = createInjectHook({
|
|
222
|
-
store,
|
|
223
|
-
allowDestroy: admin.allowDestroy,
|
|
224
|
-
// Bypass both the bare base path (admin HTML) and any descendant
|
|
225
|
-
// (`/overrides`, `/ui.js`, …) so the inject hook never injects on
|
|
226
|
-
// admin-owned URLs.
|
|
227
|
-
bypassPathPrefixes: [admin.path],
|
|
228
|
-
logger: { warn: (m) => console.warn(m) },
|
|
229
|
-
});
|
|
230
|
-
const adminHook = admin.headers
|
|
231
|
-
? adminHookCore
|
|
232
|
-
: async (req) => adminHookCore({ ...req, headers: {} });
|
|
233
|
-
const userHook = config.hooks?.onRequest;
|
|
234
|
-
const composed = composeAdminOnRequest(userHook, adminHook);
|
|
235
|
-
effectiveConfig = {
|
|
236
|
-
...config,
|
|
237
|
-
routes: { ...userRoutes, ...adminRouteEntries },
|
|
238
|
-
hooks: { ...(config.hooks ?? {}), onRequest: composed },
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
const index = buildRouteIndex(effectiveConfig);
|
|
242
|
-
const hooks = effectiveConfig.hooks ?? {};
|
|
243
|
-
const cors = effectiveConfig.cors;
|
|
244
|
-
const accessLogger = createAccessLogger(effectiveConfig.log, admin?.path);
|
|
245
|
-
// Build the request-validation context once per server boot. When
|
|
246
|
-
// `validation.request !== true` (the default) the context is null
|
|
247
|
-
// and the per-request hot path short-circuits.
|
|
248
|
-
//
|
|
249
|
-
// Schema-level errors (unsupported keyword, $ref cycle, ReDoS-sized
|
|
250
|
-
// pattern, depth-cap exceeded) throw at boot from
|
|
251
|
-
// `createRequestValidationContext` so a misconfigured spec is a
|
|
252
|
-
// fail-fast — never a silent runtime degradation.
|
|
253
|
-
let validationCtx = null;
|
|
254
|
-
if (effectiveConfig.validation?.request === true && effectiveConfig.openapi !== undefined) {
|
|
255
|
-
const doc = parseOpenApi(effectiveConfig.openapi);
|
|
256
|
-
const maxBodyBytes = effectiveConfig.validation?.maxBodyBytes;
|
|
257
|
-
validationCtx = createRequestValidationContext(doc, maxBodyBytes !== undefined ? { maxBodyBytes } : {});
|
|
258
|
-
}
|
|
259
|
-
const decorateWithCors = (res, req) => {
|
|
260
|
-
if (!cors)
|
|
261
|
-
return res;
|
|
262
|
-
const corsHeaders = buildCorsResponseHeaders(cors, req);
|
|
263
|
-
// Handler-supplied headers win — CORS only fills the gaps. `Vary`
|
|
264
|
-
// is the deliberate exception: HTTP semantics require it to be
|
|
265
|
-
// additive, so a handler-set `Vary: Accept-Encoding` is merged
|
|
266
|
-
// with the CORS `Vary: Origin` (case-insensitive dedupe) rather
|
|
267
|
-
// than silently replacing it.
|
|
268
|
-
const merged = { ...corsHeaders };
|
|
269
|
-
if (res.headers) {
|
|
270
|
-
for (const [k, v] of Object.entries(res.headers)) {
|
|
271
|
-
const lk = k.toLowerCase();
|
|
272
|
-
if (lk === 'vary') {
|
|
273
|
-
merged.vary = mergeVary(merged.vary, v);
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
merged[lk] = v;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
return { ...res, headers: merged };
|
|
65
|
+
return genericError();
|
|
281
66
|
};
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
status: res.status,
|
|
292
|
-
ms: Date.now() - logStart,
|
|
293
|
-
bytes: res.headers.get('content-length') ?? '-',
|
|
294
|
-
override: res.headers.get('x-mock-injected')?.split(':')[0],
|
|
295
|
-
error: errorMsg,
|
|
296
|
-
});
|
|
297
|
-
return res;
|
|
298
|
-
};
|
|
299
|
-
// 1. CORS preflight bypasses everything below.
|
|
300
|
-
if (cors && req.method.toUpperCase() === 'OPTIONS') {
|
|
301
|
-
return finalize(buildResponse(buildPreflightResponse(cors, probe)));
|
|
302
|
-
}
|
|
303
|
-
// 1a. Inbound request validation (opt-in via
|
|
304
|
-
// `config.validation.request`). Runs BEFORE mock/handler
|
|
305
|
-
// resolution so a request that fails the OAS contract is rejected
|
|
306
|
-
// with an RFC 7807 envelope without invoking handlers, hooks, or
|
|
307
|
-
// mock-mode injection. When `validationCtx` is null (the default)
|
|
308
|
-
// the function short-circuits and behaviour is byte-identical to
|
|
309
|
-
// pre-validation builds.
|
|
310
|
-
if (validationCtx) {
|
|
311
|
-
const violation = await validateRequest(req, validationCtx);
|
|
312
|
-
if (violation)
|
|
313
|
-
return finalize(violation);
|
|
314
|
-
}
|
|
315
|
-
// 1b. RFC 7231 HEAD semantics: if the HEAD route is not declared,
|
|
316
|
-
// run the GET pipeline once and strip the body from the response.
|
|
317
|
-
// Hooks (`onRequest`, `onResponse`, mock-mode) fire on the inner
|
|
318
|
-
// GET call only — the outer HEAD short-circuits here before the
|
|
319
|
-
// `try` block, so they aren't observed twice for one logical
|
|
320
|
-
// request. Explicit `head` routes still take precedence (handled
|
|
321
|
-
// by `dispatch` for the original method).
|
|
322
|
-
if (req.method.toUpperCase() === 'HEAD' &&
|
|
323
|
-
!dispatch(index, 'head', probe.path)) {
|
|
324
|
-
const asGet = new Request(req.url, {
|
|
325
|
-
method: 'GET',
|
|
326
|
-
headers: req.headers,
|
|
327
|
-
});
|
|
328
|
-
// Inner GET is recursed with logging suppressed so the access
|
|
329
|
-
// logger emits one line per logical request — for the outer
|
|
330
|
-
// HEAD method, not the synthetic GET probe.
|
|
331
|
-
const fullResponse = await fetchImpl(asGet, true);
|
|
332
|
-
return finalize(new Response(null, {
|
|
333
|
-
status: fullResponse.status,
|
|
334
|
-
headers: fullResponse.headers,
|
|
335
|
-
}));
|
|
336
|
-
}
|
|
67
|
+
const execute = async (raw) => {
|
|
68
|
+
const started = performance.now();
|
|
69
|
+
const method = raw.method.toLowerCase();
|
|
70
|
+
const path = new URL(raw.url).pathname;
|
|
71
|
+
const matched = findRoute(method, path) ?? (method === 'head' ? findRoute('get', path) : undefined);
|
|
72
|
+
const request = buildRequest(raw, matched?.params, matched?.route.method ?? method);
|
|
73
|
+
let error;
|
|
74
|
+
let outcome;
|
|
75
|
+
let response;
|
|
337
76
|
try {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
throw new AdminDestroySocketSignal();
|
|
77
|
+
for (const stage of ['preflight', 'validation', 'interception']) {
|
|
78
|
+
for (const entry of middleware) {
|
|
79
|
+
if (entry.stage !== stage)
|
|
80
|
+
continue;
|
|
81
|
+
const result = await entry.handle(request);
|
|
82
|
+
if (result) {
|
|
83
|
+
response = result;
|
|
84
|
+
break;
|
|
347
85
|
}
|
|
348
|
-
const final = hooks.onResponse
|
|
349
|
-
? (await hooks.onResponse(probe, short)) ?? short
|
|
350
|
-
: short;
|
|
351
|
-
return finalize(buildResponse(decorateWithCors(final, probe)));
|
|
352
86
|
}
|
|
87
|
+
if (response)
|
|
88
|
+
break;
|
|
353
89
|
}
|
|
354
|
-
|
|
355
|
-
const matched = dispatch(index, probe.method, probe.path);
|
|
356
|
-
if (!matched) {
|
|
357
|
-
const notFound = {
|
|
358
|
-
status: 404,
|
|
359
|
-
json: { error: 'not_found', method: probe.method, path: probe.path },
|
|
360
|
-
};
|
|
361
|
-
const final = hooks.onResponse
|
|
362
|
-
? (await hooks.onResponse(probe, notFound)) ?? notFound
|
|
363
|
-
: notFound;
|
|
364
|
-
return finalize(buildResponse(decorateWithCors(final, probe)));
|
|
365
|
-
}
|
|
366
|
-
const kitReq = Object.keys(matched.params).length === 0
|
|
367
|
-
? probe
|
|
368
|
-
: buildRequest(req, matched.params);
|
|
369
|
-
const raw = await matched.handler(kitReq);
|
|
370
|
-
// 4. onResponse may replace the body / status / headers.
|
|
371
|
-
const final = hooks.onResponse
|
|
372
|
-
? (await hooks.onResponse(kitReq, raw)) ?? raw
|
|
373
|
-
: raw;
|
|
374
|
-
return finalize(buildResponse(decorateWithCors(final, kitReq)));
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
// Admin destroy signal must escape — hooks.onError must NOT see
|
|
378
|
-
// it (it's transport-level, not a user error). `listen()` catches
|
|
379
|
-
// it specifically. `server.fetch()` callers also receive the throw.
|
|
380
|
-
if (err instanceof AdminDestroySocketSignal)
|
|
381
|
-
throw err;
|
|
382
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
383
|
-
const errRes = hooks.onError
|
|
384
|
-
? await hooks.onError(probe, err)
|
|
385
|
-
: {
|
|
386
|
-
status: 500,
|
|
387
|
-
json: {
|
|
388
|
-
error: 'internal_error',
|
|
389
|
-
message: 'Internal server error',
|
|
390
|
-
},
|
|
391
|
-
};
|
|
392
|
-
return finalize(buildResponse(decorateWithCors(errRes, probe)), errMsg);
|
|
90
|
+
response ??= matched ? await matched.route.handler(request) : { status: 404, json: { error: 'not_found', method: request.method, path: request.path } };
|
|
393
91
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
// opted in. Host comes from listen options (not config), so the
|
|
401
|
-
// check belongs here rather than at server construction time.
|
|
402
|
-
if (admin && admin.bind !== 'any' && !LOOPBACK_HOSTS.has(requestedHost)) {
|
|
403
|
-
throw new Error(`@databehave/server: admin is enabled and server.host=${JSON.stringify(requestedHost)} — set admin.bind: "any" to confirm this is intentional, or bind to loopback (127.0.0.1, ::1, localhost). See README "Admin mode".`);
|
|
92
|
+
catch (caught) {
|
|
93
|
+
error = caught;
|
|
94
|
+
if (caught instanceof HttpTransportTermination)
|
|
95
|
+
outcome = { kind: 'terminate', reason: caught.message };
|
|
96
|
+
else
|
|
97
|
+
response = await handleError(request, caught);
|
|
404
98
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const method = (req.method ?? 'GET').toUpperCase();
|
|
413
|
-
const headers = new Headers();
|
|
414
|
-
for (const [k, v] of Object.entries(req.headers)) {
|
|
415
|
-
if (v === undefined)
|
|
416
|
-
continue;
|
|
417
|
-
if (Array.isArray(v)) {
|
|
418
|
-
for (const value of v)
|
|
419
|
-
headers.append(k, value);
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
headers.set(k, v);
|
|
99
|
+
if (!outcome) {
|
|
100
|
+
try {
|
|
101
|
+
let decorated = response ?? genericError();
|
|
102
|
+
for (const entry of middleware) {
|
|
103
|
+
if (entry.stage !== 'decoration')
|
|
104
|
+
continue;
|
|
105
|
+
decorated = await entry.handle(request, decorated) ?? decorated;
|
|
423
106
|
}
|
|
107
|
+
const webResponse = buildResponse(decorated);
|
|
108
|
+
outcome = { kind: 'response', response: await suppressHead(webResponse, method) };
|
|
424
109
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
Promise.resolve()
|
|
432
|
-
.then(() => fetch(new Request(url, init)))
|
|
433
|
-
.then(async (webRes) => {
|
|
434
|
-
res.statusCode = webRes.status;
|
|
435
|
-
webRes.headers.forEach((value, key) => {
|
|
436
|
-
res.setHeader(key, value);
|
|
437
|
-
});
|
|
438
|
-
if (!webRes.body) {
|
|
439
|
-
res.end();
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
const nodeStream = Readable.fromWeb(webRes.body);
|
|
443
|
-
await new Promise((resolveStream, rejectStream) => {
|
|
444
|
-
nodeStream.on('error', rejectStream);
|
|
445
|
-
res.on('error', rejectStream);
|
|
446
|
-
res.on('finish', () => resolveStream());
|
|
447
|
-
nodeStream.pipe(res);
|
|
448
|
-
});
|
|
449
|
-
})
|
|
450
|
-
.catch((err) => {
|
|
451
|
-
// Admin destroy: drop the socket without a response.
|
|
452
|
-
if (err instanceof AdminDestroySocketSignal) {
|
|
110
|
+
catch (caught) {
|
|
111
|
+
error = caught;
|
|
112
|
+
if (caught instanceof HttpTransportTermination)
|
|
113
|
+
outcome = { kind: 'terminate', reason: caught.message };
|
|
114
|
+
else {
|
|
115
|
+
let fallback;
|
|
453
116
|
try {
|
|
454
|
-
|
|
117
|
+
fallback = buildResponse(await handleError(request, caught));
|
|
455
118
|
}
|
|
456
119
|
catch {
|
|
457
|
-
|
|
120
|
+
fallback = buildResponse(genericError());
|
|
458
121
|
}
|
|
459
|
-
|
|
122
|
+
outcome = { kind: 'response', response: await suppressHead(fallback, method) };
|
|
460
123
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const observation = { request, outcome, durationMs: performance.now() - started, ...(error === undefined ? {} : { error }) };
|
|
127
|
+
for (const entry of middleware) {
|
|
128
|
+
if (entry.stage !== 'observation')
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
await entry.handle(observation);
|
|
132
|
+
}
|
|
133
|
+
catch { /* Observers cannot change the terminal outcome. */ }
|
|
134
|
+
}
|
|
135
|
+
return outcome;
|
|
136
|
+
};
|
|
137
|
+
const runtime = {
|
|
138
|
+
get drainTimeoutMs() { return drainTimeoutMs; },
|
|
139
|
+
get drainDeadline() { return drainDeadline; },
|
|
140
|
+
registerRoutes(source) {
|
|
141
|
+
assertRegistration();
|
|
142
|
+
if (!source.owner || !Number.isFinite(source.order))
|
|
143
|
+
throw new Error('HTTP route source requires an owner and finite composition order');
|
|
144
|
+
sources.push(Object.freeze({ ...source }));
|
|
145
|
+
},
|
|
146
|
+
registerMiddleware(entry) {
|
|
147
|
+
assertRegistration();
|
|
148
|
+
if (!entry.owner || !Number.isFinite(entry.order))
|
|
149
|
+
throw new Error('HTTP middleware requires an owner and finite composition order');
|
|
150
|
+
middleware.push(Object.freeze({ ...entry }));
|
|
151
|
+
},
|
|
152
|
+
registerBindGuard(guard) { assertRegistration(); guards.push(Object.freeze({ ...guard })); },
|
|
153
|
+
get routes() {
|
|
154
|
+
if (!active)
|
|
155
|
+
throw new Error('HTTP route catalog is unavailable before activation');
|
|
156
|
+
return routes;
|
|
157
|
+
},
|
|
158
|
+
resolveRoute(method, path) {
|
|
159
|
+
assertActive();
|
|
160
|
+
const normalized = method.toLowerCase();
|
|
161
|
+
return (findRoute(normalized, path) ?? (normalized === 'head' ? findRoute('get', path) : undefined))?.route;
|
|
162
|
+
},
|
|
163
|
+
async checkBind(bind) { for (const guard of guards)
|
|
164
|
+
await guard.check(bind); },
|
|
165
|
+
async dispatch(request) {
|
|
166
|
+
assertActive();
|
|
167
|
+
if (!accepting)
|
|
168
|
+
throw new Error('HTTP runtime is not accepting requests');
|
|
169
|
+
request.signal.throwIfAborted();
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const abortRequest = () => controller.abort(request.signal.reason);
|
|
172
|
+
const abortLifetime = () => controller.abort(lifetime?.reason);
|
|
173
|
+
request.signal.addEventListener('abort', abortRequest, { once: true });
|
|
174
|
+
lifetime?.addEventListener('abort', abortLifetime, { once: true });
|
|
175
|
+
let resolveDone = () => undefined;
|
|
176
|
+
const done = new Promise((resolve) => { resolveDone = resolve; });
|
|
177
|
+
pending.add(done);
|
|
178
|
+
const finish = () => {
|
|
179
|
+
pending.delete(done);
|
|
180
|
+
resolveDone();
|
|
181
|
+
request.signal.removeEventListener('abort', abortRequest);
|
|
182
|
+
lifetime?.removeEventListener('abort', abortLifetime);
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
const outcome = await execute(new Request(request, { signal: controller.signal }));
|
|
186
|
+
if (outcome.kind !== 'response' || !outcome.response.body) {
|
|
187
|
+
finish();
|
|
188
|
+
return outcome;
|
|
467
189
|
}
|
|
468
|
-
|
|
190
|
+
const reader = outcome.response.body.getReader();
|
|
191
|
+
let stopped = false;
|
|
192
|
+
let cancellation;
|
|
193
|
+
let output;
|
|
194
|
+
const complete = () => {
|
|
195
|
+
controller.signal.removeEventListener('abort', abortBody);
|
|
196
|
+
reader.releaseLock();
|
|
197
|
+
finish();
|
|
198
|
+
};
|
|
199
|
+
const cancel = (reason) => {
|
|
200
|
+
if (cancellation)
|
|
201
|
+
return cancellation;
|
|
202
|
+
stopped = true;
|
|
203
|
+
cancellation = reader.cancel(reason).finally(complete);
|
|
204
|
+
return cancellation;
|
|
205
|
+
};
|
|
206
|
+
const abortBody = () => {
|
|
207
|
+
if (stopped)
|
|
208
|
+
return;
|
|
209
|
+
output.error(controller.signal.reason);
|
|
210
|
+
void cancel(controller.signal.reason).catch(() => undefined);
|
|
211
|
+
};
|
|
212
|
+
const body = new ReadableStream({
|
|
213
|
+
start(stream) { output = stream; },
|
|
214
|
+
async pull(stream) {
|
|
215
|
+
try {
|
|
216
|
+
const chunk = await reader.read();
|
|
217
|
+
if (stopped)
|
|
218
|
+
return;
|
|
219
|
+
if (chunk.done) {
|
|
220
|
+
stopped = true;
|
|
221
|
+
stream.close();
|
|
222
|
+
complete();
|
|
223
|
+
}
|
|
224
|
+
else
|
|
225
|
+
stream.enqueue(chunk.value);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (!stopped) {
|
|
229
|
+
stopped = true;
|
|
230
|
+
stream.error(error);
|
|
231
|
+
complete();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
cancel,
|
|
236
|
+
}, { highWaterMark: 0 });
|
|
237
|
+
controller.signal.addEventListener('abort', abortBody, { once: true });
|
|
238
|
+
if (controller.signal.aborted)
|
|
239
|
+
abortBody();
|
|
240
|
+
return { kind: 'response', response: new Response(body, { status: outcome.response.status, statusText: outcome.response.statusText, headers: outcome.response.headers }) };
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
finish();
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
async fetch(request) {
|
|
248
|
+
const outcome = await runtime.dispatch(request);
|
|
249
|
+
if (outcome.kind === 'terminate')
|
|
250
|
+
throw new UnsupportedTransportError(outcome.reason);
|
|
251
|
+
return outcome.response;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
id: 'databehave.http', apiVersion: 1, provides: [httpCapability],
|
|
256
|
+
setup(ctx) {
|
|
257
|
+
ctx.provide(httpCapability, runtime);
|
|
258
|
+
ctx.onSeal(() => {
|
|
259
|
+
sealed = true;
|
|
260
|
+
sources.sort((a, b) => a.order - b.order);
|
|
261
|
+
middleware.sort((a, b) => a.order - b.order);
|
|
469
262
|
});
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
263
|
+
},
|
|
264
|
+
activate(ctx) {
|
|
265
|
+
lifetime = ctx.signal;
|
|
266
|
+
const resolved = new Map();
|
|
267
|
+
for (const source of sources) {
|
|
268
|
+
for (const definition of source.materialize()) {
|
|
269
|
+
const { method, path } = parseRouteKey(definition.key);
|
|
270
|
+
parseRoutePattern(path);
|
|
271
|
+
const key = `${method} ${path}`;
|
|
272
|
+
const previous = resolved.get(key);
|
|
273
|
+
if (previous?.priority === definition.priority) {
|
|
274
|
+
throw new Error(`Duplicate HTTP route ${definition.key}: ${previous.owner} and ${source.owner}`);
|
|
275
|
+
}
|
|
276
|
+
if (previous?.priority === 'explicit')
|
|
277
|
+
continue;
|
|
278
|
+
resolved.delete(key);
|
|
279
|
+
resolved.set(key, Object.freeze({ ...definition, owner: source.owner, method, path }));
|
|
486
280
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
});
|
|
281
|
+
}
|
|
282
|
+
routes = Object.freeze([...resolved.values()]);
|
|
283
|
+
indexed = routes.flatMap((route) => {
|
|
284
|
+
const pattern = parseRoutePattern(route.path);
|
|
285
|
+
if (!pattern.isStatic)
|
|
286
|
+
return [{ route, pattern }];
|
|
287
|
+
exact.set(`${route.method} ${route.path}`, route);
|
|
288
|
+
return [];
|
|
289
|
+
});
|
|
290
|
+
active = true;
|
|
291
|
+
ctx.defer(() => { active = false; });
|
|
292
|
+
},
|
|
293
|
+
open(ctx) {
|
|
294
|
+
accepting = true;
|
|
295
|
+
const stopAccepting = () => {
|
|
296
|
+
accepting = false;
|
|
297
|
+
drainDeadline ??= performance.now() + drainTimeoutMs;
|
|
298
|
+
};
|
|
299
|
+
ctx.ownIngress({
|
|
300
|
+
stopAccepting,
|
|
301
|
+
async drainAndClose() {
|
|
302
|
+
stopAccepting();
|
|
303
|
+
if (!pending.size)
|
|
304
|
+
return;
|
|
305
|
+
let timer;
|
|
306
|
+
try {
|
|
307
|
+
await Promise.race([
|
|
308
|
+
Promise.all([...pending]),
|
|
309
|
+
new Promise((_resolve, reject) => {
|
|
310
|
+
timer = setTimeout(() => reject(new Error(`HTTP drain timed out with ${pending.size} unfinished request(s)`)), Math.max(0, (drainDeadline ?? 0) - performance.now()));
|
|
311
|
+
}),
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
494
318
|
});
|
|
495
|
-
}
|
|
319
|
+
},
|
|
496
320
|
};
|
|
497
|
-
return { fetch, listen };
|
|
498
321
|
};
|
|
499
322
|
//# sourceMappingURL=server.js.map
|