vector-framework 1.1.1 → 1.2.1

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 (128) hide show
  1. package/README.md +99 -628
  2. package/dist/auth/protected.d.ts +1 -0
  3. package/dist/auth/protected.d.ts.map +1 -1
  4. package/dist/auth/protected.js +3 -0
  5. package/dist/auth/protected.js.map +1 -1
  6. package/dist/cache/manager.d.ts +1 -0
  7. package/dist/cache/manager.d.ts.map +1 -1
  8. package/dist/cache/manager.js +5 -7
  9. package/dist/cache/manager.js.map +1 -1
  10. package/dist/cli/graceful-shutdown.d.ts +15 -0
  11. package/dist/cli/graceful-shutdown.d.ts.map +1 -0
  12. package/dist/cli/graceful-shutdown.js +42 -0
  13. package/dist/cli/graceful-shutdown.js.map +1 -0
  14. package/dist/cli/index.js +46 -97
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/option-resolution.d.ts +4 -0
  17. package/dist/cli/option-resolution.d.ts.map +1 -0
  18. package/dist/cli/option-resolution.js +28 -0
  19. package/dist/cli/option-resolution.js.map +1 -0
  20. package/dist/cli.js +3423 -660
  21. package/dist/constants/index.d.ts +3 -0
  22. package/dist/constants/index.d.ts.map +1 -1
  23. package/dist/constants/index.js +6 -0
  24. package/dist/constants/index.js.map +1 -1
  25. package/dist/core/config-loader.d.ts.map +1 -1
  26. package/dist/core/config-loader.js +7 -2
  27. package/dist/core/config-loader.js.map +1 -1
  28. package/dist/core/router.d.ts +41 -17
  29. package/dist/core/router.d.ts.map +1 -1
  30. package/dist/core/router.js +432 -153
  31. package/dist/core/router.js.map +1 -1
  32. package/dist/core/server.d.ts +17 -1
  33. package/dist/core/server.d.ts.map +1 -1
  34. package/dist/core/server.js +471 -31
  35. package/dist/core/server.js.map +1 -1
  36. package/dist/core/vector.d.ts +8 -5
  37. package/dist/core/vector.d.ts.map +1 -1
  38. package/dist/core/vector.js +53 -14
  39. package/dist/core/vector.js.map +1 -1
  40. package/dist/dev/route-generator.d.ts.map +1 -1
  41. package/dist/dev/route-generator.js.map +1 -1
  42. package/dist/dev/route-scanner.d.ts.map +1 -1
  43. package/dist/dev/route-scanner.js +1 -5
  44. package/dist/dev/route-scanner.js.map +1 -1
  45. package/dist/http.d.ts +14 -14
  46. package/dist/http.d.ts.map +1 -1
  47. package/dist/http.js +34 -41
  48. package/dist/http.js.map +1 -1
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1420 -8
  52. package/dist/index.js.map +1 -1
  53. package/dist/index.mjs +1420 -8
  54. package/dist/middleware/manager.d.ts.map +1 -1
  55. package/dist/middleware/manager.js +4 -0
  56. package/dist/middleware/manager.js.map +1 -1
  57. package/dist/openapi/docs-ui.d.ts +2 -0
  58. package/dist/openapi/docs-ui.d.ts.map +1 -0
  59. package/dist/openapi/docs-ui.js +1425 -0
  60. package/dist/openapi/docs-ui.js.map +1 -0
  61. package/dist/openapi/generator.d.ts +12 -0
  62. package/dist/openapi/generator.d.ts.map +1 -0
  63. package/dist/openapi/generator.js +502 -0
  64. package/dist/openapi/generator.js.map +1 -0
  65. package/dist/start-vector.d.ts +3 -0
  66. package/dist/start-vector.d.ts.map +1 -0
  67. package/dist/start-vector.js +38 -0
  68. package/dist/start-vector.js.map +1 -0
  69. package/dist/types/index.d.ts +95 -11
  70. package/dist/types/index.d.ts.map +1 -1
  71. package/dist/types/standard-schema.d.ts +118 -0
  72. package/dist/types/standard-schema.d.ts.map +1 -0
  73. package/dist/types/standard-schema.js +2 -0
  74. package/dist/types/standard-schema.js.map +1 -0
  75. package/dist/utils/cors.d.ts +13 -0
  76. package/dist/utils/cors.d.ts.map +1 -0
  77. package/dist/utils/cors.js +89 -0
  78. package/dist/utils/cors.js.map +1 -0
  79. package/dist/utils/logger.js +1 -1
  80. package/dist/utils/path.d.ts +6 -0
  81. package/dist/utils/path.d.ts.map +1 -1
  82. package/dist/utils/path.js +5 -0
  83. package/dist/utils/path.js.map +1 -1
  84. package/dist/utils/schema-validation.d.ts +31 -0
  85. package/dist/utils/schema-validation.d.ts.map +1 -0
  86. package/dist/utils/schema-validation.js +77 -0
  87. package/dist/utils/schema-validation.js.map +1 -0
  88. package/dist/utils/validation.d.ts.map +1 -1
  89. package/dist/utils/validation.js +3 -0
  90. package/dist/utils/validation.js.map +1 -1
  91. package/package.json +15 -12
  92. package/src/auth/protected.ts +7 -13
  93. package/src/cache/manager.ts +8 -18
  94. package/src/cli/graceful-shutdown.ts +60 -0
  95. package/src/cli/index.ts +52 -115
  96. package/src/cli/option-resolution.ts +40 -0
  97. package/src/constants/index.ts +7 -0
  98. package/src/core/config-loader.ts +7 -4
  99. package/src/core/router.ts +502 -156
  100. package/src/core/server.ts +610 -33
  101. package/src/core/vector.ts +87 -33
  102. package/src/dev/route-generator.ts +1 -3
  103. package/src/dev/route-scanner.ts +2 -9
  104. package/src/http.ts +85 -125
  105. package/src/index.ts +4 -3
  106. package/src/middleware/manager.ts +4 -0
  107. package/src/openapi/assets/favicon/android-chrome-192x192.png +0 -0
  108. package/src/openapi/assets/favicon/android-chrome-512x512.png +0 -0
  109. package/src/openapi/assets/favicon/apple-touch-icon.png +0 -0
  110. package/src/openapi/assets/favicon/favicon-16x16.png +0 -0
  111. package/src/openapi/assets/favicon/favicon-32x32.png +0 -0
  112. package/src/openapi/assets/favicon/favicon.ico +0 -0
  113. package/src/openapi/assets/favicon/site.webmanifest +11 -0
  114. package/src/openapi/assets/logo.svg +12 -0
  115. package/src/openapi/assets/logo_dark.svg +6 -0
  116. package/src/openapi/assets/logo_icon.png +0 -0
  117. package/src/openapi/assets/logo_white.svg +6 -0
  118. package/src/openapi/assets/tailwindcdn.js +83 -0
  119. package/src/openapi/docs-ui.ts +1435 -0
  120. package/src/openapi/generator.ts +586 -0
  121. package/src/start-vector.ts +50 -0
  122. package/src/types/index.ts +138 -17
  123. package/src/types/standard-schema.ts +147 -0
  124. package/src/utils/cors.ts +101 -0
  125. package/src/utils/logger.ts +1 -1
  126. package/src/utils/path.ts +6 -0
  127. package/src/utils/schema-validation.ts +123 -0
  128. package/src/utils/validation.ts +3 -0
@@ -1,28 +1,270 @@
1
1
  import type { Server } from 'bun';
2
- import { cors } from 'itty-router';
3
- import type { CorsOptions, DefaultVectorTypes, VectorConfig, VectorTypes } from '../types';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { STATIC_RESPONSES } from '../constants';
5
+ import { cors } from '../utils/cors';
6
+ import { renderOpenAPIDocsHtml } from '../openapi/docs-ui';
7
+ import { generateOpenAPIDocument } from '../openapi/generator';
8
+ import type { CorsOptions, DefaultVectorTypes, OpenAPIOptions, VectorConfig, VectorTypes } from '../types';
4
9
  import type { VectorRouter } from './router';
5
10
 
11
+ interface NormalizedOpenAPIConfig {
12
+ enabled: boolean;
13
+ path: string;
14
+ target: string;
15
+ docs: {
16
+ enabled: boolean;
17
+ path: string;
18
+ exposePaths?: string[];
19
+ };
20
+ info?: {
21
+ title?: string;
22
+ version?: string;
23
+ description?: string;
24
+ };
25
+ }
26
+
27
+ const OPENAPI_TAILWIND_ASSET_PATH = '/_vector/openapi/tailwindcdn.js';
28
+ const OPENAPI_LOGO_DARK_ASSET_PATH = '/_vector/openapi/logo_dark.svg';
29
+ const OPENAPI_LOGO_WHITE_ASSET_PATH = '/_vector/openapi/logo_white.svg';
30
+ const OPENAPI_APPLE_TOUCH_ICON_ASSET_PATH = '/_vector/openapi/favicon/apple-touch-icon.png';
31
+ const OPENAPI_FAVICON_32_ASSET_PATH = '/_vector/openapi/favicon/favicon-32x32.png';
32
+ const OPENAPI_FAVICON_16_ASSET_PATH = '/_vector/openapi/favicon/favicon-16x16.png';
33
+ const OPENAPI_FAVICON_ICO_ASSET_PATH = '/_vector/openapi/favicon/favicon.ico';
34
+ const OPENAPI_WEBMANIFEST_ASSET_PATH = '/_vector/openapi/favicon/site.webmanifest';
35
+ const OPENAPI_ANDROID_192_ASSET_PATH = '/_vector/openapi/favicon/android-chrome-192x192.png';
36
+ const OPENAPI_ANDROID_512_ASSET_PATH = '/_vector/openapi/favicon/android-chrome-512x512.png';
37
+ const OPENAPI_TAILWIND_ASSET_RELATIVE_CANDIDATES = [
38
+ // Source execution (src/core/server.ts -> src/openapi/assets/tailwindcdn.js)
39
+ '../openapi/assets/tailwindcdn.js',
40
+ // Bundled dist entrypoints (dist/index.mjs|dist/cli.js -> src/openapi/assets/tailwindcdn.js)
41
+ '../src/openapi/assets/tailwindcdn.js',
42
+ // Unbundled dist/core/server.js execution (dist/core -> src/openapi/assets/tailwindcdn.js)
43
+ '../../src/openapi/assets/tailwindcdn.js',
44
+ ] as const;
45
+ const OPENAPI_LOGO_DARK_ASSET_RELATIVE_CANDIDATES = [
46
+ // Source execution (src/core/server.ts -> src/openapi/assets/logo_dark.svg)
47
+ '../openapi/assets/logo_dark.svg',
48
+ // Bundled dist entrypoints (dist/index.mjs|dist/cli.js -> src/openapi/assets/logo_dark.svg)
49
+ '../src/openapi/assets/logo_dark.svg',
50
+ // Unbundled dist/core/server.js execution (dist/core -> src/openapi/assets/logo_dark.svg)
51
+ '../../src/openapi/assets/logo_dark.svg',
52
+ ] as const;
53
+ const OPENAPI_LOGO_WHITE_ASSET_RELATIVE_CANDIDATES = [
54
+ // Source execution (src/core/server.ts -> src/openapi/assets/logo_white.svg)
55
+ '../openapi/assets/logo_white.svg',
56
+ // Bundled dist entrypoints (dist/index.mjs|dist/cli.js -> src/openapi/assets/logo_white.svg)
57
+ '../src/openapi/assets/logo_white.svg',
58
+ // Unbundled dist/core/server.js execution (dist/core -> src/openapi/assets/logo_white.svg)
59
+ '../../src/openapi/assets/logo_white.svg',
60
+ ] as const;
61
+ const OPENAPI_TAILWIND_ASSET_CWD_CANDIDATES = [
62
+ 'src/openapi/assets/tailwindcdn.js',
63
+ 'openapi/assets/tailwindcdn.js',
64
+ 'dist/openapi/assets/tailwindcdn.js',
65
+ ] as const;
66
+ const OPENAPI_LOGO_DARK_ASSET_CWD_CANDIDATES = [
67
+ 'src/openapi/assets/logo_dark.svg',
68
+ 'openapi/assets/logo_dark.svg',
69
+ 'dist/openapi/assets/logo_dark.svg',
70
+ ] as const;
71
+ const OPENAPI_LOGO_WHITE_ASSET_CWD_CANDIDATES = [
72
+ 'src/openapi/assets/logo_white.svg',
73
+ 'openapi/assets/logo_white.svg',
74
+ 'dist/openapi/assets/logo_white.svg',
75
+ ] as const;
76
+ const OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES = [
77
+ '../openapi/assets/favicon',
78
+ '../src/openapi/assets/favicon',
79
+ '../../src/openapi/assets/favicon',
80
+ ] as const;
81
+ const OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES = [
82
+ 'src/openapi/assets/favicon',
83
+ 'openapi/assets/favicon',
84
+ 'dist/openapi/assets/favicon',
85
+ ] as const;
86
+ const OPENAPI_TAILWIND_ASSET_INLINE_FALLBACK = '/* OpenAPI docs runtime asset missing: tailwind disabled */';
87
+
88
+ function buildOpenAPIAssetCandidatePaths(bases: readonly string[], filename: string): string[] {
89
+ return bases.map((base) => `${base}/${filename}`);
90
+ }
91
+
92
+ function resolveOpenAPIAssetFile(
93
+ relativeCandidates: readonly string[],
94
+ cwdCandidates: readonly string[]
95
+ ): ReturnType<typeof Bun.file> | null {
96
+ for (const relativePath of relativeCandidates) {
97
+ try {
98
+ const fileUrl = new URL(relativePath, import.meta.url);
99
+ if (existsSync(fileUrl)) {
100
+ return Bun.file(fileUrl);
101
+ }
102
+ } catch {
103
+ // Ignore resolution failures and try the next candidate.
104
+ }
105
+ }
106
+
107
+ const cwd = process.cwd();
108
+ for (const relativePath of cwdCandidates) {
109
+ const absolutePath = join(cwd, relativePath);
110
+ if (existsSync(absolutePath)) {
111
+ return Bun.file(absolutePath);
112
+ }
113
+ }
114
+
115
+ return null;
116
+ }
117
+
118
+ const OPENAPI_TAILWIND_ASSET_FILE = resolveOpenAPIAssetFile(
119
+ OPENAPI_TAILWIND_ASSET_RELATIVE_CANDIDATES,
120
+ OPENAPI_TAILWIND_ASSET_CWD_CANDIDATES
121
+ );
122
+ const OPENAPI_LOGO_DARK_ASSET_FILE = resolveOpenAPIAssetFile(
123
+ OPENAPI_LOGO_DARK_ASSET_RELATIVE_CANDIDATES,
124
+ OPENAPI_LOGO_DARK_ASSET_CWD_CANDIDATES
125
+ );
126
+ const OPENAPI_LOGO_WHITE_ASSET_FILE = resolveOpenAPIAssetFile(
127
+ OPENAPI_LOGO_WHITE_ASSET_RELATIVE_CANDIDATES,
128
+ OPENAPI_LOGO_WHITE_ASSET_CWD_CANDIDATES
129
+ );
130
+ const OPENAPI_APPLE_TOUCH_ICON_ASSET_FILE = resolveOpenAPIAssetFile(
131
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, 'apple-touch-icon.png'),
132
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, 'apple-touch-icon.png')
133
+ );
134
+ const OPENAPI_FAVICON_32_ASSET_FILE = resolveOpenAPIAssetFile(
135
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, 'favicon-32x32.png'),
136
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, 'favicon-32x32.png')
137
+ );
138
+ const OPENAPI_FAVICON_16_ASSET_FILE = resolveOpenAPIAssetFile(
139
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, 'favicon-16x16.png'),
140
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, 'favicon-16x16.png')
141
+ );
142
+ const OPENAPI_FAVICON_ICO_ASSET_FILE = resolveOpenAPIAssetFile(
143
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, 'favicon.ico'),
144
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, 'favicon.ico')
145
+ );
146
+ const OPENAPI_WEBMANIFEST_ASSET_FILE = resolveOpenAPIAssetFile(
147
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, 'site.webmanifest'),
148
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, 'site.webmanifest')
149
+ );
150
+ const OPENAPI_ANDROID_192_ASSET_FILE = resolveOpenAPIAssetFile(
151
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, 'android-chrome-192x192.png'),
152
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, 'android-chrome-192x192.png')
153
+ );
154
+ const OPENAPI_ANDROID_512_ASSET_FILE = resolveOpenAPIAssetFile(
155
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, 'android-chrome-512x512.png'),
156
+ buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, 'android-chrome-512x512.png')
157
+ );
158
+ const OPENAPI_FAVICON_ASSETS = [
159
+ {
160
+ path: OPENAPI_APPLE_TOUCH_ICON_ASSET_PATH,
161
+ file: OPENAPI_APPLE_TOUCH_ICON_ASSET_FILE,
162
+ contentType: 'image/png',
163
+ filename: 'apple-touch-icon.png',
164
+ },
165
+ {
166
+ path: OPENAPI_FAVICON_32_ASSET_PATH,
167
+ file: OPENAPI_FAVICON_32_ASSET_FILE,
168
+ contentType: 'image/png',
169
+ filename: 'favicon-32x32.png',
170
+ },
171
+ {
172
+ path: OPENAPI_FAVICON_16_ASSET_PATH,
173
+ file: OPENAPI_FAVICON_16_ASSET_FILE,
174
+ contentType: 'image/png',
175
+ filename: 'favicon-16x16.png',
176
+ },
177
+ {
178
+ path: OPENAPI_FAVICON_ICO_ASSET_PATH,
179
+ file: OPENAPI_FAVICON_ICO_ASSET_FILE,
180
+ contentType: 'image/x-icon',
181
+ filename: 'favicon.ico',
182
+ },
183
+ {
184
+ path: OPENAPI_WEBMANIFEST_ASSET_PATH,
185
+ file: OPENAPI_WEBMANIFEST_ASSET_FILE,
186
+ contentType: 'application/manifest+json; charset=utf-8',
187
+ filename: 'site.webmanifest',
188
+ },
189
+ {
190
+ path: OPENAPI_ANDROID_192_ASSET_PATH,
191
+ file: OPENAPI_ANDROID_192_ASSET_FILE,
192
+ contentType: 'image/png',
193
+ filename: 'android-chrome-192x192.png',
194
+ },
195
+ {
196
+ path: OPENAPI_ANDROID_512_ASSET_PATH,
197
+ file: OPENAPI_ANDROID_512_ASSET_FILE,
198
+ contentType: 'image/png',
199
+ filename: 'android-chrome-512x512.png',
200
+ },
201
+ ] as const;
202
+ const DOCS_HTML_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
203
+ const DOCS_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
204
+ const DOCS_ASSET_ERROR_CACHE_CONTROL = 'no-store';
205
+
206
+ interface OpenAPIDocsHtmlCacheEntry {
207
+ html: string;
208
+ gzip: Uint8Array;
209
+ etag: string;
210
+ }
211
+
212
+ function escapeRegex(value: string): string {
213
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
214
+ }
215
+
216
+ function wildcardPatternToRegex(pattern: string): RegExp {
217
+ let regexSource = '^';
218
+ for (const char of pattern) {
219
+ if (char === '*') {
220
+ regexSource += '.*';
221
+ continue;
222
+ }
223
+ regexSource += escapeRegex(char);
224
+ }
225
+ regexSource += '$';
226
+ return new RegExp(regexSource);
227
+ }
228
+
229
+ function matchesExposePath(path: string, exposePathPattern: string): boolean {
230
+ if (!exposePathPattern.includes('*')) {
231
+ return path === exposePathPattern;
232
+ }
233
+ return wildcardPatternToRegex(exposePathPattern).test(path);
234
+ }
235
+
6
236
  export class VectorServer<TTypes extends VectorTypes = DefaultVectorTypes> {
7
237
  private server: Server | null = null;
8
238
  private router: VectorRouter<TTypes>;
9
239
  private config: VectorConfig<TTypes>;
10
- private corsHandler: any;
11
- private corsHeaders: Record<string, string> | null = null;
240
+ private openapiConfig: NormalizedOpenAPIConfig;
241
+ private openapiDocCache: Record<string, unknown> | null = null;
242
+ private openapiDocsHtmlCache: OpenAPIDocsHtmlCacheEntry | null = null;
243
+ private openapiWarningsLogged = false;
244
+ private openapiTailwindMissingLogged = false;
245
+ private openapiLogoDarkMissingLogged = false;
246
+ private openapiLogoWhiteMissingLogged = false;
247
+ private corsHandler: {
248
+ preflight: (request: Request) => Response;
249
+ corsify: (response: Response, request: Request) => Response;
250
+ } | null = null;
251
+ private corsHeadersEntries: [string, string][] | null = null;
12
252
 
13
253
  constructor(router: VectorRouter<TTypes>, config: VectorConfig<TTypes>) {
14
254
  this.router = router;
15
255
  this.config = config;
256
+ this.openapiConfig = this.normalizeOpenAPIConfig(config.openapi, config.development);
16
257
 
17
258
  if (config.cors) {
18
259
  const opts = this.normalizeCorsOptions(config.cors);
19
260
  const { preflight, corsify } = cors(opts);
20
261
  this.corsHandler = { preflight, corsify };
21
262
 
22
- // Pre-build static CORS headers when origin is a fixed string.
23
- // Avoids cloning the Response on every request via corsify().
24
- if (typeof opts.origin === 'string') {
25
- this.corsHeaders = {
263
+ // Pre-build static CORS headers when origin does not require per-request reflection.
264
+ const canUseStaticCorsHeaders = typeof opts.origin === 'string' && (opts.origin !== '*' || !opts.credentials);
265
+
266
+ if (canUseStaticCorsHeaders) {
267
+ const corsHeaders: Record<string, string> = {
26
268
  'access-control-allow-origin': opts.origin,
27
269
  'access-control-allow-methods': opts.allowMethods,
28
270
  'access-control-allow-headers': opts.allowHeaders,
@@ -30,10 +272,333 @@ export class VectorServer<TTypes extends VectorTypes = DefaultVectorTypes> {
30
272
  'access-control-max-age': String(opts.maxAge),
31
273
  };
32
274
  if (opts.credentials) {
33
- this.corsHeaders['access-control-allow-credentials'] = 'true';
275
+ corsHeaders['access-control-allow-credentials'] = 'true';
276
+ }
277
+ this.corsHeadersEntries = Object.entries(corsHeaders);
278
+ }
279
+
280
+ // Pass CORS behavior to router so matched routes also receive CORS headers.
281
+ this.router.setCorsHeaders(this.corsHeadersEntries);
282
+ this.router.setCorsHandler(this.corsHeadersEntries ? null : this.corsHandler.corsify);
283
+ }
284
+ }
285
+
286
+ private normalizeOpenAPIConfig(
287
+ openapi: OpenAPIOptions | boolean | undefined,
288
+ development: boolean | undefined
289
+ ): NormalizedOpenAPIConfig {
290
+ const isDev = development !== false && process.env.NODE_ENV !== 'production';
291
+ const defaultEnabled = isDev;
292
+
293
+ if (openapi === false) {
294
+ return {
295
+ enabled: false,
296
+ path: '/openapi.json',
297
+ target: 'openapi-3.0',
298
+ docs: { enabled: false, path: '/docs' },
299
+ };
300
+ }
301
+
302
+ if (openapi === true) {
303
+ return {
304
+ enabled: true,
305
+ path: '/openapi.json',
306
+ target: 'openapi-3.0',
307
+ docs: { enabled: false, path: '/docs' },
308
+ };
309
+ }
310
+
311
+ const openapiObject = openapi || {};
312
+ const docsValue = openapiObject.docs;
313
+ const docs =
314
+ typeof docsValue === 'boolean'
315
+ ? { enabled: docsValue, path: '/docs', exposePaths: undefined }
316
+ : {
317
+ enabled: docsValue?.enabled === true,
318
+ path: docsValue?.path || '/docs',
319
+ exposePaths: Array.isArray(docsValue?.exposePaths)
320
+ ? docsValue.exposePaths
321
+ .map((path) => (typeof path === 'string' ? path.trim() : ''))
322
+ .filter((path) => path.length > 0)
323
+ : undefined,
324
+ };
325
+
326
+ return {
327
+ enabled: openapiObject.enabled ?? defaultEnabled,
328
+ path: openapiObject.path || '/openapi.json',
329
+ target: openapiObject.target || 'openapi-3.0',
330
+ docs,
331
+ info: openapiObject.info,
332
+ };
333
+ }
334
+
335
+ private isDocsReservedPath(path: string): boolean {
336
+ return (
337
+ path === this.openapiConfig.path || (this.openapiConfig.docs.enabled && path === this.openapiConfig.docs.path)
338
+ );
339
+ }
340
+
341
+ private getOpenAPIDocument(): Record<string, unknown> {
342
+ if (this.openapiDocCache) {
343
+ return this.openapiDocCache;
344
+ }
345
+
346
+ const routes = this.router.getRouteDefinitions().filter((route) => !this.isDocsReservedPath(route.path));
347
+
348
+ const result = generateOpenAPIDocument(routes as any, {
349
+ target: this.openapiConfig.target,
350
+ info: this.openapiConfig.info,
351
+ });
352
+
353
+ if (!this.openapiWarningsLogged && result.warnings.length > 0) {
354
+ for (const warning of result.warnings) {
355
+ console.warn(warning);
356
+ }
357
+ this.openapiWarningsLogged = true;
358
+ }
359
+
360
+ this.openapiDocCache = result.document;
361
+ return this.openapiDocCache;
362
+ }
363
+
364
+ private getOpenAPIDocumentForDocs(): Record<string, unknown> {
365
+ const exposePaths = this.openapiConfig.docs.exposePaths;
366
+ const document = this.getOpenAPIDocument();
367
+
368
+ if (!Array.isArray(exposePaths) || exposePaths.length === 0) {
369
+ return document;
370
+ }
371
+
372
+ const existingPaths =
373
+ document.paths && typeof document.paths === 'object' && !Array.isArray(document.paths)
374
+ ? (document.paths as Record<string, unknown>)
375
+ : {};
376
+
377
+ const filteredPaths: Record<string, unknown> = {};
378
+ for (const [path, value] of Object.entries(existingPaths)) {
379
+ if (exposePaths.some((pattern) => matchesExposePath(path, pattern))) {
380
+ filteredPaths[path] = value;
381
+ }
382
+ }
383
+
384
+ return {
385
+ ...document,
386
+ paths: filteredPaths,
387
+ };
388
+ }
389
+
390
+ private getOpenAPIDocsHtmlCacheEntry(): OpenAPIDocsHtmlCacheEntry {
391
+ if (this.openapiDocsHtmlCache) {
392
+ return this.openapiDocsHtmlCache;
393
+ }
394
+
395
+ const html = renderOpenAPIDocsHtml(
396
+ this.getOpenAPIDocumentForDocs(),
397
+ this.openapiConfig.path,
398
+ OPENAPI_TAILWIND_ASSET_PATH,
399
+ OPENAPI_LOGO_DARK_ASSET_PATH,
400
+ OPENAPI_LOGO_WHITE_ASSET_PATH,
401
+ OPENAPI_APPLE_TOUCH_ICON_ASSET_PATH,
402
+ OPENAPI_FAVICON_32_ASSET_PATH,
403
+ OPENAPI_FAVICON_16_ASSET_PATH,
404
+ OPENAPI_WEBMANIFEST_ASSET_PATH
405
+ );
406
+ const gzip = Bun.gzipSync(html);
407
+ const etag = `"${Bun.hash(html).toString(16)}"`;
408
+
409
+ this.openapiDocsHtmlCache = { html, gzip, etag };
410
+ return this.openapiDocsHtmlCache;
411
+ }
412
+
413
+ private requestAcceptsGzip(request: Request): boolean {
414
+ const acceptEncoding = request.headers.get('accept-encoding');
415
+ return Boolean(acceptEncoding && /\bgzip\b/i.test(acceptEncoding));
416
+ }
417
+
418
+ private validateReservedOpenAPIPaths(): void {
419
+ if (!this.openapiConfig.enabled) {
420
+ return;
421
+ }
422
+
423
+ const reserved = new Set<string>([this.openapiConfig.path]);
424
+ if (this.openapiConfig.docs.enabled) {
425
+ reserved.add(this.openapiConfig.docs.path);
426
+ reserved.add(OPENAPI_TAILWIND_ASSET_PATH);
427
+ reserved.add(OPENAPI_LOGO_DARK_ASSET_PATH);
428
+ reserved.add(OPENAPI_LOGO_WHITE_ASSET_PATH);
429
+ for (const asset of OPENAPI_FAVICON_ASSETS) {
430
+ reserved.add(asset.path);
431
+ }
432
+ }
433
+
434
+ const methodConflicts = this.router
435
+ .getRouteDefinitions()
436
+ .filter((route) => reserved.has(route.path))
437
+ .map((route) => `${route.method} ${route.path}`);
438
+
439
+ const staticConflicts = Object.entries(this.router.getRouteTable())
440
+ .filter(([path, value]) => reserved.has(path) && value instanceof Response)
441
+ .map(([path]) => `STATIC ${path}`);
442
+
443
+ const conflicts = [...methodConflicts, ...staticConflicts];
444
+
445
+ if (conflicts.length > 0) {
446
+ throw new Error(
447
+ `OpenAPI reserved path conflict: ${conflicts.join(
448
+ ', '
449
+ )}. Change your route path(s) or reconfigure openapi.path/docs.path.`
450
+ );
451
+ }
452
+ }
453
+
454
+ private tryHandleOpenAPIRequest(request: Request): Response | null {
455
+ if (!this.openapiConfig.enabled || request.method !== 'GET') {
456
+ return null;
457
+ }
458
+
459
+ const pathname = new URL(request.url).pathname;
460
+ if (pathname === this.openapiConfig.path) {
461
+ return Response.json(this.getOpenAPIDocument());
462
+ }
463
+
464
+ if (this.openapiConfig.docs.enabled && pathname === this.openapiConfig.docs.path) {
465
+ const { html, gzip, etag } = this.getOpenAPIDocsHtmlCacheEntry();
466
+ if (request.headers.get('if-none-match') === etag) {
467
+ return new Response(null, {
468
+ status: 304,
469
+ headers: {
470
+ etag,
471
+ 'cache-control': DOCS_HTML_CACHE_CONTROL,
472
+ vary: 'accept-encoding',
473
+ },
474
+ });
475
+ }
476
+
477
+ if (this.requestAcceptsGzip(request)) {
478
+ return new Response(gzip, {
479
+ status: 200,
480
+ headers: {
481
+ 'content-type': 'text/html; charset=utf-8',
482
+ 'content-encoding': 'gzip',
483
+ etag,
484
+ 'cache-control': DOCS_HTML_CACHE_CONTROL,
485
+ vary: 'accept-encoding',
486
+ },
487
+ });
488
+ }
489
+
490
+ return new Response(html, {
491
+ status: 200,
492
+ headers: {
493
+ 'content-type': 'text/html; charset=utf-8',
494
+ etag,
495
+ 'cache-control': DOCS_HTML_CACHE_CONTROL,
496
+ vary: 'accept-encoding',
497
+ },
498
+ });
499
+ }
500
+
501
+ if (this.openapiConfig.docs.enabled && pathname === OPENAPI_TAILWIND_ASSET_PATH) {
502
+ if (!OPENAPI_TAILWIND_ASSET_FILE) {
503
+ if (!this.openapiTailwindMissingLogged) {
504
+ this.openapiTailwindMissingLogged = true;
505
+ console.warn(
506
+ '[OpenAPI] Missing docs runtime asset "tailwindcdn.js". Serving inline fallback script instead.'
507
+ );
508
+ }
509
+
510
+ return new Response(OPENAPI_TAILWIND_ASSET_INLINE_FALLBACK, {
511
+ status: 200,
512
+ headers: {
513
+ 'content-type': 'application/javascript; charset=utf-8',
514
+ 'cache-control': DOCS_ASSET_CACHE_CONTROL,
515
+ },
516
+ });
517
+ }
518
+
519
+ return new Response(OPENAPI_TAILWIND_ASSET_FILE, {
520
+ status: 200,
521
+ headers: {
522
+ 'content-type': 'application/javascript; charset=utf-8',
523
+ 'cache-control': DOCS_ASSET_CACHE_CONTROL,
524
+ },
525
+ });
526
+ }
527
+
528
+ if (this.openapiConfig.docs.enabled && pathname === OPENAPI_LOGO_DARK_ASSET_PATH) {
529
+ if (!OPENAPI_LOGO_DARK_ASSET_FILE) {
530
+ if (!this.openapiLogoDarkMissingLogged) {
531
+ this.openapiLogoDarkMissingLogged = true;
532
+ console.warn('[OpenAPI] Missing docs runtime asset "logo_dark.svg".');
34
533
  }
534
+
535
+ return new Response('OpenAPI docs runtime asset missing: logo_dark.svg', {
536
+ status: 404,
537
+ headers: {
538
+ 'content-type': 'text/plain; charset=utf-8',
539
+ 'cache-control': DOCS_ASSET_ERROR_CACHE_CONTROL,
540
+ },
541
+ });
35
542
  }
543
+
544
+ return new Response(OPENAPI_LOGO_DARK_ASSET_FILE, {
545
+ status: 200,
546
+ headers: {
547
+ 'content-type': 'image/svg+xml; charset=utf-8',
548
+ 'cache-control': DOCS_ASSET_CACHE_CONTROL,
549
+ },
550
+ });
551
+ }
552
+
553
+ if (this.openapiConfig.docs.enabled && pathname === OPENAPI_LOGO_WHITE_ASSET_PATH) {
554
+ if (!OPENAPI_LOGO_WHITE_ASSET_FILE) {
555
+ if (!this.openapiLogoWhiteMissingLogged) {
556
+ this.openapiLogoWhiteMissingLogged = true;
557
+ console.warn('[OpenAPI] Missing docs runtime asset "logo_white.svg".');
558
+ }
559
+
560
+ return new Response('OpenAPI docs runtime asset missing: logo_white.svg', {
561
+ status: 404,
562
+ headers: {
563
+ 'content-type': 'text/plain; charset=utf-8',
564
+ 'cache-control': DOCS_ASSET_ERROR_CACHE_CONTROL,
565
+ },
566
+ });
567
+ }
568
+
569
+ return new Response(OPENAPI_LOGO_WHITE_ASSET_FILE, {
570
+ status: 200,
571
+ headers: {
572
+ 'content-type': 'image/svg+xml; charset=utf-8',
573
+ 'cache-control': DOCS_ASSET_CACHE_CONTROL,
574
+ },
575
+ });
36
576
  }
577
+
578
+ if (this.openapiConfig.docs.enabled) {
579
+ const faviconAsset = OPENAPI_FAVICON_ASSETS.find((asset) => asset.path === pathname);
580
+ if (faviconAsset) {
581
+ if (!faviconAsset.file) {
582
+ return new Response(`OpenAPI docs runtime asset missing: ${faviconAsset.filename}`, {
583
+ status: 404,
584
+ headers: {
585
+ 'content-type': 'text/plain; charset=utf-8',
586
+ 'cache-control': DOCS_ASSET_ERROR_CACHE_CONTROL,
587
+ },
588
+ });
589
+ }
590
+
591
+ return new Response(faviconAsset.file, {
592
+ status: 200,
593
+ headers: {
594
+ 'content-type': faviconAsset.contentType,
595
+ 'cache-control': DOCS_ASSET_CACHE_CONTROL,
596
+ },
597
+ });
598
+ }
599
+ }
600
+
601
+ return null;
37
602
  }
38
603
 
39
604
  private normalizeCorsOptions(options: CorsOptions): any {
@@ -53,33 +618,45 @@ export class VectorServer<TTypes extends VectorTypes = DefaultVectorTypes> {
53
618
  };
54
619
  }
55
620
 
621
+ private applyCors(response: Response, request?: Request): Response {
622
+ if (this.corsHeadersEntries) {
623
+ for (const [k, v] of this.corsHeadersEntries) {
624
+ response.headers.set(k, v);
625
+ }
626
+ return response;
627
+ }
628
+
629
+ if (this.corsHandler && request) {
630
+ return this.corsHandler.corsify(response, request);
631
+ }
632
+
633
+ return response;
634
+ }
635
+
56
636
  async start(): Promise<Server> {
57
- const port = this.config.port || 3000;
637
+ const port = this.config.port ?? 3000;
58
638
  const hostname = this.config.hostname || 'localhost';
59
639
 
60
- const fetch = async (request: Request): Promise<Response> => {
640
+ this.validateReservedOpenAPIPaths();
641
+
642
+ const fallbackFetch = async (request: Request): Promise<Response> => {
61
643
  try {
62
- // Handle CORS preflight
644
+ // Handle CORS preflight for any path
63
645
  if (this.corsHandler && request.method === 'OPTIONS') {
64
646
  return this.corsHandler.preflight(request);
65
647
  }
66
648
 
67
- // Try to handle the request with our router
68
- let response = await this.router.handle(request);
69
-
70
- // Apply CORS headers if configured
71
- if (this.corsHeaders) {
72
- for (const [k, v] of Object.entries(this.corsHeaders)) {
73
- response.headers.set(k, v);
74
- }
75
- } else if (this.corsHandler) {
76
- response = this.corsHandler.corsify(response, request);
649
+ // Handle built-in docs endpoints for requests that fell through the Bun route table.
650
+ const openapiResponse = this.tryHandleOpenAPIRequest(request);
651
+ if (openapiResponse) {
652
+ return this.applyCors(openapiResponse, request);
77
653
  }
78
654
 
79
- return response;
655
+ // No route matched — return 404
656
+ return this.applyCors(STATIC_RESPONSES.NOT_FOUND.clone() as unknown as Response, request);
80
657
  } catch (error) {
81
658
  console.error('Server error:', error);
82
- return new Response('Internal Server Error', { status: 500 });
659
+ return this.applyCors(new Response('Internal Server Error', { status: 500 }), request);
83
660
  }
84
661
  };
85
662
 
@@ -88,24 +665,21 @@ export class VectorServer<TTypes extends VectorTypes = DefaultVectorTypes> {
88
665
  port,
89
666
  hostname,
90
667
  reusePort: this.config.reusePort !== false,
91
- fetch,
92
- idleTimeout: this.config.idleTimeout || 60,
93
- error: (error) => {
668
+ routes: this.router.getRouteTable(),
669
+ fetch: fallbackFetch,
670
+ idleTimeout: this.config.idleTimeout ?? 60,
671
+ error: (error, request?: Request) => {
94
672
  console.error('[ERROR] Server error:', error);
95
- return new Response('Internal Server Error', { status: 500 });
673
+ return this.applyCors(new Response('Internal Server Error', { status: 500 }), request);
96
674
  },
97
675
  });
98
676
 
99
- // Validate that the server actually started
100
677
  if (!this.server || !this.server.port) {
101
678
  throw new Error(`Failed to start server on ${hostname}:${port} - server object is invalid`);
102
679
  }
103
680
 
104
- // Server logs are handled by CLI
105
-
106
681
  return this.server;
107
682
  } catch (error: any) {
108
- // Enhance error message with context for common issues
109
683
  if (error.code === 'EADDRINUSE' || error.message?.includes('address already in use')) {
110
684
  error.message = `Port ${port} is already in use`;
111
685
  error.port = port;
@@ -125,6 +699,9 @@ export class VectorServer<TTypes extends VectorTypes = DefaultVectorTypes> {
125
699
  if (this.server) {
126
700
  this.server.stop();
127
701
  this.server = null;
702
+ this.openapiDocCache = null;
703
+ this.openapiDocsHtmlCache = null;
704
+ this.openapiWarningsLogged = false;
128
705
  console.log('Server stopped');
129
706
  }
130
707
  }
@@ -134,7 +711,7 @@ export class VectorServer<TTypes extends VectorTypes = DefaultVectorTypes> {
134
711
  }
135
712
 
136
713
  getPort(): number {
137
- return this.server?.port || this.config.port || 3000;
714
+ return this.server?.port ?? this.config.port ?? 3000;
138
715
  }
139
716
 
140
717
  getHostname(): string {