@databehave/server 0.1.4 → 0.3.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 +30 -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 +11 -24
  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
@@ -1,684 +0,0 @@
1
- /**
2
- * JSON / JSONC config loader for @databehave/server.
3
- *
4
- * Reads a `databehave.json` (or `.jsonc`) file from disk, validates
5
- * its shape, resolves the OpenAPI document, dynamically imports every
6
- * handler referenced by `endpoints`, and wires the built-in mock-mode
7
- * middleware. The returned value is a regular `Config` ready
8
- * for `createServer`.
9
- *
10
- * JSONC features (`//` comments, `/* block * /` comments, trailing
11
- * commas) are accepted in any config file regardless of extension.
12
- *
13
- * All paths inside the JSON are resolved relative to the JSON file's
14
- * directory. Handlers must be ESM modules whose `default` export is
15
- * a `Handler`.
16
- */
17
- import { readFile, stat } from 'node:fs/promises';
18
- import { dirname, isAbsolute, resolve } from 'node:path';
19
- import { pathToFileURL } from 'node:url';
20
- import { buildMockModeHooks, } from './mock-mode.js';
21
- import { _deepFreezeForDev } from './define.js';
22
- import { stripJsonc } from './jsonc.js';
23
- import { buildAutoSchemaRoutes, loadSchemaModule, normaliseSchemaConfig, } from './openapi/auto-schema.js';
24
- import { parseOpenApi } from './openapi/loader.js';
25
- import { buildOpenApiResponseGenerators } from './openapi/register.js';
26
- import { matchPattern, parseRouteKey, parseRoutePattern } from './route-key.js';
27
- const DEFAULT_PORT = 3000;
28
- const DEFAULT_HOST = '127.0.0.1';
29
- const resolvePath = (baseDir, p) => isAbsolute(p) ? p : resolve(baseDir, p);
30
- /**
31
- * Resolve `${VAR}` / `${VAR:default}` placeholders inside any string
32
- * value of the JSON tree. Walks recursively; non-string nodes are
33
- * returned as-is. Missing env var without default → empty string.
34
- */
35
- const interpolateEnv = (value, env = process.env) => {
36
- if (typeof value === 'string') {
37
- // Accept the conventional `[A-Z0-9_]+` shell-style names AND
38
- // lowercase / mixed-case names (e.g. `${path}`, `${myVar}`).
39
- // The leading char must still be a letter or underscore so we
40
- // don't accidentally interpolate `${1abc}` template fragments.
41
- return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}/g, (_, name, dflt) => {
42
- const v = env[name];
43
- if (v !== undefined && v !== '')
44
- return v;
45
- return dflt ?? '';
46
- });
47
- }
48
- if (Array.isArray(value))
49
- return value.map((v) => interpolateEnv(v, env));
50
- if (value !== null && typeof value === 'object') {
51
- const out = {};
52
- for (const [k, v] of Object.entries(value)) {
53
- out[k] = interpolateEnv(v, env);
54
- }
55
- return out;
56
- }
57
- return value;
58
- };
59
- /**
60
- * Coerce a port value that may have come from a `${VAR}` placeholder
61
- * (i.e. is now a numeric string).
62
- */
63
- const coercePort = (value, fallback) => {
64
- if (typeof value === 'number' && Number.isFinite(value))
65
- return value;
66
- if (typeof value === 'string' && value.trim() !== '') {
67
- const n = Number.parseInt(value, 10);
68
- if (Number.isFinite(n))
69
- return n;
70
- }
71
- return fallback;
72
- };
73
- const fileExists = async (path) => {
74
- try {
75
- const s = await stat(path);
76
- return s.isFile();
77
- }
78
- catch (err) {
79
- // Only swallow “does not exist”. Permission errors (EACCES, EPERM)
80
- // are real misconfigurations — surface them instead of pretending
81
- // the file is missing and falling through to a confusing
82
- // ERR_MODULE_NOT_FOUND.
83
- const code = err.code;
84
- if (code === 'ENOENT' || code === 'ENOTDIR')
85
- return false;
86
- throw err;
87
- }
88
- };
89
- /**
90
- * Normalize a handler module path so override-map lookups tolerate
91
- * trivial spelling differences between the JSONC value and the key
92
- * the caller built (typically from `import.meta.glob`).
93
- *
94
- * Strips a single leading `./`, collapses repeated `/`, and drops a
95
- * trailing `.js` / `.mjs` / `.cjs` / `.ts` / `.tsx` / `.mts` / `.cts`
96
- * so `./src/routes/x.js`, `src/routes/x`, and `./src/routes/x.ts` all
97
- * collapse to the same canonical form.
98
- */
99
- const canonicalizeHandlerKey = (modulePath) => modulePath
100
- .replace(/^\.\//, '')
101
- .replace(/\/{2,}/g, '/')
102
- .replace(/\.(?:js|mjs|cjs|ts|tsx|mts|cts)$/, '');
103
- /**
104
- * Resolve a handler module path to a URL that `import()` can load.
105
- *
106
- * Config files reference handlers by their compiled `.js` path
107
- * (e.g. `./src/routes/health.js`) so they stay valid for production
108
- * `tsc`-built deployments. In dev/test scenarios where only the
109
- * TypeScript source exists, fall back to the same path with a `.ts`
110
- * (or `.tsx`, `.mjs`, `.cjs`) extension so test runners that register
111
- * a TS loader (vitest, tsx, ts-node) can still resolve it.
112
- *
113
- * Set `DATABEHAVE_PREFER_TS=1` to prefer `.ts` siblings over the
114
- * declared `.js` even when both exist on disk (handy in dev when an
115
- * outdated `dist/` is still around and would otherwise win).
116
- */
117
- const resolveHandlerUrl = async (absolute) => {
118
- const preferTs = process.env.DATABEHAVE_PREFER_TS === '1';
119
- const m = absolute.match(/\.(js|mjs|cjs)$/);
120
- if (preferTs && m) {
121
- const stem = absolute.slice(0, -m[0].length);
122
- for (const ext of ['.ts', '.tsx', '.mts', '.cts']) {
123
- const candidate = `${stem}${ext}`;
124
- if (await fileExists(candidate))
125
- return pathToFileURL(candidate).href;
126
- }
127
- }
128
- if (await fileExists(absolute))
129
- return pathToFileURL(absolute).href;
130
- if (m) {
131
- const stem = absolute.slice(0, -m[0].length);
132
- for (const ext of ['.ts', '.tsx', '.mts', '.cts']) {
133
- const candidate = `${stem}${ext}`;
134
- if (await fileExists(candidate))
135
- return pathToFileURL(candidate).href;
136
- }
137
- }
138
- // No fallback hit — return the original URL so `import()` raises the
139
- // canonical ERR_MODULE_NOT_FOUND with the requested path.
140
- return pathToFileURL(absolute).href;
141
- };
142
- const importHandler = async (baseDir, modulePath, routeKey, handlerOverrides) => {
143
- // 1. Pre-resolved override map wins. The caller-supplied object is
144
- // flattened into a `Map` keyed by `canonicalizeHandlerKey` once at
145
- // load time (see `buildHandlerOverrideMap`), so lookup is O(1)
146
- // regardless of the number of overrides.
147
- if (handlerOverrides) {
148
- const candidate = handlerOverrides.get(canonicalizeHandlerKey(modulePath));
149
- if (candidate !== undefined) {
150
- if (typeof candidate !== 'function') {
151
- throw new Error(`@databehave/server/config: handlers override for ${JSON.stringify(modulePath)} (mapped to ${routeKey}) is not a function (got ${typeof candidate})`);
152
- }
153
- return candidate;
154
- }
155
- }
156
- // 2. Dynamic import from disk, with `.js` → `.ts/.tsx/.mts/.cts`
157
- // sibling fallback for TS-loader environments (tsx, ts-node).
158
- const absolute = resolvePath(baseDir, modulePath);
159
- const url = await resolveHandlerUrl(absolute);
160
- const mod = (await import(/* @vite-ignore */ url));
161
- const handler = mod.default;
162
- if (typeof handler !== 'function') {
163
- throw new Error(`@databehave/server/config: ${modulePath} (mapped to ${routeKey}) has no default-exported handler function`);
164
- }
165
- return handler;
166
- };
167
- /**
168
- * Convert an `EndpointResponse` (JSON-safe DTO) into a `MockResponse`
169
- * (discriminated union: either body OR empty).
170
- */
171
- const toResponse = (r) => {
172
- if (r.empty) {
173
- return {
174
- status: r.status,
175
- empty: true,
176
- ...(r.headers ? { headers: r.headers } : {}),
177
- };
178
- }
179
- return {
180
- status: r.status,
181
- json: r.json ?? null,
182
- ...(r.headers ? { headers: r.headers } : {}),
183
- };
184
- };
185
- const synthesizeResponseHandler = (r) => {
186
- const frozen = toResponse(r);
187
- return () => frozen;
188
- };
189
- const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
190
- const validateEndpointResponse = (v, routeKey) => {
191
- if (!isPlainObject(v)) {
192
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].response must be an object`);
193
- }
194
- if (typeof v.status !== 'number' || !Number.isFinite(v.status)) {
195
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].response.status must be a number`);
196
- }
197
- const out = { status: v.status };
198
- if (v.json !== undefined)
199
- out.json = v.json;
200
- if (v.empty !== undefined) {
201
- if (typeof v.empty !== 'boolean') {
202
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].response.empty must be a boolean`);
203
- }
204
- out.empty = v.empty;
205
- }
206
- if (v.headers !== undefined) {
207
- if (!isPlainObject(v.headers)) {
208
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].response.headers must be an object`);
209
- }
210
- const h = {};
211
- for (const [k, val] of Object.entries(v.headers)) {
212
- if (typeof val !== 'string') {
213
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].response.headers["${k}"] must be a string`);
214
- }
215
- h[k] = val;
216
- }
217
- out.headers = h;
218
- }
219
- return out;
220
- };
221
- const validateEndpointSpec = (raw, routeKey) => {
222
- if (typeof raw === 'string')
223
- return raw;
224
- if (!isPlainObject(raw)) {
225
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"] must be a string or object`);
226
- }
227
- const hasHandler = raw.handler !== undefined;
228
- const hasResponse = raw.response !== undefined;
229
- if (hasHandler && hasResponse) {
230
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"] cannot have both "handler" and "response"`);
231
- }
232
- if (!hasHandler && !hasResponse) {
233
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"] must have either "handler" or "response"`);
234
- }
235
- if (hasHandler) {
236
- if (typeof raw.handler !== 'string') {
237
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].handler must be a string`);
238
- }
239
- if (raw.status !== undefined) {
240
- if (typeof raw.status !== 'number' || !Number.isFinite(raw.status)) {
241
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].status must be a number`);
242
- }
243
- return { handler: raw.handler, status: raw.status };
244
- }
245
- return { handler: raw.handler };
246
- }
247
- // hasResponse
248
- if (raw.status !== undefined) {
249
- throw new Error(`@databehave/server/config: endpoints["${routeKey}"].status cannot be combined with "response" (put status inside response)`);
250
- }
251
- return { response: validateEndpointResponse(raw.response, routeKey) };
252
- };
253
- const validate = (raw) => {
254
- if (raw === null || typeof raw !== 'object') {
255
- throw new Error('@databehave/server/config: JSON root must be an object');
256
- }
257
- const obj = raw;
258
- const out = {};
259
- if (obj.openapi !== undefined) {
260
- if (typeof obj.openapi !== 'string') {
261
- throw new Error('@databehave/server/config: "openapi" must be a string path');
262
- }
263
- out.openapi = obj.openapi;
264
- }
265
- if (obj.server !== undefined) {
266
- if (typeof obj.server !== 'object' || obj.server === null) {
267
- throw new Error('@databehave/server/config: "server" must be an object');
268
- }
269
- out.server = obj.server;
270
- }
271
- if (obj.basePath !== undefined) {
272
- if (typeof obj.basePath !== 'string') {
273
- throw new Error('@databehave/server/config: "basePath" must be a string');
274
- }
275
- if (!obj.basePath.startsWith('/')) {
276
- throw new Error('@databehave/server/config: "basePath" must start with "/"');
277
- }
278
- if (obj.basePath.length > 1 && obj.basePath.endsWith('/')) {
279
- throw new Error('@databehave/server/config: "basePath" must not end with "/"');
280
- }
281
- out.basePath = obj.basePath;
282
- }
283
- if (obj.cors !== undefined) {
284
- if (typeof obj.cors !== 'object' || obj.cors === null) {
285
- throw new Error('@databehave/server/config: "cors" must be an object');
286
- }
287
- out.cors = obj.cors;
288
- }
289
- if (obj.mockMode !== undefined) {
290
- if (typeof obj.mockMode !== 'object' || obj.mockMode === null) {
291
- throw new Error('@databehave/server/config: "mockMode" must be an object');
292
- }
293
- const mm = obj.mockMode;
294
- if (mm.pathOverrides !== undefined) {
295
- if (typeof mm.pathOverrides !== 'object' ||
296
- mm.pathOverrides === null ||
297
- Array.isArray(mm.pathOverrides)) {
298
- throw new Error('@databehave/server/config: "mockMode.pathOverrides" must be an object');
299
- }
300
- // Keys must be either `<METHOD> <absolute-path>` or `<absolute-path>`.
301
- // Anything else silently never matches at runtime, which is the
302
- // exact class of typo that this validation exists to catch.
303
- const KEY_RE = /^(?:(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+)?\/[^\s]*$/i;
304
- // The mock-mode matcher uppercases the request method before
305
- // lookup (see `mock-mode.ts`), so a lowercase config key
306
- // (e.g. `"get /api/x"`) would never match. Normalize the method
307
- // portion here so the validator stays case-insensitive while the
308
- // stored map keys remain canonical.
309
- const normalized = {};
310
- for (const [k, v] of Object.entries(mm.pathOverrides)) {
311
- if (!KEY_RE.test(k)) {
312
- throw new Error(`@databehave/server/config: "mockMode.pathOverrides" key ${JSON.stringify(k)} must be "<METHOD> /path" or "/path" (path must start with "/")`);
313
- }
314
- if (typeof v !== 'number' || !Number.isFinite(v)) {
315
- throw new Error(`@databehave/server/config: "mockMode.pathOverrides[${JSON.stringify(k)}]" must be a number (HTTP status code)`);
316
- }
317
- const spaceIdx = k.indexOf(' ');
318
- const canonical = spaceIdx > 0
319
- ? `${k.slice(0, spaceIdx).toUpperCase()}${k.slice(spaceIdx)}`
320
- : k;
321
- normalized[canonical] = v;
322
- }
323
- mm.pathOverrides = normalized;
324
- }
325
- out.mockMode = mm;
326
- }
327
- if (obj.endpoints !== undefined) {
328
- if (typeof obj.endpoints !== 'object' || obj.endpoints === null) {
329
- throw new Error('@databehave/server/config: "endpoints" must be an object');
330
- }
331
- const map = {};
332
- for (const [k, v] of Object.entries(obj.endpoints)) {
333
- map[k] = validateEndpointSpec(v, k);
334
- }
335
- out.endpoints = map;
336
- }
337
- if (obj.admin !== undefined) {
338
- if (typeof obj.admin !== 'object' || obj.admin === null) {
339
- throw new Error('@databehave/server/config: "admin" must be an object');
340
- }
341
- // Pass-through; createServer validates the shape.
342
- out.admin = obj.admin;
343
- }
344
- if (obj.log !== undefined) {
345
- const v = obj.log;
346
- if (typeof v !== 'boolean' &&
347
- (typeof v !== 'object' || v === null || Array.isArray(v))) {
348
- throw new Error('@databehave/server/config: "log" must be a boolean or an object');
349
- }
350
- out.log = v;
351
- }
352
- if (obj.validation !== undefined) {
353
- if (typeof obj.validation !== 'object' || obj.validation === null || Array.isArray(obj.validation)) {
354
- throw new Error('@databehave/server/config: "validation" must be an object');
355
- }
356
- out.validation = obj.validation;
357
- }
358
- if (obj.schema !== undefined) {
359
- // Re-validate via `normaliseSchemaConfig` at load time; here we
360
- // just pass-through the raw shape so the loader can call the
361
- // normaliser once and reuse the resolved object. Reject the
362
- // removed boolean shorthand fail-fast at validation time so
363
- // the migration error surfaces with the same wording the
364
- // normaliser would emit.
365
- if (typeof obj.schema === 'boolean') {
366
- throw new Error('@databehave/server/config: "schema" must be an object like ' +
367
- '{ "enabled": true }; the boolean shorthand was removed (got ' +
368
- JSON.stringify(obj.schema) +
369
- ')');
370
- }
371
- out.schema = obj.schema;
372
- }
373
- return out;
374
- };
375
- /**
376
- * Rewrite an endpoint key by prepending `basePath` to its path
377
- * segment. Convention:
378
- * - Keys whose path starts with `/` are absolute and left untouched
379
- * (use this for cross-cutting endpoints like `/health`).
380
- * - Keys whose path does NOT start with `/` are treated as
381
- * relative to `basePath` and rewritten to `${basePath}/${path}`.
382
- * When `basePath` is empty or `/`, every key is forced to absolute
383
- * form by prepending a single `/` if missing.
384
- */
385
- const applyBasePath = (key, basePath) => {
386
- const sp = key.indexOf(' ');
387
- if (sp <= 0)
388
- return key;
389
- const method = key.slice(0, sp);
390
- const path = key.slice(sp + 1);
391
- if (path.startsWith('/'))
392
- return key;
393
- if (basePath === '' || basePath === '/')
394
- return `${method} /${path}`;
395
- return `${method} ${basePath}/${path}`;
396
- };
397
- /**
398
- * Load and fully resolve a `databehave.json`.
399
- *
400
- * Returns a ready-to-run `Config` plus the listen options the
401
- * CLI should bind to. Errors are thrown synchronously after parsing
402
- * to fail fast on bad configs.
403
- */
404
- export const loadConfig = async (configPath, options) => {
405
- const absoluteConfigPath = isAbsolute(configPath)
406
- ? configPath
407
- : resolve(process.cwd(), configPath);
408
- const baseDir = dirname(absoluteConfigPath);
409
- const raw = JSON.parse(stripJsonc(await readFile(absoluteConfigPath, 'utf8')));
410
- const json = validate(interpolateEnv(raw));
411
- // 0. Canonicalise the handler-override map once. Lookup inside
412
- // `importHandler` is then O(1) per route instead of scanning the
413
- // entire map for every endpoint that doesn't hit a direct key.
414
- const handlerOverrides = options?.handlers
415
- ? (() => {
416
- const m = new Map();
417
- for (const [key, fn] of Object.entries(options.handlers)) {
418
- m.set(canonicalizeHandlerKey(key), fn);
419
- }
420
- return m;
421
- })()
422
- : undefined;
423
- // 1. OpenAPI body (loaded from disk into a string). JSON only;
424
- // convert YAML up-front with `yq -o=json`. The raw file bytes
425
- // flow through unchanged so callers see the same source-mapped
426
- // error positions a standalone tool would report.
427
- let openapiBody;
428
- if (json.openapi !== undefined) {
429
- const specPath = resolvePath(baseDir, json.openapi);
430
- openapiBody = await readFile(specPath, 'utf8');
431
- }
432
- // 2. endpoints → routes map + per-endpoint status overrides.
433
- const routes = {};
434
- const endpointStatusOverrides = {};
435
- if (json.endpoints) {
436
- const basePath = json.basePath ?? '';
437
- const entries = Object.entries(json.endpoints);
438
- // Detect collisions where two endpoint keys collapse to the same
439
- // `METHOD path` after applying `basePath` — silent overwrite would
440
- // mask a real configuration bug.
441
- const seenKeys = new Map();
442
- for (const [rawKey] of entries) {
443
- const finalKey = applyBasePath(rawKey, basePath);
444
- const prior = seenKeys.get(finalKey);
445
- if (prior !== undefined && prior !== rawKey) {
446
- throw new Error(`@databehave/server/config: endpoints keys "${prior}" and "${rawKey}" both resolve to "${finalKey}" after applying basePath`);
447
- }
448
- seenKeys.set(finalKey, rawKey);
449
- }
450
- // Resolve every endpoint concurrently. `allSettled` collects all
451
- // failures so one bad path doesn't hide the rest — a misconfigured
452
- // config surfaces every broken handler at once instead of forcing
453
- // an iterative trial-and-error loop.
454
- const settled = await Promise.allSettled(entries.map(async ([rawKey, spec]) => {
455
- const key = applyBasePath(rawKey, basePath);
456
- parseRouteKey(key); // validate after prefixing
457
- if (typeof spec === 'string') {
458
- routes[key] = await importHandler(baseDir, spec, key, handlerOverrides);
459
- return;
460
- }
461
- if (spec.handler !== undefined) {
462
- routes[key] = await importHandler(baseDir, spec.handler, key, handlerOverrides);
463
- if (spec.status !== undefined) {
464
- endpointStatusOverrides[key] = spec.status;
465
- }
466
- return;
467
- }
468
- routes[key] = synthesizeResponseHandler(spec.response);
469
- }));
470
- const failures = settled.flatMap((r, i) => r.status === 'rejected'
471
- ? [{ reason: r.reason, rawKey: entries[i][0] }]
472
- : []);
473
- if (failures.length > 0) {
474
- const errors = failures.map((f) => f.reason instanceof Error ? f.reason : new Error(String(f.reason)));
475
- const messages = failures.map((f, i) => `${f.rawKey}: ${errors[i].message}`);
476
- // Node ≥ 18.17 always has AggregateError.
477
- throw new AggregateError(errors, `@databehave/server/config: ${failures.length} endpoint(s) failed to load:\n - ${messages.join('\n - ')}`);
478
- }
479
- }
480
- const warn = options?.logger?.warn ?? ((m) => {
481
- // eslint-disable-next-line no-console
482
- console.warn(m);
483
- });
484
- // 3. Built-in mock-mode hooks (with OAS-backed body resolver when
485
- // an OpenAPI document is present). Per-endpoint `status`
486
- // overrides are merged on top of any user-supplied
487
- // `mockMode.pathOverrides` (user values take precedence to keep
488
- // explicit JSON authoritative).
489
- let hooks;
490
- if (json.mockMode) {
491
- const resolvedLogger = options?.logger ?? json.mockMode.logger;
492
- const mergedConfig = {
493
- ...json.mockMode,
494
- pathOverrides: {
495
- ...endpointStatusOverrides,
496
- ...(json.mockMode.pathOverrides ?? {}),
497
- },
498
- // Thread the caller-supplied logger so mock-mode warnings
499
- // (e.g. out-of-range x-mock-status header overrides) flow
500
- // through the same channel as JSON/OAS warnings instead of
501
- // leaking to stdout. Only set the key when defined so
502
- // `exactOptionalPropertyTypes` is honoured.
503
- ...(resolvedLogger ? { logger: resolvedLogger } : {}),
504
- };
505
- let bodyResolver;
506
- if (openapiBody !== undefined) {
507
- try {
508
- const doc = parseOpenApi(openapiBody);
509
- const generators = buildOpenApiResponseGenerators(doc, {
510
- onWalkError: (method, path, err) => {
511
- const msg = err instanceof Error ? err.message : String(err);
512
- warn(`[@databehave/server] mock-mode body resolver: openapi walk failed for ${method.toUpperCase()} ${path}: ${msg}`);
513
- },
514
- });
515
- // Build a single pattern index, sorted so the dispatch
516
- // result does not depend on OAS declaration order:
517
- // 1. Static patterns first (exact paths beat parametric).
518
- // 2. Within the same kind, longer paths first (more
519
- // specific routes shadow shorter wildcards — e.g.
520
- // `/users/me` is preferred over `/users/:id`).
521
- // 3. Stable fallback on the raw path string for ties.
522
- // Without this sort, two OAS paths that could both match a
523
- // request resolve in `paths` insertion order — i.e. the
524
- // schema author's typing order silently picks the winner.
525
- const patternIndex = [];
526
- for (const [key, perStatus] of generators) {
527
- const { method, path } = parseRouteKey(key);
528
- patternIndex.push({
529
- method: method.toUpperCase(),
530
- pattern: parseRoutePattern(path),
531
- perStatus,
532
- });
533
- }
534
- patternIndex.sort((a, b) => {
535
- if (a.pattern.isStatic !== b.pattern.isStatic) {
536
- return a.pattern.isStatic ? -1 : 1;
537
- }
538
- if (a.pattern.segments.length !== b.pattern.segments.length) {
539
- return b.pattern.segments.length - a.pattern.segments.length;
540
- }
541
- return a.pattern.path.localeCompare(b.pattern.path);
542
- });
543
- bodyResolver = (method, path, status) => {
544
- const upper = method.toUpperCase();
545
- for (const entry of patternIndex) {
546
- if (entry.method !== upper)
547
- continue;
548
- if (entry.pattern.isStatic) {
549
- if (entry.pattern.path !== path)
550
- continue;
551
- }
552
- else if (matchPattern(entry.pattern, path) === null) {
553
- continue;
554
- }
555
- const gen = entry.perStatus.get(status);
556
- if (gen !== undefined)
557
- return gen();
558
- }
559
- return undefined;
560
- };
561
- }
562
- catch {
563
- // If OAS parsing fails, mock-mode still works with fallback envelope.
564
- bodyResolver = undefined;
565
- }
566
- }
567
- hooks = buildMockModeHooks(mergedConfig, bodyResolver);
568
- }
569
- // 4. Build a `cors` value with a sensible default `origin` so the
570
- // JSON config never has to declare a function. When `cors.origin`
571
- // is declared in JSON it may be either a single allowed origin
572
- // string or an allowlist of strings — anything not on the
573
- // allowlist returns an empty value so the runtime omits the
574
- // `Access-Control-Allow-Origin` header entirely (the de-facto
575
- // “reject” signal). Allowlist comparison is case-insensitive on
576
- // the scheme/host portion so trivial typos don't silently lock
577
- // callers out.
578
- let cors;
579
- if (json.cors) {
580
- const rawOrigin = json.cors.origin;
581
- const allowlist = typeof rawOrigin === 'string'
582
- ? [rawOrigin]
583
- : Array.isArray(rawOrigin) && rawOrigin.every((v) => typeof v === 'string')
584
- ? rawOrigin
585
- : undefined;
586
- const normalisedAllowlist = allowlist?.map((o) => o.toLowerCase());
587
- const { origin: _origin, ...rest } = json.cors;
588
- cors = {
589
- ...rest,
590
- origin: (o) => {
591
- if (normalisedAllowlist) {
592
- // With a declared allowlist: only echo back exact matches.
593
- // No request Origin and `credentials: true` is the canonical
594
- // "don't send ACAO" case per CORS §3.2.1 — returning empty
595
- // lets the response builder omit the header entirely instead
596
- // of echoing some arbitrary allowlist entry.
597
- if (o && normalisedAllowlist.includes(o.toLowerCase()))
598
- return o;
599
- return '';
600
- }
601
- return o ? o : '*';
602
- },
603
- };
604
- }
605
- // admin: pass through; the CLI loader supplements openapiBody
606
- // from the resolved OpenAPI document so the UI route picker is
607
- // populated without the consumer having to wire it manually.
608
- let admin;
609
- if (json.admin !== undefined) {
610
- admin = {
611
- ...json.admin,
612
- ...(openapiBody !== undefined && json.admin.openapiBody === undefined
613
- ? { openapiBody }
614
- : {}),
615
- };
616
- }
617
- // 5. Auto-schema dispatch (Phase 2C / brief #11). When the JSONC
618
- // config sets a top-level `schema:` field, dynamic-import
619
- // `@databehave/schema` (suppressing the native "Cannot find
620
- // module" with the friendly install hint), pre-translate every
621
- // OAS path/method whose 200 response carries a JSON schema, and
622
- // inject synthesized handlers into `routes`. Hand-written
623
- // `endpoints` always win (auto-mode skips routes already in
624
- // `routes`). When `schema:` is absent everything below this
625
- // block is a no-op and the OAS-fallback walker in `server.ts`
626
- // keeps using `generateFromOasSchema` (the zero-dep
627
- // placeholder generator) — byte-identical legacy behaviour.
628
- // 5. Auto-schema dispatch (Phase 2C / brief #11). When the JSONC
629
- // config sets `"schema": { "enabled": true }`, dynamic-import
630
- // `@databehave/schema` (suppressing the native "Cannot find
631
- // module" with the friendly install hint), pre-translate every
632
- // OAS path/method whose 200 response carries a JSON schema, and
633
- // inject synthesized handlers into `routes`. Hand-written
634
- // `endpoints` always win (auto-mode skips routes already in
635
- // `routes`). When `schema.enabled` is `false` (or the field is
636
- // absent) everything below this block is a no-op and the
637
- // OAS-fallback walker in `server.ts` keeps using
638
- // `generateFromOasSchema` (the zero-dep placeholder generator)
639
- // — byte-identical legacy behaviour.
640
- if (json.schema !== undefined) {
641
- const schemaCfg = normaliseSchemaConfig(json.schema);
642
- if (schemaCfg !== null) {
643
- // Cache the imported module on the loaded-config closure for
644
- // later use; throws fail-fast (with the friendly install-hint
645
- // template) if the optional peer is not installed. Native
646
- // "Cannot find module" stack traces never reach the consumer.
647
- const schemaModule = await loadSchemaModule();
648
- if (openapiBody !== undefined) {
649
- const doc = parseOpenApi(openapiBody);
650
- const declaredKeys = new Set(Object.keys(routes).filter((k) => routes[k] !== undefined));
651
- const autoRoutes = await buildAutoSchemaRoutes(doc, schemaCfg, schemaModule, declaredKeys);
652
- for (const [key, handler] of autoRoutes) {
653
- routes[key] = handler;
654
- }
655
- }
656
- }
657
- }
658
- const config = {
659
- routes,
660
- ...(openapiBody !== undefined ? { openapi: openapiBody } : {}),
661
- ...(hooks !== undefined ? { hooks } : {}),
662
- ...(cors !== undefined ? { cors } : {}),
663
- ...(admin !== undefined ? { admin } : {}),
664
- ...(json.log !== undefined ? { log: json.log } : {}),
665
- ...(json.validation !== undefined ? { validation: json.validation } : {}),
666
- onOpenApiWalkError: (method, path, err) => {
667
- const msg = err instanceof Error ? err.message : String(err);
668
- warn(`[@databehave/server] openapi walk failed for ${method.toUpperCase()} ${path}: ${msg}`);
669
- },
670
- onOpenApiEmptySchema: (method, path, status) => {
671
- warn(`[@databehave/server] OAS response schema is empty for ${method.toUpperCase()} ${path} (status ${status}) — serving stub. Consider filling the schema in your OpenAPI document.`);
672
- },
673
- };
674
- const server = {
675
- host: json.server?.host ?? DEFAULT_HOST,
676
- port: coercePort(json.server?.port, DEFAULT_PORT),
677
- };
678
- // Deep-freeze in dev so the resolved config behaves identically to
679
- // one returned by `defineConfig()` — accidental late mutation
680
- // throws loudly instead of corrupting the running server.
681
- _deepFreezeForDev(config);
682
- return { config, server };
683
- };
684
- //# sourceMappingURL=json-config.js.map