@databehave/server 0.1.3 → 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.
Files changed (156) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +85 -83
  3. package/dist/cors.d.ts +1 -1
  4. package/dist/cors.js +1 -1
  5. package/dist/cors.js.map +1 -1
  6. package/dist/http.d.ts +94 -0
  7. package/dist/http.d.ts.map +1 -0
  8. package/dist/http.js +23 -0
  9. package/dist/http.js.map +1 -0
  10. package/dist/index.d.ts +10 -20
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +8 -18
  13. package/dist/index.js.map +1 -1
  14. package/dist/log.d.ts +9 -49
  15. package/dist/log.d.ts.map +1 -1
  16. package/dist/log.js +16 -92
  17. package/dist/log.js.map +1 -1
  18. package/dist/node-authority.d.ts +12 -0
  19. package/dist/node-authority.d.ts.map +1 -0
  20. package/dist/node-authority.js +72 -0
  21. package/dist/node-authority.js.map +1 -0
  22. package/dist/node-body.d.ts +11 -0
  23. package/dist/node-body.d.ts.map +1 -0
  24. package/dist/node-body.js +61 -0
  25. package/dist/node-body.js.map +1 -0
  26. package/dist/node-http.d.ts +19 -0
  27. package/dist/node-http.d.ts.map +1 -0
  28. package/dist/node-http.js +210 -0
  29. package/dist/node-http.js.map +1 -0
  30. package/dist/plugins/access-log.d.ts +11 -0
  31. package/dist/plugins/access-log.d.ts.map +1 -0
  32. package/dist/plugins/access-log.js +27 -0
  33. package/dist/plugins/access-log.js.map +1 -0
  34. package/dist/plugins/cors.d.ts +5 -0
  35. package/dist/plugins/cors.d.ts.map +1 -0
  36. package/dist/plugins/cors.js +35 -0
  37. package/dist/plugins/cors.js.map +1 -0
  38. package/dist/plugins/routes.d.ts +5 -0
  39. package/dist/plugins/routes.d.ts.map +1 -0
  40. package/dist/plugins/routes.js +10 -0
  41. package/dist/plugins/routes.js.map +1 -0
  42. package/dist/request.d.ts +2 -2
  43. package/dist/request.d.ts.map +1 -1
  44. package/dist/request.js +6 -4
  45. package/dist/request.js.map +1 -1
  46. package/dist/route-key.d.ts +2 -0
  47. package/dist/route-key.d.ts.map +1 -1
  48. package/dist/route-key.js +5 -1
  49. package/dist/route-key.js.map +1 -1
  50. package/dist/server.d.ts +7 -59
  51. package/dist/server.d.ts.map +1 -1
  52. package/dist/server.js +289 -466
  53. package/dist/server.js.map +1 -1
  54. package/dist/types.d.ts +10 -156
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js +1 -1
  57. package/package.json +9 -22
  58. package/dist/admin/admin-routes.d.ts +0 -42
  59. package/dist/admin/admin-routes.d.ts.map +0 -1
  60. package/dist/admin/admin-routes.js +0 -472
  61. package/dist/admin/admin-routes.js.map +0 -1
  62. package/dist/admin/admin-types.d.ts +0 -170
  63. package/dist/admin/admin-types.d.ts.map +0 -1
  64. package/dist/admin/admin-types.js +0 -30
  65. package/dist/admin/admin-types.js.map +0 -1
  66. package/dist/admin/banner.d.ts +0 -11
  67. package/dist/admin/banner.d.ts.map +0 -1
  68. package/dist/admin/banner.js +0 -5
  69. package/dist/admin/banner.js.map +0 -1
  70. package/dist/admin/header-parser.d.ts +0 -14
  71. package/dist/admin/header-parser.d.ts.map +0 -1
  72. package/dist/admin/header-parser.js +0 -171
  73. package/dist/admin/header-parser.js.map +0 -1
  74. package/dist/admin/inject.d.ts +0 -48
  75. package/dist/admin/inject.d.ts.map +0 -1
  76. package/dist/admin/inject.js +0 -167
  77. package/dist/admin/inject.js.map +0 -1
  78. package/dist/admin/overrides-store.d.ts +0 -18
  79. package/dist/admin/overrides-store.d.ts.map +0 -1
  80. package/dist/admin/overrides-store.js +0 -100
  81. package/dist/admin/overrides-store.js.map +0 -1
  82. package/dist/admin/resolve-cors.d.ts +0 -22
  83. package/dist/admin/resolve-cors.d.ts.map +0 -1
  84. package/dist/admin/resolve-cors.js +0 -28
  85. package/dist/admin/resolve-cors.js.map +0 -1
  86. package/dist/admin/scenarios-store.d.ts +0 -15
  87. package/dist/admin/scenarios-store.d.ts.map +0 -1
  88. package/dist/admin/scenarios-store.js +0 -109
  89. package/dist/admin/scenarios-store.js.map +0 -1
  90. package/dist/admin/ui.css +0 -1
  91. package/dist/admin/ui.html +0 -13
  92. package/dist/admin/ui.js +0 -240
  93. package/dist/bin-helpers.d.ts +0 -46
  94. package/dist/bin-helpers.d.ts.map +0 -1
  95. package/dist/bin-helpers.js +0 -61
  96. package/dist/bin-helpers.js.map +0 -1
  97. package/dist/bin.d.ts +0 -3
  98. package/dist/bin.d.ts.map +0 -1
  99. package/dist/bin.js +0 -86
  100. package/dist/bin.js.map +0 -1
  101. package/dist/define.d.ts +0 -26
  102. package/dist/define.d.ts.map +0 -1
  103. package/dist/define.js +0 -55
  104. package/dist/define.js.map +0 -1
  105. package/dist/json-config.d.ts +0 -170
  106. package/dist/json-config.d.ts.map +0 -1
  107. package/dist/json-config.js +0 -684
  108. package/dist/json-config.js.map +0 -1
  109. package/dist/jsonc.d.ts +0 -24
  110. package/dist/jsonc.d.ts.map +0 -1
  111. package/dist/jsonc.js +0 -125
  112. package/dist/jsonc.js.map +0 -1
  113. package/dist/middleware/request-validation.d.ts +0 -58
  114. package/dist/middleware/request-validation.d.ts.map +0 -1
  115. package/dist/middleware/request-validation.js +0 -328
  116. package/dist/middleware/request-validation.js.map +0 -1
  117. package/dist/mock-mode.d.ts +0 -95
  118. package/dist/mock-mode.d.ts.map +0 -1
  119. package/dist/mock-mode.js +0 -95
  120. package/dist/mock-mode.js.map +0 -1
  121. package/dist/openapi/auto-schema.d.ts +0 -83
  122. package/dist/openapi/auto-schema.d.ts.map +0 -1
  123. package/dist/openapi/auto-schema.js +0 -232
  124. package/dist/openapi/auto-schema.js.map +0 -1
  125. package/dist/openapi/generate.d.ts +0 -20
  126. package/dist/openapi/generate.d.ts.map +0 -1
  127. package/dist/openapi/generate.js +0 -121
  128. package/dist/openapi/generate.js.map +0 -1
  129. package/dist/openapi/loader.d.ts +0 -18
  130. package/dist/openapi/loader.d.ts.map +0 -1
  131. package/dist/openapi/loader.js +0 -25
  132. package/dist/openapi/loader.js.map +0 -1
  133. package/dist/openapi/register.d.ts +0 -58
  134. package/dist/openapi/register.d.ts.map +0 -1
  135. package/dist/openapi/register.js +0 -166
  136. package/dist/openapi/register.js.map +0 -1
  137. package/dist/openapi/seed.d.ts +0 -16
  138. package/dist/openapi/seed.d.ts.map +0 -1
  139. package/dist/openapi/seed.js +0 -58
  140. package/dist/openapi/seed.js.map +0 -1
  141. package/dist/openapi/translate.d.ts +0 -55
  142. package/dist/openapi/translate.d.ts.map +0 -1
  143. package/dist/openapi/translate.js +0 -319
  144. package/dist/openapi/translate.js.map +0 -1
  145. package/dist/openapi/walker.d.ts +0 -44
  146. package/dist/openapi/walker.d.ts.map +0 -1
  147. package/dist/openapi/walker.js +0 -15
  148. package/dist/openapi/walker.js.map +0 -1
  149. package/dist/run.d.ts +0 -27
  150. package/dist/run.d.ts.map +0 -1
  151. package/dist/run.js +0 -87
  152. package/dist/run.js.map +0 -1
  153. package/dist/validation/validate.d.ts +0 -38
  154. package/dist/validation/validate.d.ts.map +0 -1
  155. package/dist/validation/validate.js +0 -372
  156. package/dist/validation/validate.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,499 +1,322 @@
1
- import { createServer as createNodeHttpServer, } from 'node:http';
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 LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
19
- /**
20
- * Resolve admin config defaults + validate inputs that don't
21
- * depend on listen() host. Returns `null` when admin is absent or
22
- * `enabled !== true`. Pure — no side effects.
23
- */
24
- export const resolveAdminMode = (cfg) => {
25
- if (!cfg || cfg.enabled !== true)
26
- return null;
27
- const path = cfg.path ?? '/databehave';
28
- if (!path.startsWith('/')) {
29
- throw new Error(`@databehave/server: admin.path must start with "/" (got ${JSON.stringify(path)})`);
30
- }
31
- return {
32
- path,
33
- ui: cfg.ui !== false,
34
- headers: cfg.headers !== false,
35
- bind: cfg.bind ?? 'loopback-only',
36
- cors: cfg.cors ?? 'auto',
37
- allowDestroy: cfg.allowDestroy !== false,
38
- openapiBody: cfg.openapiBody,
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
- * Compose two onRequest hooks (user-first per spec H13): if the user
44
- * hook returned a response, skip the admin inject hook; else call
45
- * inject. Exported for unit testability.
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 buildRouteIndex = (config) => {
58
- const statik = new Map();
59
- const dynamic = [];
60
- const declared = new Set();
61
- const declaredKeys = new Set();
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
- declared.add(dispatchKey);
101
- if (pattern.isStatic) {
102
- statik.set(dispatchKey, handler);
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
- return { statik, dynamic };
110
- };
111
- const dispatch = (index, method, path) => {
112
- const staticHit = index.statik.get(`${method} ${path}`);
113
- if (staticHit)
114
- return { handler: staticHit, params: {} };
115
- for (const route of index.dynamic) {
116
- if (route.method !== method)
117
- continue;
118
- const params = matchPattern(route.pattern, path);
119
- if (params)
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 { method, path } = parseRouteKey(key);
190
- handlerRoutes.push({ method: method.toUpperCase(), path });
57
+ const response = await entry.handle(request, error);
58
+ if (response)
59
+ return response;
191
60
  }
192
61
  catch {
193
- // buildRouteIndex below will surface invalid keys with a real error.
62
+ return genericError();
194
63
  }
195
64
  }
196
- const adminRouteEntries = createAdminRoutes({
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 fetchImpl = async (req, suppressLog = false) => {
283
- const probe = buildRequest(req);
284
- const logStart = accessLogger && !suppressLog ? Date.now() : 0;
285
- const finalize = (res, errorMsg) => {
286
- if (!accessLogger || suppressLog)
287
- return res;
288
- accessLogger.emit({
289
- method: probe.method.toUpperCase(),
290
- path: probe.path,
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
- // 2. onRequest may short-circuit (e.g. mock-mode error injection).
339
- if (hooks.onRequest) {
340
- const short = await hooks.onRequest(probe);
341
- if (short) {
342
- // admin destroy sentinel: bubble up so `listen()` can drop the
343
- // underlying socket. `server.fetch()` callers observe the
344
- // throw — destroy is a transport-level signal, not a body.
345
- if (isDestroySocketSentinel(short)) {
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
- // 3. Dispatch.
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
- const fetch = (req) => fetchImpl(req);
396
- const listen = async (opts = {}) => {
397
- const requestedPort = opts.port ?? 0;
398
- const requestedHost = opts.host ?? '127.0.0.1';
399
- // Admin bind-policy: refuse non-loopback hosts unless explicitly
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
- // Tiny Node `http` ↔ Web Fetch adapter. Node ≥ 18.17 provides
406
- // `Request`, `Response`, `Headers`, and `Readable.{toWeb,fromWeb}`
407
- // as built-ins, so no polyfill or extra runtime dependency is needed.
408
- const server = createNodeHttpServer((req, res) => {
409
- // Build a Web Request out of the IncomingMessage.
410
- const host = req.headers.host ?? `${requestedHost}:${requestedPort}`;
411
- const url = `http://${host}${req.url ?? '/'}`;
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
- const init = { method, headers };
426
- if (method !== 'GET' && method !== 'HEAD') {
427
- init.body = Readable.toWeb(req);
428
- // Required when sending a stream body in undici/fetch.
429
- init.duplex = 'half';
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
- req.socket.destroy();
117
+ fallback = buildResponse(await handleError(request, caught));
455
118
  }
456
119
  catch {
457
- /* socket may already be gone */
120
+ fallback = buildResponse(genericError());
458
121
  }
459
- return;
122
+ outcome = { kind: 'response', response: await suppressHead(fallback, method) };
460
123
  }
461
- // Keep diagnostics on the server side; never leak internals to clients.
462
- // eslint-disable-next-line no-console
463
- console.error('[@databehave/server] request handling error:', err);
464
- if (!res.headersSent) {
465
- res.statusCode = 500;
466
- res.setHeader('content-type', 'application/json');
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
- res.end(JSON.stringify({ error: 'internal_error', message: 'Internal server error' }));
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
- return new Promise((resolve, reject) => {
472
- const onBindError = (err) => {
473
- reject(err);
474
- };
475
- server.once('error', onBindError);
476
- server.listen(requestedPort, requestedHost, () => {
477
- // Bind succeeded — detach the bind-time rejector so subsequent
478
- // runtime errors (EMFILE, accept errors, etc.) don't try to
479
- // resolve an already-settled promise. Surface them on stderr
480
- // instead so they're not swallowed silently.
481
- server.removeListener('error', onBindError);
482
- server.on('error', (err) => handleRuntimeServerError(err, hooks.onServerError));
483
- const info = server.address();
484
- if (admin) {
485
- emitAdminBanner(info.address, info.port, admin.path);
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
- resolve({
488
- port: info.port,
489
- host: info.address,
490
- close: () => new Promise((res, rej) => {
491
- server.close((err) => (err ? rej(err) : res()));
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