vector-framework 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -634
- package/dist/auth/protected.d.ts.map +1 -1
- package/dist/auth/protected.js.map +1 -1
- package/dist/cache/manager.d.ts +5 -2
- package/dist/cache/manager.d.ts.map +1 -1
- package/dist/cache/manager.js +21 -12
- package/dist/cache/manager.js.map +1 -1
- package/dist/cli/index.js +60 -126
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/option-resolution.d.ts +4 -0
- package/dist/cli/option-resolution.d.ts.map +1 -0
- package/dist/cli/option-resolution.js +28 -0
- package/dist/cli/option-resolution.js.map +1 -0
- package/dist/cli.js +2774 -599
- package/dist/constants/index.d.ts +3 -0
- package/dist/constants/index.d.ts.map +1 -1
- package/dist/constants/index.js +6 -0
- package/dist/constants/index.js.map +1 -1
- package/dist/core/config-loader.d.ts +2 -2
- package/dist/core/config-loader.d.ts.map +1 -1
- package/dist/core/config-loader.js +18 -18
- package/dist/core/config-loader.js.map +1 -1
- package/dist/core/router.d.ts +41 -15
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +465 -150
- package/dist/core/router.js.map +1 -1
- package/dist/core/server.d.ts +17 -3
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +274 -33
- package/dist/core/server.js.map +1 -1
- package/dist/core/vector.d.ts +9 -8
- package/dist/core/vector.d.ts.map +1 -1
- package/dist/core/vector.js +40 -32
- package/dist/core/vector.js.map +1 -1
- package/dist/dev/route-generator.d.ts.map +1 -1
- package/dist/dev/route-generator.js.map +1 -1
- package/dist/dev/route-scanner.d.ts +1 -1
- package/dist/dev/route-scanner.d.ts.map +1 -1
- package/dist/dev/route-scanner.js +37 -43
- package/dist/dev/route-scanner.js.map +1 -1
- package/dist/http.d.ts +14 -14
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +84 -84
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1314 -8
- package/dist/index.mjs +1314 -8
- package/dist/middleware/manager.d.ts +1 -1
- package/dist/middleware/manager.d.ts.map +1 -1
- package/dist/middleware/manager.js +4 -0
- package/dist/middleware/manager.js.map +1 -1
- package/dist/openapi/docs-ui.d.ts +2 -0
- package/dist/openapi/docs-ui.d.ts.map +1 -0
- package/dist/openapi/docs-ui.js +1313 -0
- package/dist/openapi/docs-ui.js.map +1 -0
- package/dist/openapi/generator.d.ts +12 -0
- package/dist/openapi/generator.d.ts.map +1 -0
- package/dist/openapi/generator.js +273 -0
- package/dist/openapi/generator.js.map +1 -0
- package/dist/types/index.d.ts +70 -11
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/standard-schema.d.ts +118 -0
- package/dist/types/standard-schema.d.ts.map +1 -0
- package/dist/types/standard-schema.js +2 -0
- package/dist/types/standard-schema.js.map +1 -0
- package/dist/utils/cors.d.ts +13 -0
- package/dist/utils/cors.d.ts.map +1 -0
- package/dist/utils/cors.js +89 -0
- package/dist/utils/cors.js.map +1 -0
- package/dist/utils/path.d.ts +7 -0
- package/dist/utils/path.d.ts.map +1 -1
- package/dist/utils/path.js +14 -3
- package/dist/utils/path.js.map +1 -1
- package/dist/utils/schema-validation.d.ts +31 -0
- package/dist/utils/schema-validation.d.ts.map +1 -0
- package/dist/utils/schema-validation.js +77 -0
- package/dist/utils/schema-validation.js.map +1 -0
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +1 -0
- package/dist/utils/validation.js.map +1 -1
- package/package.json +24 -19
- package/src/auth/protected.ts +3 -13
- package/src/cache/manager.ts +25 -30
- package/src/cli/index.ts +62 -141
- package/src/cli/option-resolution.ts +40 -0
- package/src/constants/index.ts +7 -0
- package/src/core/config-loader.ts +20 -22
- package/src/core/router.ts +535 -155
- package/src/core/server.ts +354 -45
- package/src/core/vector.ts +71 -61
- package/src/dev/route-generator.ts +1 -3
- package/src/dev/route-scanner.ts +38 -51
- package/src/http.ts +117 -187
- package/src/index.ts +3 -3
- package/src/middleware/manager.ts +8 -11
- package/src/openapi/assets/tailwindcdn.js +83 -0
- package/src/openapi/docs-ui.ts +1317 -0
- package/src/openapi/generator.ts +359 -0
- package/src/types/index.ts +104 -17
- package/src/types/standard-schema.ts +147 -0
- package/src/utils/cors.ts +101 -0
- package/src/utils/path.ts +19 -4
- package/src/utils/schema-validation.ts +123 -0
- package/src/utils/validation.ts +1 -0
package/dist/cli.js
CHANGED
|
@@ -1,244 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
-
var __create = Object.create;
|
|
4
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
5
|
-
var __defProp = Object.defineProperty;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __toESM = (mod, isNodeMode, target) => {
|
|
9
|
-
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
10
|
-
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
11
|
-
for (let key of __getOwnPropNames(mod))
|
|
12
|
-
if (!__hasOwnProp.call(to, key))
|
|
13
|
-
__defProp(to, key, {
|
|
14
|
-
get: () => mod[key],
|
|
15
|
-
enumerable: true
|
|
16
|
-
});
|
|
17
|
-
return to;
|
|
18
|
-
};
|
|
19
|
-
var __export = (target, all) => {
|
|
20
|
-
for (var name in all)
|
|
21
|
-
__defProp(target, name, {
|
|
22
|
-
get: all[name],
|
|
23
|
-
enumerable: true,
|
|
24
|
-
configurable: true,
|
|
25
|
-
set: (newValue) => all[name] = () => newValue
|
|
26
|
-
});
|
|
27
|
-
};
|
|
28
|
-
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
29
|
-
var __require = import.meta.require;
|
|
30
|
-
|
|
31
|
-
// src/dev/route-generator.ts
|
|
32
|
-
var exports_route_generator = {};
|
|
33
|
-
__export(exports_route_generator, {
|
|
34
|
-
RouteGenerator: () => RouteGenerator
|
|
35
|
-
});
|
|
36
|
-
import { promises as fs } from "fs";
|
|
37
|
-
import { dirname, relative } from "path";
|
|
38
|
-
|
|
39
|
-
class RouteGenerator {
|
|
40
|
-
outputPath;
|
|
41
|
-
constructor(outputPath = "./.vector/routes.generated.ts") {
|
|
42
|
-
this.outputPath = outputPath;
|
|
43
|
-
}
|
|
44
|
-
async generate(routes) {
|
|
45
|
-
const outputDir = dirname(this.outputPath);
|
|
46
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
47
|
-
const imports = [];
|
|
48
|
-
const groupedByFile = new Map;
|
|
49
|
-
for (const route of routes) {
|
|
50
|
-
if (!groupedByFile.has(route.path)) {
|
|
51
|
-
groupedByFile.set(route.path, []);
|
|
52
|
-
}
|
|
53
|
-
groupedByFile.get(route.path).push(route);
|
|
54
|
-
}
|
|
55
|
-
let importIndex = 0;
|
|
56
|
-
const routeEntries = [];
|
|
57
|
-
for (const [filePath, fileRoutes] of groupedByFile) {
|
|
58
|
-
const relativePath = relative(dirname(this.outputPath), filePath).replace(/\\/g, "/").replace(/\.(ts|js)$/, "");
|
|
59
|
-
const importName = `route_${importIndex++}`;
|
|
60
|
-
const namedImports = fileRoutes.filter((r) => r.name !== "default").map((r) => r.name);
|
|
61
|
-
if (fileRoutes.some((r) => r.name === "default")) {
|
|
62
|
-
if (namedImports.length > 0) {
|
|
63
|
-
imports.push(`import ${importName}, { ${namedImports.join(", ")} } from '${relativePath}';`);
|
|
64
|
-
} else {
|
|
65
|
-
imports.push(`import ${importName} from '${relativePath}';`);
|
|
66
|
-
}
|
|
67
|
-
} else if (namedImports.length > 0) {
|
|
68
|
-
imports.push(`import { ${namedImports.join(", ")} } from '${relativePath}';`);
|
|
69
|
-
}
|
|
70
|
-
for (const route of fileRoutes) {
|
|
71
|
-
const routeVar = route.name === "default" ? importName : route.name;
|
|
72
|
-
routeEntries.push(` ${routeVar},`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const content = `// This file is auto-generated. Do not edit manually.
|
|
76
|
-
// Generated at: ${new Date().toISOString()}
|
|
77
|
-
|
|
78
|
-
${imports.join(`
|
|
79
|
-
`)}
|
|
80
|
-
|
|
81
|
-
export const routes = [
|
|
82
|
-
${routeEntries.join(`
|
|
83
|
-
`)}
|
|
84
|
-
];
|
|
85
|
-
|
|
86
|
-
export default routes;
|
|
87
|
-
`;
|
|
88
|
-
await fs.writeFile(this.outputPath, content, "utf-8");
|
|
89
|
-
}
|
|
90
|
-
async generateDynamic(routes) {
|
|
91
|
-
const routeEntries = [];
|
|
92
|
-
for (const route of routes) {
|
|
93
|
-
const routeObj = JSON.stringify({
|
|
94
|
-
method: route.method,
|
|
95
|
-
path: route.options.path,
|
|
96
|
-
options: route.options
|
|
97
|
-
});
|
|
98
|
-
routeEntries.push(` await import('${route.path}').then(m => ({
|
|
99
|
-
...${routeObj},
|
|
100
|
-
handler: m.${route.name === "default" ? "default" : route.name}
|
|
101
|
-
}))`);
|
|
102
|
-
}
|
|
103
|
-
return `export const loadRoutes = async () => {
|
|
104
|
-
return Promise.all([
|
|
105
|
-
${routeEntries.join(`,
|
|
106
|
-
`)}
|
|
107
|
-
]);
|
|
108
|
-
};`;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
var init_route_generator = () => {};
|
|
112
|
-
|
|
113
|
-
// src/dev/route-scanner.ts
|
|
114
|
-
var exports_route_scanner = {};
|
|
115
|
-
__export(exports_route_scanner, {
|
|
116
|
-
RouteScanner: () => RouteScanner
|
|
117
|
-
});
|
|
118
|
-
import { existsSync, promises as fs2 } from "fs";
|
|
119
|
-
import { join, relative as relative2, resolve, sep } from "path";
|
|
120
|
-
var RouteScanner;
|
|
121
|
-
var init_route_scanner = __esm(() => {
|
|
122
|
-
RouteScanner = class RouteScanner {
|
|
123
|
-
routesDir;
|
|
124
|
-
excludePatterns;
|
|
125
|
-
static DEFAULT_EXCLUDE_PATTERNS = [
|
|
126
|
-
"*.test.ts",
|
|
127
|
-
"*.test.js",
|
|
128
|
-
"*.test.tsx",
|
|
129
|
-
"*.test.jsx",
|
|
130
|
-
"*.spec.ts",
|
|
131
|
-
"*.spec.js",
|
|
132
|
-
"*.spec.tsx",
|
|
133
|
-
"*.spec.jsx",
|
|
134
|
-
"*.tests.ts",
|
|
135
|
-
"*.tests.js",
|
|
136
|
-
"**/__tests__/**",
|
|
137
|
-
"*.interface.ts",
|
|
138
|
-
"*.type.ts",
|
|
139
|
-
"*.d.ts"
|
|
140
|
-
];
|
|
141
|
-
constructor(routesDir = "./routes", excludePatterns) {
|
|
142
|
-
this.routesDir = resolve(process.cwd(), routesDir);
|
|
143
|
-
this.excludePatterns = excludePatterns || RouteScanner.DEFAULT_EXCLUDE_PATTERNS;
|
|
144
|
-
}
|
|
145
|
-
async scan() {
|
|
146
|
-
const routes = [];
|
|
147
|
-
if (!existsSync(this.routesDir)) {
|
|
148
|
-
return [];
|
|
149
|
-
}
|
|
150
|
-
try {
|
|
151
|
-
await this.scanDirectory(this.routesDir, routes);
|
|
152
|
-
} catch (error) {
|
|
153
|
-
if (error.code === "ENOENT") {
|
|
154
|
-
console.warn(` \u2717 Routes directory not accessible: ${this.routesDir}`);
|
|
155
|
-
return [];
|
|
156
|
-
}
|
|
157
|
-
throw error;
|
|
158
|
-
}
|
|
159
|
-
return routes;
|
|
160
|
-
}
|
|
161
|
-
isExcluded(filePath) {
|
|
162
|
-
const relativePath = relative2(this.routesDir, filePath);
|
|
163
|
-
for (const pattern of this.excludePatterns) {
|
|
164
|
-
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, "[^/]*").replace(/\*\*/g, ".*").replace(/\?/g, ".");
|
|
165
|
-
const regex = new RegExp(`^${regexPattern}$`);
|
|
166
|
-
const filename = relativePath.split(sep).pop() || "";
|
|
167
|
-
if (regex.test(relativePath) || regex.test(filename)) {
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
async scanDirectory(dir, routes, basePath = "") {
|
|
174
|
-
const entries = await fs2.readdir(dir);
|
|
175
|
-
for (const entry of entries) {
|
|
176
|
-
const fullPath = join(dir, entry);
|
|
177
|
-
const stats = await fs2.stat(fullPath);
|
|
178
|
-
if (stats.isDirectory()) {
|
|
179
|
-
const newBasePath = basePath ? `${basePath}/${entry}` : entry;
|
|
180
|
-
await this.scanDirectory(fullPath, routes, newBasePath);
|
|
181
|
-
} else if (entry.endsWith(".ts") || entry.endsWith(".js")) {
|
|
182
|
-
if (this.isExcluded(fullPath)) {
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
const routePath = relative2(this.routesDir, fullPath).replace(/\.(ts|js)$/, "").split(sep).join("/");
|
|
186
|
-
try {
|
|
187
|
-
const importPath = process.platform === "win32" ? `file:///${fullPath.replace(/\\/g, "/")}` : fullPath;
|
|
188
|
-
const module = await import(importPath);
|
|
189
|
-
if (module.default && typeof module.default === "function") {
|
|
190
|
-
routes.push({
|
|
191
|
-
name: "default",
|
|
192
|
-
path: fullPath,
|
|
193
|
-
method: "GET",
|
|
194
|
-
options: {
|
|
195
|
-
method: "GET",
|
|
196
|
-
path: `/${routePath}`,
|
|
197
|
-
expose: true
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
for (const [name, value] of Object.entries(module)) {
|
|
202
|
-
if (name === "default")
|
|
203
|
-
continue;
|
|
204
|
-
if (value && typeof value === "object" && "entry" in value && "options" in value && "handler" in value) {
|
|
205
|
-
const routeDef = value;
|
|
206
|
-
routes.push({
|
|
207
|
-
name,
|
|
208
|
-
path: fullPath,
|
|
209
|
-
method: routeDef.options.method,
|
|
210
|
-
options: routeDef.options
|
|
211
|
-
});
|
|
212
|
-
} else if (Array.isArray(value) && value.length >= 4) {
|
|
213
|
-
const [method, , , path] = value;
|
|
214
|
-
routes.push({
|
|
215
|
-
name,
|
|
216
|
-
path: fullPath,
|
|
217
|
-
method,
|
|
218
|
-
options: {
|
|
219
|
-
method,
|
|
220
|
-
path,
|
|
221
|
-
expose: true
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
} catch (error) {
|
|
227
|
-
console.error(`Failed to load route from ${fullPath}:`, error);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
enableWatch(callback) {
|
|
233
|
-
if (typeof Bun !== "undefined" && Bun.env.NODE_ENV === "development") {
|
|
234
|
-
console.log(`Watching for route changes in ${this.routesDir}`);
|
|
235
|
-
setInterval(async () => {
|
|
236
|
-
await callback();
|
|
237
|
-
}, 1000);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
|
-
});
|
|
242
3
|
|
|
243
4
|
// src/cli/index.ts
|
|
244
5
|
import { watch } from "fs";
|
|
@@ -345,12 +106,19 @@ var CONTENT_TYPES = {
|
|
|
345
106
|
FORM_URLENCODED: "application/x-www-form-urlencoded",
|
|
346
107
|
MULTIPART: "multipart/form-data"
|
|
347
108
|
};
|
|
109
|
+
var STATIC_RESPONSES = {
|
|
110
|
+
NOT_FOUND: new Response(JSON.stringify({ error: true, message: "Not Found", statusCode: 404 }), {
|
|
111
|
+
status: 404,
|
|
112
|
+
headers: { "content-type": "application/json" }
|
|
113
|
+
})
|
|
114
|
+
};
|
|
348
115
|
|
|
349
116
|
// src/cache/manager.ts
|
|
350
117
|
class CacheManager {
|
|
351
118
|
cacheHandler = null;
|
|
352
119
|
memoryCache = new Map;
|
|
353
120
|
cleanupInterval = null;
|
|
121
|
+
inflight = new Map;
|
|
354
122
|
setCacheHandler(handler) {
|
|
355
123
|
this.cacheHandler = handler;
|
|
356
124
|
}
|
|
@@ -369,9 +137,20 @@ class CacheManager {
|
|
|
369
137
|
if (this.isCacheValid(cached, now)) {
|
|
370
138
|
return cached.value;
|
|
371
139
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
140
|
+
if (this.inflight.has(key)) {
|
|
141
|
+
return await this.inflight.get(key);
|
|
142
|
+
}
|
|
143
|
+
const promise = (async () => {
|
|
144
|
+
const value = await factory();
|
|
145
|
+
this.setInMemoryCache(key, value, ttl);
|
|
146
|
+
return value;
|
|
147
|
+
})();
|
|
148
|
+
this.inflight.set(key, promise);
|
|
149
|
+
try {
|
|
150
|
+
return await promise;
|
|
151
|
+
} finally {
|
|
152
|
+
this.inflight.delete(key);
|
|
153
|
+
}
|
|
375
154
|
}
|
|
376
155
|
isCacheValid(entry, now) {
|
|
377
156
|
return entry !== undefined && entry.expires > now;
|
|
@@ -431,20 +210,212 @@ class CacheManager {
|
|
|
431
210
|
return true;
|
|
432
211
|
}
|
|
433
212
|
generateKey(request, options) {
|
|
434
|
-
const url = new URL(request.url);
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
url.pathname,
|
|
438
|
-
url.search,
|
|
439
|
-
options?.authUser?.id || "anonymous"
|
|
440
|
-
];
|
|
441
|
-
return parts.join(":");
|
|
213
|
+
const url = request._parsedUrl ?? new URL(request.url);
|
|
214
|
+
const userId = options?.authUser?.id != null ? String(options.authUser.id) : "anonymous";
|
|
215
|
+
return `${request.method}:${url.pathname}:${url.search}:${userId}`;
|
|
442
216
|
}
|
|
443
217
|
}
|
|
444
218
|
|
|
445
|
-
// src/
|
|
446
|
-
|
|
447
|
-
|
|
219
|
+
// src/dev/route-generator.ts
|
|
220
|
+
import { promises as fs } from "fs";
|
|
221
|
+
import { dirname, relative } from "path";
|
|
222
|
+
|
|
223
|
+
class RouteGenerator {
|
|
224
|
+
outputPath;
|
|
225
|
+
constructor(outputPath = "./.vector/routes.generated.ts") {
|
|
226
|
+
this.outputPath = outputPath;
|
|
227
|
+
}
|
|
228
|
+
async generate(routes) {
|
|
229
|
+
const outputDir = dirname(this.outputPath);
|
|
230
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
231
|
+
const imports = [];
|
|
232
|
+
const groupedByFile = new Map;
|
|
233
|
+
for (const route of routes) {
|
|
234
|
+
if (!groupedByFile.has(route.path)) {
|
|
235
|
+
groupedByFile.set(route.path, []);
|
|
236
|
+
}
|
|
237
|
+
groupedByFile.get(route.path).push(route);
|
|
238
|
+
}
|
|
239
|
+
let importIndex = 0;
|
|
240
|
+
const routeEntries = [];
|
|
241
|
+
for (const [filePath, fileRoutes] of groupedByFile) {
|
|
242
|
+
const relativePath = relative(dirname(this.outputPath), filePath).replace(/\\/g, "/").replace(/\.(ts|js)$/, "");
|
|
243
|
+
const importName = `route_${importIndex++}`;
|
|
244
|
+
const namedImports = fileRoutes.filter((r) => r.name !== "default").map((r) => r.name);
|
|
245
|
+
if (fileRoutes.some((r) => r.name === "default")) {
|
|
246
|
+
if (namedImports.length > 0) {
|
|
247
|
+
imports.push(`import ${importName}, { ${namedImports.join(", ")} } from '${relativePath}';`);
|
|
248
|
+
} else {
|
|
249
|
+
imports.push(`import ${importName} from '${relativePath}';`);
|
|
250
|
+
}
|
|
251
|
+
} else if (namedImports.length > 0) {
|
|
252
|
+
imports.push(`import { ${namedImports.join(", ")} } from '${relativePath}';`);
|
|
253
|
+
}
|
|
254
|
+
for (const route of fileRoutes) {
|
|
255
|
+
const routeVar = route.name === "default" ? importName : route.name;
|
|
256
|
+
routeEntries.push(` ${routeVar},`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const content = `// This file is auto-generated. Do not edit manually.
|
|
260
|
+
// Generated at: ${new Date().toISOString()}
|
|
261
|
+
|
|
262
|
+
${imports.join(`
|
|
263
|
+
`)}
|
|
264
|
+
|
|
265
|
+
export const routes = [
|
|
266
|
+
${routeEntries.join(`
|
|
267
|
+
`)}
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
export default routes;
|
|
271
|
+
`;
|
|
272
|
+
await fs.writeFile(this.outputPath, content, "utf-8");
|
|
273
|
+
}
|
|
274
|
+
async generateDynamic(routes) {
|
|
275
|
+
const routeEntries = [];
|
|
276
|
+
for (const route of routes) {
|
|
277
|
+
const routeObj = JSON.stringify({
|
|
278
|
+
method: route.method,
|
|
279
|
+
path: route.options.path,
|
|
280
|
+
options: route.options
|
|
281
|
+
});
|
|
282
|
+
routeEntries.push(` await import('${route.path}').then(m => ({
|
|
283
|
+
...${routeObj},
|
|
284
|
+
handler: m.${route.name === "default" ? "default" : route.name}
|
|
285
|
+
}))`);
|
|
286
|
+
}
|
|
287
|
+
return `export const loadRoutes = async () => {
|
|
288
|
+
return Promise.all([
|
|
289
|
+
${routeEntries.join(`,
|
|
290
|
+
`)}
|
|
291
|
+
]);
|
|
292
|
+
};`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/dev/route-scanner.ts
|
|
297
|
+
import { existsSync, promises as fs2 } from "fs";
|
|
298
|
+
import { join, relative as relative2, resolve, sep } from "path";
|
|
299
|
+
|
|
300
|
+
class RouteScanner {
|
|
301
|
+
routesDir;
|
|
302
|
+
excludePatterns;
|
|
303
|
+
static DEFAULT_EXCLUDE_PATTERNS = [
|
|
304
|
+
"*.test.ts",
|
|
305
|
+
"*.test.js",
|
|
306
|
+
"*.test.tsx",
|
|
307
|
+
"*.test.jsx",
|
|
308
|
+
"*.spec.ts",
|
|
309
|
+
"*.spec.js",
|
|
310
|
+
"*.spec.tsx",
|
|
311
|
+
"*.spec.jsx",
|
|
312
|
+
"*.tests.ts",
|
|
313
|
+
"*.tests.js",
|
|
314
|
+
"**/__tests__/**",
|
|
315
|
+
"*.interface.ts",
|
|
316
|
+
"*.type.ts",
|
|
317
|
+
"*.d.ts"
|
|
318
|
+
];
|
|
319
|
+
constructor(routesDir = "./routes", excludePatterns) {
|
|
320
|
+
this.routesDir = resolve(process.cwd(), routesDir);
|
|
321
|
+
this.excludePatterns = excludePatterns || RouteScanner.DEFAULT_EXCLUDE_PATTERNS;
|
|
322
|
+
}
|
|
323
|
+
async scan() {
|
|
324
|
+
const routes = [];
|
|
325
|
+
if (!existsSync(this.routesDir)) {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
await this.scanDirectory(this.routesDir, routes);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error.code === "ENOENT") {
|
|
332
|
+
console.warn(` \u2717 Routes directory not accessible: ${this.routesDir}`);
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
return routes;
|
|
338
|
+
}
|
|
339
|
+
isExcluded(filePath) {
|
|
340
|
+
const relativePath = relative2(this.routesDir, filePath);
|
|
341
|
+
for (const pattern of this.excludePatterns) {
|
|
342
|
+
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "__GLOBSTAR__").replace(/\*/g, "[^/]*").replace(/__GLOBSTAR__/g, ".*").replace(/\?/g, ".");
|
|
343
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
344
|
+
const filename = relativePath.split(sep).pop() || "";
|
|
345
|
+
if (regex.test(relativePath) || regex.test(filename)) {
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
async scanDirectory(dir, routes, basePath = "") {
|
|
352
|
+
const entries = await fs2.readdir(dir);
|
|
353
|
+
for (const entry of entries) {
|
|
354
|
+
const fullPath = join(dir, entry);
|
|
355
|
+
const stats = await fs2.stat(fullPath);
|
|
356
|
+
if (stats.isDirectory()) {
|
|
357
|
+
const newBasePath = basePath ? `${basePath}/${entry}` : entry;
|
|
358
|
+
await this.scanDirectory(fullPath, routes, newBasePath);
|
|
359
|
+
} else if (entry.endsWith(".ts") || entry.endsWith(".js")) {
|
|
360
|
+
if (this.isExcluded(fullPath)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const routePath = relative2(this.routesDir, fullPath).replace(/\.(ts|js)$/, "").split(sep).join("/");
|
|
364
|
+
try {
|
|
365
|
+
const importPath = process.platform === "win32" ? `file:///${fullPath.replace(/\\/g, "/")}` : fullPath;
|
|
366
|
+
const module = await import(importPath);
|
|
367
|
+
if (module.default && typeof module.default === "function") {
|
|
368
|
+
routes.push({
|
|
369
|
+
name: "default",
|
|
370
|
+
path: fullPath,
|
|
371
|
+
method: "GET",
|
|
372
|
+
options: {
|
|
373
|
+
method: "GET",
|
|
374
|
+
path: `/${routePath}`,
|
|
375
|
+
expose: true
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
for (const [name, value] of Object.entries(module)) {
|
|
380
|
+
if (name === "default")
|
|
381
|
+
continue;
|
|
382
|
+
if (value && typeof value === "object" && "entry" in value && "options" in value && "handler" in value) {
|
|
383
|
+
const routeDef = value;
|
|
384
|
+
routes.push({
|
|
385
|
+
name,
|
|
386
|
+
path: fullPath,
|
|
387
|
+
method: routeDef.options.method,
|
|
388
|
+
options: routeDef.options
|
|
389
|
+
});
|
|
390
|
+
} else if (Array.isArray(value) && value.length >= 4) {
|
|
391
|
+
const [method, , , path] = value;
|
|
392
|
+
routes.push({
|
|
393
|
+
name,
|
|
394
|
+
path: fullPath,
|
|
395
|
+
method,
|
|
396
|
+
options: {
|
|
397
|
+
method,
|
|
398
|
+
path,
|
|
399
|
+
expose: true
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
console.error(`Failed to load route from ${fullPath}:`, error);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
enableWatch(callback) {
|
|
411
|
+
if (typeof Bun !== "undefined" && Bun.env.NODE_ENV === "development") {
|
|
412
|
+
console.log(`Watching for route changes in ${this.routesDir}`);
|
|
413
|
+
setInterval(async () => {
|
|
414
|
+
await callback();
|
|
415
|
+
}, 1000);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
448
419
|
|
|
449
420
|
// src/middleware/manager.ts
|
|
450
421
|
class MiddlewareManager {
|
|
@@ -457,6 +428,8 @@ class MiddlewareManager {
|
|
|
457
428
|
this.finallyHandlers.push(...handlers);
|
|
458
429
|
}
|
|
459
430
|
async executeBefore(request) {
|
|
431
|
+
if (this.beforeHandlers.length === 0)
|
|
432
|
+
return request;
|
|
460
433
|
let currentRequest = request;
|
|
461
434
|
for (const handler of this.beforeHandlers) {
|
|
462
435
|
const result = await handler(currentRequest);
|
|
@@ -468,6 +441,8 @@ class MiddlewareManager {
|
|
|
468
441
|
return currentRequest;
|
|
469
442
|
}
|
|
470
443
|
async executeFinally(response, request) {
|
|
444
|
+
if (this.finallyHandlers.length === 0)
|
|
445
|
+
return response;
|
|
471
446
|
let currentResponse = response;
|
|
472
447
|
for (const handler of this.finallyHandlers) {
|
|
473
448
|
try {
|
|
@@ -494,51 +469,21 @@ class MiddlewareManager {
|
|
|
494
469
|
function toFileUrl(path) {
|
|
495
470
|
return process.platform === "win32" ? `file:///${path.replace(/\\/g, "/")}` : path;
|
|
496
471
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (r2 === undefined || r2 instanceof Response)
|
|
501
|
-
return r2;
|
|
502
|
-
const a = new Response(t?.(r2) ?? r2, o.url ? undefined : o);
|
|
503
|
-
return a.headers.set("content-type", e), a;
|
|
504
|
-
};
|
|
505
|
-
var o = r("application/json; charset=utf-8", JSON.stringify);
|
|
506
|
-
var p = r("text/plain; charset=utf-8", String);
|
|
507
|
-
var f = r("text/html");
|
|
508
|
-
var u = r("image/jpeg");
|
|
509
|
-
var h = r("image/png");
|
|
510
|
-
var g = r("image/webp");
|
|
511
|
-
var w = (e) => {
|
|
512
|
-
e.cookies = (e.headers.get("Cookie") || "").split(/;\s*/).map((e2) => e2.split(/=(.+)/)).reduce((e2, [t, r2]) => r2 ? (e2[t] = r2, e2) : e2, {});
|
|
513
|
-
};
|
|
514
|
-
var y = (e = {}) => {
|
|
515
|
-
const { origin: t = "*", credentials: r2 = false, allowMethods: o2 = "*", allowHeaders: a, exposeHeaders: s, maxAge: c } = e, n = (e2) => {
|
|
516
|
-
const o3 = e2?.headers.get("origin");
|
|
517
|
-
return t === true ? o3 : t instanceof RegExp ? t.test(o3) ? o3 : undefined : Array.isArray(t) ? t.includes(o3) ? o3 : undefined : t instanceof Function ? t(o3) : t == "*" && r2 ? o3 : t;
|
|
518
|
-
}, l = (e2, t2) => {
|
|
519
|
-
for (const [r3, o3] of Object.entries(t2))
|
|
520
|
-
o3 && e2.headers.append(r3, o3);
|
|
521
|
-
return e2;
|
|
522
|
-
};
|
|
523
|
-
return { corsify: (e2, t2) => e2?.headers?.get("access-control-allow-origin") || e2.status == 101 ? e2 : l(e2.clone(), { "access-control-allow-origin": n(t2), "access-control-allow-credentials": r2 }), preflight: (e2) => {
|
|
524
|
-
if (e2.method == "OPTIONS") {
|
|
525
|
-
const t2 = new Response(null, { status: 204 });
|
|
526
|
-
return l(t2, { "access-control-allow-origin": n(e2), "access-control-allow-methods": o2?.join?.(",") ?? o2, "access-control-expose-headers": s?.join?.(",") ?? s, "access-control-allow-headers": a?.join?.(",") ?? a ?? e2.headers.get("access-control-request-headers"), "access-control-max-age": c, "access-control-allow-credentials": r2 });
|
|
527
|
-
}
|
|
528
|
-
} };
|
|
529
|
-
};
|
|
472
|
+
function buildRouteRegex(path) {
|
|
473
|
+
return RegExp(`^${path.replace(/\/+(\/|$)/g, "$1").replace(/(\/?\.?):(\w+)\+/g, "($1(?<$2>[\\s\\S]+))").replace(/(\/?\.?):(\w+)/g, "($1(?<$2>[^$1/]+?))").replace(/\./g, "\\.").replace(/(\/?)\*/g, "($1.*)?")}/*$`);
|
|
474
|
+
}
|
|
530
475
|
|
|
531
476
|
// src/http.ts
|
|
532
|
-
var { preflight, corsify } = y({
|
|
533
|
-
origin: "*",
|
|
534
|
-
credentials: true,
|
|
535
|
-
allowHeaders: "Content-Type, Authorization",
|
|
536
|
-
allowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
537
|
-
exposeHeaders: "Authorization",
|
|
538
|
-
maxAge: 86400
|
|
539
|
-
});
|
|
540
477
|
function stringifyData(data) {
|
|
541
|
-
|
|
478
|
+
const val = data ?? null;
|
|
479
|
+
try {
|
|
480
|
+
return JSON.stringify(val);
|
|
481
|
+
} catch (e) {
|
|
482
|
+
if (e instanceof TypeError && /\bbigint\b/i.test(e.message)) {
|
|
483
|
+
return JSON.stringify(val, (_key, value) => typeof value === "bigint" ? value.toString() : value);
|
|
484
|
+
}
|
|
485
|
+
throw e;
|
|
486
|
+
}
|
|
542
487
|
}
|
|
543
488
|
function createErrorResponse(code, message, contentType) {
|
|
544
489
|
const errorBody = {
|
|
@@ -602,248 +547,2481 @@ function createResponse(statusCode, data, contentType = CONTENT_TYPES.JSON) {
|
|
|
602
547
|
});
|
|
603
548
|
}
|
|
604
549
|
|
|
605
|
-
// src/
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
550
|
+
// src/utils/schema-validation.ts
|
|
551
|
+
function isStandardRouteSchema(schema) {
|
|
552
|
+
const standard = schema?.["~standard"];
|
|
553
|
+
return !!standard && typeof standard === "object" && typeof standard.validate === "function" && standard.version === 1;
|
|
554
|
+
}
|
|
555
|
+
async function runStandardValidation(schema, value) {
|
|
556
|
+
const result = await schema["~standard"].validate(value);
|
|
557
|
+
const issues = result?.issues;
|
|
558
|
+
if (Array.isArray(issues) && issues.length > 0) {
|
|
559
|
+
return { success: false, issues };
|
|
560
|
+
}
|
|
561
|
+
return { success: true, value: result?.value };
|
|
562
|
+
}
|
|
563
|
+
function extractThrownIssues(error) {
|
|
564
|
+
if (Array.isArray(error)) {
|
|
565
|
+
return error;
|
|
566
|
+
}
|
|
567
|
+
if (error && typeof error === "object" && Array.isArray(error.issues)) {
|
|
568
|
+
return error.issues;
|
|
569
|
+
}
|
|
570
|
+
if (error && typeof error === "object" && error.cause && Array.isArray(error.cause.issues)) {
|
|
571
|
+
return error.cause.issues;
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
function normalizePath(path) {
|
|
576
|
+
if (!Array.isArray(path))
|
|
577
|
+
return [];
|
|
578
|
+
const normalized = [];
|
|
579
|
+
for (let i = 0;i < path.length; i++) {
|
|
580
|
+
const segment = path[i];
|
|
581
|
+
let value = segment;
|
|
582
|
+
if (segment && typeof segment === "object" && "key" in segment) {
|
|
583
|
+
value = segment.key;
|
|
584
|
+
}
|
|
585
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
586
|
+
normalized.push(value);
|
|
587
|
+
} else if (typeof value === "symbol") {
|
|
588
|
+
normalized.push(String(value));
|
|
589
|
+
} else if (value !== undefined && value !== null) {
|
|
590
|
+
normalized.push(String(value));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return normalized;
|
|
594
|
+
}
|
|
595
|
+
function normalizeValidationIssues(issues, includeRawIssues) {
|
|
596
|
+
const normalized = [];
|
|
597
|
+
for (let i = 0;i < issues.length; i++) {
|
|
598
|
+
const issue = issues[i];
|
|
599
|
+
const maybeIssue = issue;
|
|
600
|
+
const normalizedIssue = {
|
|
601
|
+
message: typeof maybeIssue?.message === "string" && maybeIssue.message.length > 0 ? maybeIssue.message : "Invalid value",
|
|
602
|
+
path: normalizePath(maybeIssue?.path)
|
|
603
|
+
};
|
|
604
|
+
if (typeof maybeIssue?.code === "string") {
|
|
605
|
+
normalizedIssue.code = maybeIssue.code;
|
|
606
|
+
}
|
|
607
|
+
if (includeRawIssues) {
|
|
608
|
+
normalizedIssue.raw = issue;
|
|
609
|
+
}
|
|
610
|
+
normalized.push(normalizedIssue);
|
|
611
|
+
}
|
|
612
|
+
return normalized;
|
|
613
|
+
}
|
|
614
|
+
function createValidationErrorPayload(target, issues) {
|
|
615
|
+
return {
|
|
616
|
+
error: true,
|
|
617
|
+
message: "Validation failed",
|
|
618
|
+
statusCode: 422,
|
|
619
|
+
source: "validation",
|
|
620
|
+
target,
|
|
621
|
+
issues,
|
|
622
|
+
timestamp: new Date().toISOString()
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/core/router.ts
|
|
627
|
+
class VectorRouter {
|
|
628
|
+
middlewareManager;
|
|
629
|
+
authManager;
|
|
630
|
+
cacheManager;
|
|
631
|
+
routeBooleanDefaults = {};
|
|
632
|
+
developmentMode = undefined;
|
|
633
|
+
routeDefinitions = [];
|
|
634
|
+
routeTable = Object.create(null);
|
|
635
|
+
routeMatchers = [];
|
|
636
|
+
corsHeadersEntries = null;
|
|
637
|
+
corsHandler = null;
|
|
638
|
+
constructor(middlewareManager, authManager, cacheManager) {
|
|
639
|
+
this.middlewareManager = middlewareManager;
|
|
640
|
+
this.authManager = authManager;
|
|
641
|
+
this.cacheManager = cacheManager;
|
|
642
|
+
}
|
|
643
|
+
setCorsHeaders(entries) {
|
|
644
|
+
this.corsHeadersEntries = entries;
|
|
645
|
+
}
|
|
646
|
+
setCorsHandler(handler) {
|
|
647
|
+
this.corsHandler = handler;
|
|
648
|
+
}
|
|
649
|
+
setRouteBooleanDefaults(defaults) {
|
|
650
|
+
this.routeBooleanDefaults = { ...defaults };
|
|
651
|
+
}
|
|
652
|
+
setDevelopmentMode(mode) {
|
|
653
|
+
this.developmentMode = mode;
|
|
654
|
+
}
|
|
655
|
+
applyRouteBooleanDefaults(options) {
|
|
656
|
+
const resolved = { ...options };
|
|
657
|
+
const defaults = this.routeBooleanDefaults;
|
|
658
|
+
const keys = ["auth", "expose", "rawRequest", "validate", "rawResponse"];
|
|
659
|
+
for (const key of keys) {
|
|
660
|
+
if (resolved[key] === undefined && defaults[key] !== undefined) {
|
|
661
|
+
resolved[key] = defaults[key];
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return resolved;
|
|
665
|
+
}
|
|
666
|
+
route(options, handler) {
|
|
667
|
+
const resolvedOptions = this.applyRouteBooleanDefaults(options);
|
|
668
|
+
const method = resolvedOptions.method.toUpperCase();
|
|
669
|
+
const path = resolvedOptions.path;
|
|
670
|
+
const wrappedHandler = this.wrapHandler(resolvedOptions, handler);
|
|
671
|
+
const methodMap = this.getOrCreateMethodMap(path);
|
|
672
|
+
methodMap[method] = wrappedHandler;
|
|
673
|
+
this.routeDefinitions.push({
|
|
674
|
+
method,
|
|
675
|
+
path,
|
|
676
|
+
options: resolvedOptions
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
addRoute(entry) {
|
|
680
|
+
const [method, , handlers, path] = entry;
|
|
681
|
+
if (!path)
|
|
682
|
+
return;
|
|
683
|
+
const methodMap = this.getOrCreateMethodMap(path);
|
|
684
|
+
methodMap[method.toUpperCase()] = handlers[0];
|
|
685
|
+
const normalizedMethod = method.toUpperCase();
|
|
686
|
+
this.routeDefinitions.push({
|
|
687
|
+
method: normalizedMethod,
|
|
688
|
+
path,
|
|
689
|
+
options: {
|
|
690
|
+
method: normalizedMethod,
|
|
691
|
+
path,
|
|
692
|
+
expose: true
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
bulkAddRoutes(entries) {
|
|
697
|
+
for (const entry of entries) {
|
|
698
|
+
this.addRoute(entry);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
addStaticRoute(path, response) {
|
|
702
|
+
const existing = this.routeTable[path];
|
|
703
|
+
if (existing && !(existing instanceof Response)) {
|
|
704
|
+
throw new Error(`Cannot register static route for path "${path}" because method routes already exist.`);
|
|
705
|
+
}
|
|
706
|
+
this.routeTable[path] = response;
|
|
707
|
+
this.removeRouteMatcher(path);
|
|
708
|
+
}
|
|
709
|
+
getRouteTable() {
|
|
710
|
+
return this.routeTable;
|
|
711
|
+
}
|
|
712
|
+
getRoutes() {
|
|
713
|
+
const routes = [];
|
|
714
|
+
for (const matcher of this.routeMatchers) {
|
|
715
|
+
const value = this.routeTable[matcher.path];
|
|
716
|
+
if (!value || value instanceof Response)
|
|
717
|
+
continue;
|
|
718
|
+
for (const [method, handler] of Object.entries(value)) {
|
|
719
|
+
routes.push([method, matcher.regex, [handler], matcher.path]);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return routes;
|
|
723
|
+
}
|
|
724
|
+
getRouteDefinitions() {
|
|
725
|
+
return [...this.routeDefinitions];
|
|
726
|
+
}
|
|
727
|
+
clearRoutes() {
|
|
728
|
+
this.routeTable = Object.create(null);
|
|
729
|
+
this.routeMatchers = [];
|
|
730
|
+
this.routeDefinitions = [];
|
|
731
|
+
}
|
|
732
|
+
sortRoutes() {}
|
|
733
|
+
async handle(request) {
|
|
734
|
+
let url;
|
|
735
|
+
try {
|
|
736
|
+
url = new URL(request.url);
|
|
737
|
+
} catch {
|
|
738
|
+
return APIError.badRequest("Malformed request URL");
|
|
739
|
+
}
|
|
740
|
+
request._parsedUrl = url;
|
|
741
|
+
const pathname = url.pathname;
|
|
742
|
+
for (const matcher of this.routeMatchers) {
|
|
743
|
+
const path = matcher.path;
|
|
744
|
+
const value = this.routeTable[path];
|
|
745
|
+
if (!value)
|
|
746
|
+
continue;
|
|
747
|
+
if (value instanceof Response)
|
|
748
|
+
continue;
|
|
749
|
+
const methodMap = value;
|
|
750
|
+
if (request.method === "OPTIONS" || request.method in methodMap) {
|
|
751
|
+
const match = pathname.match(matcher.regex);
|
|
752
|
+
if (match) {
|
|
753
|
+
try {
|
|
754
|
+
request.params = match.groups ?? {};
|
|
755
|
+
} catch {}
|
|
756
|
+
const handler = methodMap[request.method] ?? methodMap["GET"];
|
|
757
|
+
if (handler) {
|
|
758
|
+
const response = await handler(request);
|
|
759
|
+
if (response)
|
|
760
|
+
return response;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return STATIC_RESPONSES.NOT_FOUND.clone();
|
|
766
|
+
}
|
|
767
|
+
prepareRequest(request, options) {
|
|
768
|
+
if (!request.context) {
|
|
769
|
+
request.context = {};
|
|
770
|
+
}
|
|
771
|
+
const hasEmptyParamsObject = !!request.params && typeof request.params === "object" && !Array.isArray(request.params) && Object.keys(request.params).length === 0;
|
|
772
|
+
if (options?.params !== undefined && (request.params === undefined || hasEmptyParamsObject)) {
|
|
773
|
+
try {
|
|
774
|
+
request.params = options.params;
|
|
775
|
+
} catch {}
|
|
776
|
+
}
|
|
777
|
+
if (options?.route !== undefined) {
|
|
778
|
+
request.route = options.route;
|
|
779
|
+
}
|
|
780
|
+
if (options?.metadata !== undefined) {
|
|
781
|
+
request.metadata = options.metadata;
|
|
782
|
+
}
|
|
783
|
+
if (request.query == null && request.url) {
|
|
784
|
+
try {
|
|
785
|
+
Object.defineProperty(request, "query", {
|
|
786
|
+
get() {
|
|
787
|
+
const url = this._parsedUrl ?? new URL(this.url);
|
|
788
|
+
const query = VectorRouter.parseQuery(url);
|
|
789
|
+
Object.defineProperty(this, "query", {
|
|
790
|
+
value: query,
|
|
791
|
+
writable: true,
|
|
792
|
+
configurable: true,
|
|
793
|
+
enumerable: true
|
|
794
|
+
});
|
|
795
|
+
return query;
|
|
796
|
+
},
|
|
797
|
+
set(value) {
|
|
798
|
+
Object.defineProperty(this, "query", {
|
|
799
|
+
value,
|
|
800
|
+
writable: true,
|
|
801
|
+
configurable: true,
|
|
802
|
+
enumerable: true
|
|
803
|
+
});
|
|
804
|
+
},
|
|
805
|
+
configurable: true,
|
|
806
|
+
enumerable: true
|
|
807
|
+
});
|
|
808
|
+
} catch {
|
|
809
|
+
const url = request._parsedUrl ?? new URL(request.url);
|
|
810
|
+
try {
|
|
811
|
+
request.query = VectorRouter.parseQuery(url);
|
|
812
|
+
} catch {}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (!Object.getOwnPropertyDescriptor(request, "cookies")) {
|
|
816
|
+
Object.defineProperty(request, "cookies", {
|
|
817
|
+
get() {
|
|
818
|
+
const cookieHeader = this.headers.get("cookie") ?? "";
|
|
819
|
+
const cookies = {};
|
|
820
|
+
if (cookieHeader) {
|
|
821
|
+
for (const pair of cookieHeader.split(";")) {
|
|
822
|
+
const idx = pair.indexOf("=");
|
|
823
|
+
if (idx > 0) {
|
|
824
|
+
cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
Object.defineProperty(this, "cookies", {
|
|
829
|
+
value: cookies,
|
|
830
|
+
writable: true,
|
|
831
|
+
configurable: true,
|
|
832
|
+
enumerable: true
|
|
833
|
+
});
|
|
834
|
+
return cookies;
|
|
835
|
+
},
|
|
836
|
+
configurable: true,
|
|
837
|
+
enumerable: true
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
resolveFallbackParams(request, routeMatcher) {
|
|
842
|
+
if (!routeMatcher) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const currentParams = request.params;
|
|
846
|
+
if (currentParams && typeof currentParams === "object" && !Array.isArray(currentParams) && Object.keys(currentParams).length > 0) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
let pathname;
|
|
850
|
+
try {
|
|
851
|
+
pathname = (request._parsedUrl ?? new URL(request.url)).pathname;
|
|
852
|
+
} catch {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const matched = pathname.match(routeMatcher);
|
|
856
|
+
if (!matched?.groups) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
return matched.groups;
|
|
860
|
+
}
|
|
861
|
+
wrapHandler(options, handler) {
|
|
862
|
+
const routePath = options.path;
|
|
863
|
+
const routeMatcher = routePath.includes(":") ? buildRouteRegex(routePath) : null;
|
|
864
|
+
return async (request) => {
|
|
865
|
+
const vectorRequest = request;
|
|
866
|
+
const fallbackParams = this.resolveFallbackParams(request, routeMatcher);
|
|
867
|
+
this.prepareRequest(vectorRequest, {
|
|
868
|
+
params: fallbackParams,
|
|
869
|
+
route: routePath,
|
|
870
|
+
metadata: options.metadata
|
|
871
|
+
});
|
|
872
|
+
try {
|
|
873
|
+
if (options.expose === false) {
|
|
874
|
+
return APIError.forbidden("Forbidden");
|
|
875
|
+
}
|
|
876
|
+
const beforeResult = await this.middlewareManager.executeBefore(vectorRequest);
|
|
877
|
+
if (beforeResult instanceof Response) {
|
|
878
|
+
return beforeResult;
|
|
879
|
+
}
|
|
880
|
+
const req = beforeResult;
|
|
881
|
+
if (options.auth) {
|
|
882
|
+
try {
|
|
883
|
+
await this.authManager.authenticate(req);
|
|
884
|
+
} catch (error) {
|
|
885
|
+
return APIError.unauthorized(error instanceof Error ? error.message : "Authentication failed", options.responseContentType);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (!options.rawRequest && req.method !== "GET" && req.method !== "HEAD") {
|
|
889
|
+
let parsedContent = null;
|
|
890
|
+
try {
|
|
891
|
+
const contentType = req.headers.get("content-type");
|
|
892
|
+
if (contentType?.startsWith("application/json")) {
|
|
893
|
+
parsedContent = await req.json();
|
|
894
|
+
} else if (contentType?.startsWith("application/x-www-form-urlencoded")) {
|
|
895
|
+
parsedContent = Object.fromEntries(await req.formData());
|
|
896
|
+
} else if (contentType?.startsWith("multipart/form-data")) {
|
|
897
|
+
parsedContent = await req.formData();
|
|
898
|
+
} else {
|
|
899
|
+
parsedContent = await req.text();
|
|
900
|
+
}
|
|
901
|
+
} catch {
|
|
902
|
+
parsedContent = null;
|
|
903
|
+
}
|
|
904
|
+
this.setContentAndBodyAlias(req, parsedContent);
|
|
905
|
+
}
|
|
906
|
+
const inputValidationResponse = await this.validateInputSchema(req, options);
|
|
907
|
+
if (inputValidationResponse) {
|
|
908
|
+
return inputValidationResponse;
|
|
909
|
+
}
|
|
910
|
+
let result;
|
|
911
|
+
const cacheOptions = options.cache;
|
|
912
|
+
if (cacheOptions && typeof cacheOptions === "number" && cacheOptions > 0) {
|
|
913
|
+
const cacheKey = this.cacheManager.generateKey(req, {
|
|
914
|
+
authUser: req.authUser
|
|
915
|
+
});
|
|
916
|
+
result = await this.cacheManager.get(cacheKey, async () => {
|
|
917
|
+
const res = await handler(req);
|
|
918
|
+
if (res instanceof Response) {
|
|
919
|
+
return {
|
|
920
|
+
_isResponse: true,
|
|
921
|
+
body: await res.text(),
|
|
922
|
+
status: res.status,
|
|
923
|
+
headers: Object.fromEntries(res.headers.entries())
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
return res;
|
|
927
|
+
}, cacheOptions);
|
|
928
|
+
} else if (cacheOptions && typeof cacheOptions === "object" && cacheOptions.ttl) {
|
|
929
|
+
const cacheKey = cacheOptions.key || this.cacheManager.generateKey(req, {
|
|
930
|
+
authUser: req.authUser
|
|
931
|
+
});
|
|
932
|
+
result = await this.cacheManager.get(cacheKey, async () => {
|
|
933
|
+
const res = await handler(req);
|
|
934
|
+
if (res instanceof Response) {
|
|
935
|
+
return {
|
|
936
|
+
_isResponse: true,
|
|
937
|
+
body: await res.text(),
|
|
938
|
+
status: res.status,
|
|
939
|
+
headers: Object.fromEntries(res.headers.entries())
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
return res;
|
|
943
|
+
}, cacheOptions.ttl);
|
|
944
|
+
} else {
|
|
945
|
+
result = await handler(req);
|
|
946
|
+
}
|
|
947
|
+
if (result && typeof result === "object" && result._isResponse === true) {
|
|
948
|
+
result = new Response(result.body, {
|
|
949
|
+
status: result.status,
|
|
950
|
+
headers: result.headers
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
let response;
|
|
954
|
+
if (options.rawResponse || result instanceof Response) {
|
|
955
|
+
response = result instanceof Response ? result : new Response(result);
|
|
956
|
+
} else {
|
|
957
|
+
response = createResponse(200, result, options.responseContentType);
|
|
958
|
+
}
|
|
959
|
+
response = await this.middlewareManager.executeFinally(response, req);
|
|
960
|
+
const entries = this.corsHeadersEntries;
|
|
961
|
+
if (entries) {
|
|
962
|
+
for (const [k, v] of entries) {
|
|
963
|
+
response.headers.set(k, v);
|
|
964
|
+
}
|
|
965
|
+
} else {
|
|
966
|
+
const dynamicCors = this.corsHandler;
|
|
967
|
+
if (dynamicCors) {
|
|
968
|
+
response = dynamicCors(response, req);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return response;
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (error instanceof Response) {
|
|
974
|
+
return error;
|
|
975
|
+
}
|
|
976
|
+
console.error("Route handler error:", error);
|
|
977
|
+
return APIError.internalServerError(error instanceof Error ? error.message : String(error), options.responseContentType);
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
isDevelopmentMode() {
|
|
982
|
+
if (this.developmentMode !== undefined) {
|
|
983
|
+
return this.developmentMode;
|
|
984
|
+
}
|
|
985
|
+
const nodeEnv = typeof Bun !== "undefined" ? Bun.env.NODE_ENV : "development";
|
|
986
|
+
return nodeEnv !== "production";
|
|
987
|
+
}
|
|
988
|
+
async buildInputValidationPayload(request, options) {
|
|
989
|
+
let body = request.content;
|
|
990
|
+
if (options.rawRequest && request.method !== "GET" && request.method !== "HEAD") {
|
|
991
|
+
try {
|
|
992
|
+
body = await request.clone().text();
|
|
993
|
+
} catch {
|
|
994
|
+
body = null;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
params: request.params ?? {},
|
|
999
|
+
query: request.query ?? {},
|
|
1000
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
1001
|
+
cookies: request.cookies ?? {},
|
|
1002
|
+
body
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
applyValidatedInput(request, validatedValue) {
|
|
1006
|
+
request.validatedInput = validatedValue;
|
|
1007
|
+
if (!validatedValue || typeof validatedValue !== "object") {
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const validated = validatedValue;
|
|
1011
|
+
if ("params" in validated) {
|
|
1012
|
+
try {
|
|
1013
|
+
request.params = validated.params;
|
|
1014
|
+
} catch {}
|
|
1015
|
+
}
|
|
1016
|
+
if ("query" in validated) {
|
|
1017
|
+
try {
|
|
1018
|
+
request.query = validated.query;
|
|
1019
|
+
} catch {}
|
|
1020
|
+
}
|
|
1021
|
+
if ("cookies" in validated) {
|
|
1022
|
+
try {
|
|
1023
|
+
request.cookies = validated.cookies;
|
|
1024
|
+
} catch {}
|
|
1025
|
+
}
|
|
1026
|
+
if ("body" in validated) {
|
|
1027
|
+
this.setContentAndBodyAlias(request, validated.body);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
setContentAndBodyAlias(request, value) {
|
|
1031
|
+
try {
|
|
1032
|
+
request.content = value;
|
|
1033
|
+
} catch {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
this.setBodyAlias(request, value);
|
|
1037
|
+
}
|
|
1038
|
+
setBodyAlias(request, value) {
|
|
1039
|
+
try {
|
|
1040
|
+
request.body = value;
|
|
1041
|
+
} catch {}
|
|
1042
|
+
}
|
|
1043
|
+
async validateInputSchema(request, options) {
|
|
1044
|
+
const inputSchema = options.schema?.input;
|
|
1045
|
+
if (!inputSchema) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
if (options.validate === false) {
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
if (!isStandardRouteSchema(inputSchema)) {
|
|
1052
|
+
return APIError.internalServerError("Invalid route schema configuration", options.responseContentType);
|
|
1053
|
+
}
|
|
1054
|
+
const includeRawIssues = this.isDevelopmentMode();
|
|
1055
|
+
const payload = await this.buildInputValidationPayload(request, options);
|
|
1056
|
+
try {
|
|
1057
|
+
const validation = await runStandardValidation(inputSchema, payload);
|
|
1058
|
+
if (validation.success === false) {
|
|
1059
|
+
const issues = normalizeValidationIssues(validation.issues, includeRawIssues);
|
|
1060
|
+
return createResponse(422, createValidationErrorPayload("input", issues), options.responseContentType);
|
|
1061
|
+
}
|
|
1062
|
+
this.applyValidatedInput(request, validation.value);
|
|
1063
|
+
return null;
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
const thrownIssues = extractThrownIssues(error);
|
|
1066
|
+
if (thrownIssues) {
|
|
1067
|
+
const issues = normalizeValidationIssues(thrownIssues, includeRawIssues);
|
|
1068
|
+
return createResponse(422, createValidationErrorPayload("input", issues), options.responseContentType);
|
|
1069
|
+
}
|
|
1070
|
+
return APIError.internalServerError(error instanceof Error ? error.message : "Validation failed", options.responseContentType);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
getOrCreateMethodMap(path) {
|
|
1074
|
+
const existing = this.routeTable[path];
|
|
1075
|
+
if (existing instanceof Response) {
|
|
1076
|
+
throw new Error(`Cannot register method route for path "${path}" because a static route already exists.`);
|
|
1077
|
+
}
|
|
1078
|
+
if (existing) {
|
|
1079
|
+
return existing;
|
|
1080
|
+
}
|
|
1081
|
+
const methodMap = Object.create(null);
|
|
1082
|
+
this.routeTable[path] = methodMap;
|
|
1083
|
+
this.addRouteMatcher(path);
|
|
1084
|
+
return methodMap;
|
|
1085
|
+
}
|
|
1086
|
+
addRouteMatcher(path) {
|
|
1087
|
+
if (this.routeMatchers.some((matcher) => matcher.path === path)) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
this.routeMatchers.push({
|
|
1091
|
+
path,
|
|
1092
|
+
regex: buildRouteRegex(path),
|
|
1093
|
+
specificity: this.routeSpecificityScore(path)
|
|
1094
|
+
});
|
|
1095
|
+
this.routeMatchers.sort((a, b) => {
|
|
1096
|
+
if (a.specificity !== b.specificity) {
|
|
1097
|
+
return b.specificity - a.specificity;
|
|
1098
|
+
}
|
|
1099
|
+
return a.path.localeCompare(b.path);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
removeRouteMatcher(path) {
|
|
1103
|
+
this.routeMatchers = this.routeMatchers.filter((matcher) => matcher.path !== path);
|
|
1104
|
+
}
|
|
1105
|
+
static parseQuery(url) {
|
|
1106
|
+
const query = {};
|
|
1107
|
+
for (const [key, value] of url.searchParams) {
|
|
1108
|
+
if (key in query) {
|
|
1109
|
+
const existing = query[key];
|
|
1110
|
+
if (Array.isArray(existing)) {
|
|
1111
|
+
existing.push(value);
|
|
1112
|
+
} else {
|
|
1113
|
+
query[key] = [existing, value];
|
|
1114
|
+
}
|
|
1115
|
+
} else {
|
|
1116
|
+
query[key] = value;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return query;
|
|
1120
|
+
}
|
|
1121
|
+
routeSpecificityScore(path) {
|
|
1122
|
+
const STATIC_SEGMENT_WEIGHT = 1000;
|
|
1123
|
+
const PARAM_SEGMENT_WEIGHT = 10;
|
|
1124
|
+
const WILDCARD_WEIGHT = 1;
|
|
1125
|
+
const EXACT_MATCH_BONUS = 1e4;
|
|
1126
|
+
const segments = path.split("/").filter(Boolean);
|
|
1127
|
+
let score = 0;
|
|
1128
|
+
for (const segment of segments) {
|
|
1129
|
+
if (segment.includes("*")) {
|
|
1130
|
+
score += WILDCARD_WEIGHT;
|
|
1131
|
+
} else if (segment.startsWith(":")) {
|
|
1132
|
+
score += PARAM_SEGMENT_WEIGHT;
|
|
1133
|
+
} else {
|
|
1134
|
+
score += STATIC_SEGMENT_WEIGHT;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
score += path.length;
|
|
1138
|
+
if (!path.includes(":") && !path.includes("*")) {
|
|
1139
|
+
score += EXACT_MATCH_BONUS;
|
|
1140
|
+
}
|
|
1141
|
+
return score;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// src/core/server.ts
|
|
1146
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1147
|
+
import { join as join2 } from "path";
|
|
1148
|
+
|
|
1149
|
+
// src/utils/cors.ts
|
|
1150
|
+
function getAllowedOrigin(origin, config) {
|
|
1151
|
+
if (!origin) {
|
|
1152
|
+
if (typeof config.origin === "string") {
|
|
1153
|
+
if (config.origin === "*" && config.credentials)
|
|
1154
|
+
return null;
|
|
1155
|
+
return config.origin;
|
|
1156
|
+
}
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
if (typeof config.origin === "string") {
|
|
1160
|
+
if (config.origin === "*") {
|
|
1161
|
+
return config.credentials ? origin : "*";
|
|
1162
|
+
}
|
|
1163
|
+
return config.origin === origin ? origin : null;
|
|
1164
|
+
}
|
|
1165
|
+
if (Array.isArray(config.origin)) {
|
|
1166
|
+
return config.origin.includes(origin) ? origin : null;
|
|
1167
|
+
}
|
|
1168
|
+
if (typeof config.origin === "function") {
|
|
1169
|
+
return config.origin(origin) ? origin : null;
|
|
1170
|
+
}
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
function shouldVaryByOrigin(config) {
|
|
1174
|
+
return typeof config.origin === "string" && config.origin === "*" && config.credentials || Array.isArray(config.origin) || typeof config.origin === "function";
|
|
1175
|
+
}
|
|
1176
|
+
function buildCorsHeaders(origin, config, varyByOrigin) {
|
|
1177
|
+
const headers = {};
|
|
1178
|
+
if (origin) {
|
|
1179
|
+
headers["access-control-allow-origin"] = origin;
|
|
1180
|
+
headers["access-control-allow-methods"] = config.allowMethods;
|
|
1181
|
+
headers["access-control-allow-headers"] = config.allowHeaders;
|
|
1182
|
+
headers["access-control-expose-headers"] = config.exposeHeaders;
|
|
1183
|
+
headers["access-control-max-age"] = String(config.maxAge);
|
|
1184
|
+
if (config.credentials) {
|
|
1185
|
+
headers["access-control-allow-credentials"] = "true";
|
|
1186
|
+
}
|
|
1187
|
+
if (varyByOrigin) {
|
|
1188
|
+
headers.vary = "Origin";
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return headers;
|
|
1192
|
+
}
|
|
1193
|
+
function mergeVary(existing, nextValue) {
|
|
1194
|
+
if (!existing)
|
|
1195
|
+
return nextValue;
|
|
1196
|
+
const parts = existing.split(",").map((v) => v.trim()).filter(Boolean);
|
|
1197
|
+
const lower = parts.map((v) => v.toLowerCase());
|
|
1198
|
+
if (!lower.includes(nextValue.toLowerCase())) {
|
|
1199
|
+
parts.push(nextValue);
|
|
1200
|
+
}
|
|
1201
|
+
return parts.join(", ");
|
|
1202
|
+
}
|
|
1203
|
+
function cors(config) {
|
|
1204
|
+
return {
|
|
1205
|
+
preflight(request) {
|
|
1206
|
+
const origin = request.headers.get("origin") ?? undefined;
|
|
1207
|
+
const allowed = getAllowedOrigin(origin, config);
|
|
1208
|
+
const varyByOrigin = Boolean(origin && allowed && shouldVaryByOrigin(config));
|
|
1209
|
+
return new Response(null, {
|
|
1210
|
+
status: 204,
|
|
1211
|
+
headers: buildCorsHeaders(allowed, config, varyByOrigin)
|
|
1212
|
+
});
|
|
1213
|
+
},
|
|
1214
|
+
corsify(response, request) {
|
|
1215
|
+
const origin = request.headers.get("origin") ?? undefined;
|
|
1216
|
+
const allowed = getAllowedOrigin(origin, config);
|
|
1217
|
+
if (!allowed)
|
|
1218
|
+
return response;
|
|
1219
|
+
const varyByOrigin = Boolean(origin && shouldVaryByOrigin(config));
|
|
1220
|
+
const headers = buildCorsHeaders(allowed, config, varyByOrigin);
|
|
1221
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1222
|
+
if (k === "vary") {
|
|
1223
|
+
response.headers.set("vary", mergeVary(response.headers.get("vary"), v));
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
response.headers.set(k, v);
|
|
1227
|
+
}
|
|
1228
|
+
return response;
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// src/openapi/docs-ui.ts
|
|
1234
|
+
function renderOpenAPIDocsHtml(spec, openapiPath, tailwindScriptPath) {
|
|
1235
|
+
const specJson = JSON.stringify(spec).replace(/<\/script/gi, "<\\/script");
|
|
1236
|
+
const openapiPathJson = JSON.stringify(openapiPath);
|
|
1237
|
+
const tailwindScriptPathJson = JSON.stringify(tailwindScriptPath);
|
|
1238
|
+
return `<!DOCTYPE html>
|
|
1239
|
+
<html lang="en">
|
|
1240
|
+
<head>
|
|
1241
|
+
<meta charset="UTF-8">
|
|
1242
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1243
|
+
<title>Vector API Documentation</title>
|
|
1244
|
+
<script>
|
|
1245
|
+
if (localStorage.getItem('theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
1246
|
+
document.documentElement.classList.add('dark');
|
|
1247
|
+
} else {
|
|
1248
|
+
document.documentElement.classList.remove('dark');
|
|
1249
|
+
}
|
|
1250
|
+
</script>
|
|
1251
|
+
<script src=${tailwindScriptPathJson}></script>
|
|
1252
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
1253
|
+
<script>
|
|
1254
|
+
tailwind.config = {
|
|
1255
|
+
darkMode: 'class',
|
|
1256
|
+
theme: {
|
|
1257
|
+
extend: {
|
|
1258
|
+
colors: {
|
|
1259
|
+
brand: '#6366F1',
|
|
1260
|
+
dark: { bg: '#0A0A0A', surface: '#111111', border: '#1F1F1F', text: '#EDEDED' },
|
|
1261
|
+
light: { bg: '#FFFFFF', surface: '#F9F9F9', border: '#E5E5E5', text: '#111111' }
|
|
1262
|
+
},
|
|
1263
|
+
fontFamily: {
|
|
1264
|
+
sans: ['Inter', 'sans-serif'],
|
|
1265
|
+
mono: ['JetBrains Mono', 'monospace'],
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
</script>
|
|
1271
|
+
<style>
|
|
1272
|
+
:root {
|
|
1273
|
+
--motion-fast: 180ms;
|
|
1274
|
+
--motion-base: 280ms;
|
|
1275
|
+
--motion-slow: 420ms;
|
|
1276
|
+
--motion-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
1277
|
+
}
|
|
1278
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
1279
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
1280
|
+
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
|
|
1281
|
+
body { transition: background-color 150ms ease, color 150ms ease; }
|
|
1282
|
+
#sidebar-nav a,
|
|
1283
|
+
#send-btn,
|
|
1284
|
+
#copy-curl,
|
|
1285
|
+
#add-header-btn,
|
|
1286
|
+
#expand-body-btn,
|
|
1287
|
+
#expand-response-btn,
|
|
1288
|
+
#expand-close,
|
|
1289
|
+
#expand-apply {
|
|
1290
|
+
transition:
|
|
1291
|
+
transform var(--motion-fast) var(--motion-ease),
|
|
1292
|
+
opacity var(--motion-fast) var(--motion-ease),
|
|
1293
|
+
border-color var(--motion-fast) var(--motion-ease),
|
|
1294
|
+
background-color var(--motion-fast) var(--motion-ease),
|
|
1295
|
+
color var(--motion-fast) var(--motion-ease);
|
|
1296
|
+
will-change: transform, opacity;
|
|
1297
|
+
}
|
|
1298
|
+
#sidebar-nav a:hover,
|
|
1299
|
+
#send-btn:hover,
|
|
1300
|
+
#add-header-btn:hover,
|
|
1301
|
+
#expand-body-btn:hover,
|
|
1302
|
+
#expand-response-btn:hover {
|
|
1303
|
+
transform: translateY(-1px);
|
|
1304
|
+
}
|
|
1305
|
+
#endpoint-card {
|
|
1306
|
+
transition:
|
|
1307
|
+
box-shadow var(--motion-base) var(--motion-ease),
|
|
1308
|
+
transform var(--motion-base) var(--motion-ease),
|
|
1309
|
+
opacity var(--motion-base) var(--motion-ease);
|
|
1310
|
+
}
|
|
1311
|
+
.enter-fade-up {
|
|
1312
|
+
animation: enterFadeUp var(--motion-base) var(--motion-ease) both;
|
|
1313
|
+
}
|
|
1314
|
+
.enter-stagger {
|
|
1315
|
+
animation: enterStagger var(--motion-base) var(--motion-ease) both;
|
|
1316
|
+
animation-delay: var(--stagger-delay, 0ms);
|
|
1317
|
+
}
|
|
1318
|
+
@keyframes enterFadeUp {
|
|
1319
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
1320
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1321
|
+
}
|
|
1322
|
+
@keyframes enterStagger {
|
|
1323
|
+
from { opacity: 0; transform: translateX(-6px); }
|
|
1324
|
+
to { opacity: 1; transform: translateX(0); }
|
|
1325
|
+
}
|
|
1326
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1327
|
+
*, *::before, *::after {
|
|
1328
|
+
animation: none !important;
|
|
1329
|
+
transition: none !important;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
.json-key { color: #0f766e; }
|
|
1333
|
+
.json-string { color: #0369a1; }
|
|
1334
|
+
.json-number { color: #7c3aed; }
|
|
1335
|
+
.json-boolean { color: #b45309; }
|
|
1336
|
+
.json-null { color: #be123c; }
|
|
1337
|
+
.dark .json-key { color: #5eead4; }
|
|
1338
|
+
.dark .json-string { color: #7dd3fc; }
|
|
1339
|
+
.dark .json-number { color: #c4b5fd; }
|
|
1340
|
+
.dark .json-boolean { color: #fcd34d; }
|
|
1341
|
+
.dark .json-null { color: #fda4af; }
|
|
1342
|
+
</style>
|
|
1343
|
+
</head>
|
|
1344
|
+
<body class="bg-light-bg text-light-text dark:bg-dark-bg dark:text-dark-text font-sans antialiased flex h-screen overflow-hidden">
|
|
1345
|
+
<div id="mobile-backdrop" class="fixed inset-0 z-30 bg-black/40 opacity-0 pointer-events-none transition-opacity duration-300 md:hidden"></div>
|
|
1346
|
+
<aside id="docs-sidebar" class="fixed inset-y-0 left-0 z-40 w-72 md:w-64 border-r border-light-border dark:border-dark-border bg-light-surface dark:bg-dark-surface flex flex-col flex-shrink-0 transition-transform duration-300 ease-out -translate-x-full md:translate-x-0 md:static md:z-auto transition-colors duration-150">
|
|
1347
|
+
<div class="h-14 flex items-center px-5 border-b border-light-border dark:border-dark-border">
|
|
1348
|
+
<svg class="w-5 h-5 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1349
|
+
<polygon points="3 21 12 2 21 21 12 15 3 21"></polygon>
|
|
1350
|
+
</svg>
|
|
1351
|
+
<span class="ml-2.5 font-bold tracking-tight text-lg">Vector</span>
|
|
1352
|
+
<button id="sidebar-close" class="ml-auto p-1.5 rounded-full border border-light-border dark:border-dark-border bg-light-bg/90 dark:bg-dark-bg/90 opacity-90 hover:opacity-100 transition md:hidden" aria-label="Close Menu" title="Close Menu">
|
|
1353
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1354
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
1355
|
+
</svg>
|
|
1356
|
+
</button>
|
|
1357
|
+
</div>
|
|
1358
|
+
<div class="p-4">
|
|
1359
|
+
<div class="relative">
|
|
1360
|
+
<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1361
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
1362
|
+
</svg>
|
|
1363
|
+
<input
|
|
1364
|
+
id="sidebar-search"
|
|
1365
|
+
type="text"
|
|
1366
|
+
placeholder="Search routes..."
|
|
1367
|
+
class="w-full pl-9 pr-3 py-2 text-sm rounded-md border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg focus:outline-none focus:border-brand dark:focus:border-brand transition-colors"
|
|
1368
|
+
/>
|
|
1369
|
+
</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
<nav class="flex-1 overflow-y-auto px-3 py-2 space-y-6 text-sm" id="sidebar-nav"></nav>
|
|
1372
|
+
</aside>
|
|
1373
|
+
|
|
1374
|
+
<main class="flex-1 flex flex-col min-w-0 relative">
|
|
1375
|
+
<header class="h-14 flex items-center justify-between px-6 border-b border-light-border dark:border-dark-border lg:border-none lg:bg-transparent absolute top-0 w-full z-10 bg-light-bg/80 dark:bg-dark-bg/80 backdrop-blur-sm transition-colors duration-150">
|
|
1376
|
+
<div class="md:hidden flex items-center gap-2">
|
|
1377
|
+
<button id="sidebar-open" class="p-1.5 rounded-full border border-light-border dark:border-dark-border bg-light-bg/90 dark:bg-dark-bg/90 opacity-90 hover:opacity-100 transition" aria-label="Open Menu" title="Open Menu">
|
|
1378
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1379
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
|
1380
|
+
</svg>
|
|
1381
|
+
</button>
|
|
1382
|
+
<svg class="w-5 h-5 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="3 21 12 2 21 21 12 15 3 21"></polygon></svg>
|
|
1383
|
+
<span class="font-bold tracking-tight">Vector</span>
|
|
1384
|
+
</div>
|
|
1385
|
+
<div class="flex-1"></div>
|
|
1386
|
+
<button id="theme-toggle" class="p-2 rounded-md hover:bg-black/5 dark:hover:bg-white/5 transition-colors" aria-label="Toggle Dark Mode">
|
|
1387
|
+
<svg class="w-5 h-5 hidden dark:block text-dark-text" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
|
1388
|
+
<svg class="w-5 h-5 block dark:hidden text-light-text" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
|
1389
|
+
</button>
|
|
1390
|
+
</header>
|
|
1391
|
+
|
|
1392
|
+
<div class="flex-1 overflow-y-auto pt-14 pb-24">
|
|
1393
|
+
<div class="max-w-[860px] mx-auto px-6 py-12 lg:py-16">
|
|
1394
|
+
<div class="mb-12">
|
|
1395
|
+
<h1 class="text-4xl font-bold tracking-tight mb-4" id="tag-title">API</h1>
|
|
1396
|
+
<p class="text-lg opacity-80 max-w-2xl leading-relaxed" id="tag-description">Interactive API documentation.</p>
|
|
1397
|
+
</div>
|
|
1398
|
+
<hr class="border-t border-light-border dark:border-dark-border mb-12">
|
|
1399
|
+
<div class="mb-20" id="endpoint-card">
|
|
1400
|
+
<div class="flex items-center gap-3 mb-4">
|
|
1401
|
+
<span id="endpoint-method" class="px-2.5 py-0.5 rounded-full text-xs font-mono font-medium"></span>
|
|
1402
|
+
<h2 class="text-xl font-semibold tracking-tight" id="endpoint-title">Operation</h2>
|
|
1403
|
+
</div>
|
|
1404
|
+
<p class="text-sm opacity-80 mb-8 font-mono" id="endpoint-path">/</p>
|
|
1405
|
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
|
1406
|
+
<div class="lg:col-span-5 space-y-8" id="params-column"></div>
|
|
1407
|
+
<div class="lg:col-span-7">
|
|
1408
|
+
<div class="rounded-lg border border-light-border dark:border-[#1F1F1F] bg-light-bg dark:bg-[#111111] overflow-hidden group">
|
|
1409
|
+
<div class="flex items-center justify-between px-4 py-2 border-b border-light-border dark:border-[#1F1F1F] bg-light-surface dark:bg-[#0A0A0A]">
|
|
1410
|
+
<span class="text-xs font-mono text-light-text/70 dark:text-[#EDEDED]/70">cURL</span>
|
|
1411
|
+
<button class="text-xs text-light-text/50 hover:text-light-text dark:text-[#EDEDED]/50 dark:hover:text-[#EDEDED] transition-colors" id="copy-curl">Copy</button>
|
|
1412
|
+
</div>
|
|
1413
|
+
<pre class="p-4 text-sm font-mono text-light-text dark:text-[#EDEDED] overflow-x-auto leading-relaxed"><code id="curl-code"></code></pre>
|
|
1414
|
+
</div>
|
|
1415
|
+
<div class="mt-4 p-4 rounded-lg border border-light-border dark:border-dark-border bg-light-surface dark:bg-dark-surface">
|
|
1416
|
+
<div class="flex items-center justify-between mb-3">
|
|
1417
|
+
<h4 class="text-sm font-medium">Try it out</h4>
|
|
1418
|
+
<button id="send-btn" class="px-4 py-1.5 bg-teal-600 text-white text-sm font-semibold rounded hover:bg-teal-500 transition-colors">Submit</button>
|
|
1419
|
+
</div>
|
|
1420
|
+
<div class="space-y-4">
|
|
1421
|
+
<div>
|
|
1422
|
+
<div id="request-param-inputs" class="space-y-3"></div>
|
|
1423
|
+
</div>
|
|
1424
|
+
|
|
1425
|
+
<div>
|
|
1426
|
+
<div class="flex items-center justify-between mb-2">
|
|
1427
|
+
<p class="text-xs font-semibold uppercase tracking-wider opacity-60">Headers</p>
|
|
1428
|
+
<button id="add-header-btn" class="p-1.5 rounded-full border border-light-border dark:border-dark-border bg-light-bg/90 dark:bg-dark-bg/90 opacity-90 hover:opacity-100 hover:border-brand/60 transition-colors" aria-label="Add Header" title="Add Header">
|
|
1429
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1430
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14"></path>
|
|
1431
|
+
</svg>
|
|
1432
|
+
</button>
|
|
1433
|
+
</div>
|
|
1434
|
+
<div id="header-inputs" class="space-y-2"></div>
|
|
1435
|
+
</div>
|
|
1436
|
+
|
|
1437
|
+
<div id="request-body-section">
|
|
1438
|
+
<div class="flex items-center justify-between mb-2">
|
|
1439
|
+
<p class="text-xs font-semibold uppercase tracking-wider opacity-60">Request Body</p>
|
|
1440
|
+
</div>
|
|
1441
|
+
<div class="relative h-40 rounded border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg overflow-hidden">
|
|
1442
|
+
<pre id="body-highlight" class="absolute inset-0 m-0 p-3 pr-11 text-xs font-mono leading-5 overflow-auto whitespace-pre-wrap break-words pointer-events-none"></pre>
|
|
1443
|
+
<textarea id="body-input" class="absolute inset-0 w-full h-full p-3 pr-11 text-xs font-mono leading-5 bg-transparent text-transparent caret-black dark:caret-white resize-none focus:outline-none overflow-auto placeholder:text-light-text/50 dark:placeholder:text-dark-text/40" placeholder='{"key":"value"}' spellcheck="false" autocapitalize="off" autocorrect="off"></textarea>
|
|
1444
|
+
<button id="expand-body-btn" class="absolute bottom-2 right-2 p-1.5 rounded-full border border-light-border dark:border-dark-border bg-light-surface/95 dark:bg-dark-surface/95 opacity-90 hover:opacity-100 hover:border-brand/60 transition-colors" aria-label="Expand Request Body" title="Expand Request Body">
|
|
1445
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1446
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 9V4h5M20 15v5h-5M15 4h5v5M9 20H4v-5"></path>
|
|
1447
|
+
</svg>
|
|
1448
|
+
</button>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1451
|
+
|
|
1452
|
+
<div>
|
|
1453
|
+
<div class="flex items-center justify-between mb-2">
|
|
1454
|
+
<p class="text-xs font-semibold uppercase tracking-wider opacity-60">Response</p>
|
|
1455
|
+
</div>
|
|
1456
|
+
<div class="relative">
|
|
1457
|
+
<pre id="result" class="p-3 pr-11 text-xs font-mono rounded border border-light-border dark:border-dark-border overflow-x-auto min-h-[140px]"></pre>
|
|
1458
|
+
<button id="expand-response-btn" class="absolute bottom-2 right-2 p-1.5 rounded-full border border-light-border dark:border-dark-border bg-light-surface/95 dark:bg-dark-surface/95 opacity-90 hover:opacity-100 hover:border-brand/60 transition-colors" aria-label="Expand Response" title="Expand Response">
|
|
1459
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1460
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 9V4h5M20 15v5h-5M15 4h5v5M9 20H4v-5"></path>
|
|
1461
|
+
</svg>
|
|
1462
|
+
</button>
|
|
1463
|
+
</div>
|
|
1464
|
+
</div>
|
|
1465
|
+
</div>
|
|
1466
|
+
</div>
|
|
1467
|
+
</div>
|
|
1468
|
+
</div>
|
|
1469
|
+
</div>
|
|
1470
|
+
</div>
|
|
1471
|
+
</div>
|
|
1472
|
+
</main>
|
|
1473
|
+
|
|
1474
|
+
<div id="expand-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 p-4">
|
|
1475
|
+
<div class="w-full max-w-5xl rounded-lg border border-light-border dark:border-dark-border bg-light-surface dark:bg-dark-surface p-4">
|
|
1476
|
+
<div class="flex items-center justify-between mb-3">
|
|
1477
|
+
<h3 id="expand-modal-title" class="text-sm font-semibold">Expanded View</h3>
|
|
1478
|
+
<div class="flex items-center gap-2">
|
|
1479
|
+
<button id="expand-apply" class="hidden text-sm px-3 py-1.5 rounded bg-teal-600 text-white font-semibold hover:bg-teal-500 transition-colors">Apply</button>
|
|
1480
|
+
<button id="expand-close" class="p-1.5 rounded-full border border-light-border dark:border-dark-border bg-light-bg/90 dark:bg-dark-bg/90 opacity-90 hover:opacity-100 hover:border-brand/60 transition-colors" aria-label="Close Modal" title="Close Modal">
|
|
1481
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1482
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
1483
|
+
</svg>
|
|
1484
|
+
</button>
|
|
1485
|
+
</div>
|
|
1486
|
+
</div>
|
|
1487
|
+
<div id="expand-editor-shell" class="hidden relative w-full h-[70vh] rounded border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg overflow-hidden">
|
|
1488
|
+
<pre id="expand-editor-highlight" class="absolute inset-0 m-0 p-3 text-sm font-mono leading-6 overflow-auto whitespace-pre-wrap break-words pointer-events-none"></pre>
|
|
1489
|
+
<textarea id="expand-editor" class="absolute inset-0 w-full h-full p-3 text-sm font-mono leading-6 bg-transparent text-transparent caret-black dark:caret-white resize-none focus:outline-none overflow-auto" spellcheck="false" autocapitalize="off" autocorrect="off"></textarea>
|
|
1490
|
+
</div>
|
|
1491
|
+
<pre id="expand-viewer" class="hidden w-full h-[70vh] text-sm p-3 rounded border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg overflow-auto font-mono"></pre>
|
|
1492
|
+
</div>
|
|
1493
|
+
</div>
|
|
1494
|
+
|
|
1495
|
+
<script>
|
|
1496
|
+
const spec = ${specJson};
|
|
1497
|
+
const openapiPath = ${openapiPathJson};
|
|
1498
|
+
const methodBadge = {
|
|
1499
|
+
GET: "bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400",
|
|
1500
|
+
POST: "bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400",
|
|
1501
|
+
PUT: "bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400",
|
|
1502
|
+
PATCH: "bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400",
|
|
1503
|
+
DELETE: "bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400",
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
function getOperations() {
|
|
1507
|
+
const httpMethods = new Set([
|
|
1508
|
+
"get",
|
|
1509
|
+
"post",
|
|
1510
|
+
"put",
|
|
1511
|
+
"patch",
|
|
1512
|
+
"delete",
|
|
1513
|
+
"head",
|
|
1514
|
+
"options",
|
|
1515
|
+
]);
|
|
1516
|
+
|
|
1517
|
+
const humanizePath = (path) =>
|
|
1518
|
+
path
|
|
1519
|
+
.replace(/^\\/+/, "")
|
|
1520
|
+
.replace(/[{}]/g, "")
|
|
1521
|
+
.replace(/[\\/_]+/g, " ")
|
|
1522
|
+
.trim() || "root";
|
|
1523
|
+
|
|
1524
|
+
const toTitleCase = (value) =>
|
|
1525
|
+
value.replace(/\\w\\S*/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
|
|
1526
|
+
|
|
1527
|
+
const getDisplayName = (op, method, path) => {
|
|
1528
|
+
if (typeof op.summary === "string" && op.summary.trim()) {
|
|
1529
|
+
return op.summary.trim();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (typeof op.operationId === "string" && op.operationId.trim()) {
|
|
1533
|
+
const withoutPrefix = op.operationId.replace(
|
|
1534
|
+
new RegExp("^" + method + "_+", "i"),
|
|
1535
|
+
"",
|
|
1536
|
+
);
|
|
1537
|
+
const readable = withoutPrefix.replace(/_+/g, " ").trim();
|
|
1538
|
+
if (readable) return toTitleCase(readable);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
return toTitleCase(humanizePath(path));
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
const ops = [];
|
|
1545
|
+
const paths = spec.paths || {};
|
|
1546
|
+
for (const path of Object.keys(paths)) {
|
|
1547
|
+
const methods = paths[path] || {};
|
|
1548
|
+
for (const method of Object.keys(methods)) {
|
|
1549
|
+
if (!httpMethods.has(method)) continue;
|
|
1550
|
+
const op = methods[method];
|
|
1551
|
+
ops.push({
|
|
1552
|
+
path,
|
|
1553
|
+
method: method.toUpperCase(),
|
|
1554
|
+
operation: op,
|
|
1555
|
+
tag: (op.tags && op.tags[0]) || "default",
|
|
1556
|
+
name: getDisplayName(op, method, path),
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return ops;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const operations = getOperations();
|
|
1564
|
+
let selected = operations[0] || null;
|
|
1565
|
+
const operationParamValues = new Map();
|
|
1566
|
+
const operationBodyDrafts = new Map();
|
|
1567
|
+
const requestHeaders = [{ key: "Authorization", value: "" }];
|
|
1568
|
+
let expandModalMode = null;
|
|
1569
|
+
let isMobileSidebarOpen = false;
|
|
1570
|
+
let sidebarSearchQuery = "";
|
|
1571
|
+
|
|
1572
|
+
function setMobileSidebarOpen(open) {
|
|
1573
|
+
const sidebar = document.getElementById("docs-sidebar");
|
|
1574
|
+
const backdrop = document.getElementById("mobile-backdrop");
|
|
1575
|
+
const openBtn = document.getElementById("sidebar-open");
|
|
1576
|
+
if (!sidebar || !backdrop || !openBtn) return;
|
|
1577
|
+
|
|
1578
|
+
isMobileSidebarOpen = open;
|
|
1579
|
+
sidebar.classList.toggle("-translate-x-full", !open);
|
|
1580
|
+
backdrop.classList.toggle("opacity-0", !open);
|
|
1581
|
+
backdrop.classList.toggle("pointer-events-none", !open);
|
|
1582
|
+
openBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1583
|
+
document.body.classList.toggle("overflow-hidden", open);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function getOperationKey(op) {
|
|
1587
|
+
return op.method + " " + op.path;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function getOperationParameterGroups(op) {
|
|
1591
|
+
const params =
|
|
1592
|
+
op &&
|
|
1593
|
+
op.operation &&
|
|
1594
|
+
Array.isArray(op.operation.parameters)
|
|
1595
|
+
? op.operation.parameters
|
|
1596
|
+
: [];
|
|
1597
|
+
|
|
1598
|
+
return {
|
|
1599
|
+
all: params,
|
|
1600
|
+
path: params.filter((p) => p.in === "path"),
|
|
1601
|
+
query: params.filter((p) => p.in === "query"),
|
|
1602
|
+
headers: params.filter((p) => p.in === "header"),
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function getParameterValues(op) {
|
|
1607
|
+
const key = getOperationKey(op);
|
|
1608
|
+
if (!operationParamValues.has(key)) {
|
|
1609
|
+
operationParamValues.set(key, {});
|
|
1610
|
+
}
|
|
1611
|
+
return operationParamValues.get(key);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function getBodyDraft(op) {
|
|
1615
|
+
const key = getOperationKey(op);
|
|
1616
|
+
return operationBodyDrafts.get(key);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function setBodyDraft(op, bodyValue) {
|
|
1620
|
+
const key = getOperationKey(op);
|
|
1621
|
+
operationBodyDrafts.set(key, bodyValue);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function resolvePath(pathTemplate, pathParams, values) {
|
|
1625
|
+
let resolved = pathTemplate;
|
|
1626
|
+
for (const param of pathParams) {
|
|
1627
|
+
const rawValue = values[param.name];
|
|
1628
|
+
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
const placeholder = "{" + param.name + "}";
|
|
1632
|
+
resolved = resolved
|
|
1633
|
+
.split(placeholder)
|
|
1634
|
+
.join(encodeURIComponent(String(rawValue)));
|
|
1635
|
+
}
|
|
1636
|
+
return resolved;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function buildRequestPath(op, pathParams, queryParams, values) {
|
|
1640
|
+
const resolvedPath = resolvePath(op.path, pathParams, values);
|
|
1641
|
+
const query = new URLSearchParams();
|
|
1642
|
+
|
|
1643
|
+
for (const param of queryParams) {
|
|
1644
|
+
const rawValue = values[param.name];
|
|
1645
|
+
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
query.append(param.name, String(rawValue));
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const queryString = query.toString();
|
|
1652
|
+
return queryString ? resolvedPath + "?" + queryString : resolvedPath;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function schemaDefaultValue(schema) {
|
|
1656
|
+
if (!schema || typeof schema !== "object") return null;
|
|
1657
|
+
if (schema.default !== undefined) return schema.default;
|
|
1658
|
+
if (schema.example !== undefined) return schema.example;
|
|
1659
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
|
|
1660
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
|
1661
|
+
return schemaDefaultValue(schema.oneOf[0]);
|
|
1662
|
+
}
|
|
1663
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
1664
|
+
return schemaDefaultValue(schema.anyOf[0]);
|
|
1665
|
+
}
|
|
1666
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
1667
|
+
return schemaDefaultValue(schema.allOf[0]);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
switch (schema.type) {
|
|
1671
|
+
case "string":
|
|
1672
|
+
return "";
|
|
1673
|
+
case "number":
|
|
1674
|
+
case "integer":
|
|
1675
|
+
return 0;
|
|
1676
|
+
case "boolean":
|
|
1677
|
+
return false;
|
|
1678
|
+
case "array":
|
|
1679
|
+
return [];
|
|
1680
|
+
case "object": {
|
|
1681
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
1682
|
+
const properties = schema.properties && typeof schema.properties === "object"
|
|
1683
|
+
? schema.properties
|
|
1684
|
+
: {};
|
|
1685
|
+
const obj = {};
|
|
1686
|
+
for (const fieldName of required) {
|
|
1687
|
+
obj[fieldName] = schemaDefaultValue(properties[fieldName]);
|
|
1688
|
+
}
|
|
1689
|
+
return obj;
|
|
1690
|
+
}
|
|
1691
|
+
default:
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function buildRequiredBodyPrefill(schema) {
|
|
1697
|
+
if (!schema || typeof schema !== "object") return "";
|
|
1698
|
+
const prefillValue = schemaDefaultValue(schema);
|
|
1699
|
+
if (
|
|
1700
|
+
prefillValue &&
|
|
1701
|
+
typeof prefillValue === "object" &&
|
|
1702
|
+
!Array.isArray(prefillValue) &&
|
|
1703
|
+
Object.keys(prefillValue).length === 0
|
|
1704
|
+
) {
|
|
1705
|
+
return "";
|
|
1706
|
+
}
|
|
1707
|
+
try {
|
|
1708
|
+
return JSON.stringify(prefillValue, null, 2);
|
|
1709
|
+
} catch {
|
|
1710
|
+
return "";
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function hasMeaningfulRequestBodySchema(schema) {
|
|
1715
|
+
if (!schema || typeof schema !== "object") return false;
|
|
1716
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return true;
|
|
1717
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return true;
|
|
1718
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) return true;
|
|
1719
|
+
if (schema.type && schema.type !== "object") return true;
|
|
1720
|
+
if (schema.additionalProperties !== undefined) return true;
|
|
1721
|
+
if (Array.isArray(schema.required) && schema.required.length > 0) return true;
|
|
1722
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
1723
|
+
return Object.keys(schema.properties).length > 0;
|
|
1724
|
+
}
|
|
1725
|
+
return false;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function renderSidebar() {
|
|
1729
|
+
const nav = document.getElementById("sidebar-nav");
|
|
1730
|
+
const groups = new Map();
|
|
1731
|
+
const query = sidebarSearchQuery.trim().toLowerCase();
|
|
1732
|
+
const visibleOps = query
|
|
1733
|
+
? operations.filter((op) => {
|
|
1734
|
+
const haystack = [
|
|
1735
|
+
op.name,
|
|
1736
|
+
op.path,
|
|
1737
|
+
op.method,
|
|
1738
|
+
op.tag,
|
|
1739
|
+
]
|
|
1740
|
+
.join(" ")
|
|
1741
|
+
.toLowerCase();
|
|
1742
|
+
return haystack.includes(query);
|
|
1743
|
+
})
|
|
1744
|
+
: operations;
|
|
1745
|
+
|
|
1746
|
+
for (const op of visibleOps) {
|
|
1747
|
+
if (!groups.has(op.tag)) groups.set(op.tag, []);
|
|
1748
|
+
groups.get(op.tag).push(op);
|
|
1749
|
+
}
|
|
1750
|
+
nav.innerHTML = "";
|
|
1751
|
+
if (visibleOps.length === 0) {
|
|
1752
|
+
nav.innerHTML =
|
|
1753
|
+
'<p class="px-2 text-xs opacity-60">No routes match your search.</p>';
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
for (const [tag, ops] of groups.entries()) {
|
|
1757
|
+
const block = document.createElement("div");
|
|
1758
|
+
block.innerHTML = '<h3 class="px-2 mb-2 font-semibold text-xs uppercase tracking-wider opacity-50"></h3><ul class="space-y-0.5"></ul>';
|
|
1759
|
+
block.querySelector("h3").textContent = tag;
|
|
1760
|
+
const list = block.querySelector("ul");
|
|
1761
|
+
for (const op of ops) {
|
|
1762
|
+
const li = document.createElement("li");
|
|
1763
|
+
li.className = "enter-stagger";
|
|
1764
|
+
li.style.setProperty("--stagger-delay", String(Math.min(list.children.length * 22, 180)) + "ms");
|
|
1765
|
+
const a = document.createElement("a");
|
|
1766
|
+
a.href = "#";
|
|
1767
|
+
a.className = op === selected
|
|
1768
|
+
? "block px-2 py-1.5 rounded-md bg-black/5 dark:bg-white/5 text-brand font-medium transition-colors"
|
|
1769
|
+
: "block px-2 py-1.5 rounded-md hover:bg-black/5 dark:hover:bg-white/5 transition-colors";
|
|
1770
|
+
|
|
1771
|
+
const row = document.createElement("span");
|
|
1772
|
+
row.className = "flex items-center gap-2";
|
|
1773
|
+
|
|
1774
|
+
const method = document.createElement("span");
|
|
1775
|
+
method.className = "px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold " + (methodBadge[op.method] || "bg-zinc-100 text-zinc-700 dark:bg-zinc-500/10 dark:text-zinc-400");
|
|
1776
|
+
method.textContent = op.method;
|
|
1777
|
+
|
|
1778
|
+
const name = document.createElement("span");
|
|
1779
|
+
name.textContent = op.name;
|
|
1780
|
+
|
|
1781
|
+
row.appendChild(method);
|
|
1782
|
+
row.appendChild(name);
|
|
1783
|
+
a.appendChild(row);
|
|
1784
|
+
|
|
1785
|
+
a.onclick = (e) => {
|
|
1786
|
+
e.preventDefault();
|
|
1787
|
+
selected = op;
|
|
1788
|
+
renderSidebar();
|
|
1789
|
+
renderEndpoint();
|
|
1790
|
+
if (window.innerWidth < 768) {
|
|
1791
|
+
setMobileSidebarOpen(false);
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
li.appendChild(a);
|
|
1795
|
+
list.appendChild(li);
|
|
1796
|
+
}
|
|
1797
|
+
nav.appendChild(block);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function renderParamSection(title, params) {
|
|
1802
|
+
if (!params.length) return "";
|
|
1803
|
+
let rows = "";
|
|
1804
|
+
for (const p of params) {
|
|
1805
|
+
const type = escapeHtml((p.schema && p.schema.type) || "unknown");
|
|
1806
|
+
const name = escapeHtml(p.name || "");
|
|
1807
|
+
rows += '<div class="py-2 flex justify-between border-b border-light-border/50 dark:border-dark-border/50"><div><code class="text-sm font-mono">' + name + '</code><span class="text-xs text-brand ml-2">' + (p.required ? "required" : "optional") + '</span></div><span class="text-xs font-mono opacity-60">' + type + '</span></div>';
|
|
1808
|
+
}
|
|
1809
|
+
return '<div><h3 class="text-sm font-semibold mb-3 flex items-center border-b border-light-border dark:border-dark-border pb-2">' + escapeHtml(title) + "</h3>" + rows + "</div>";
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function getSchemaTypeLabel(schema) {
|
|
1813
|
+
if (!schema || typeof schema !== "object") return "unknown";
|
|
1814
|
+
if (Array.isArray(schema.type)) return schema.type.join(" | ");
|
|
1815
|
+
if (schema.type) return String(schema.type);
|
|
1816
|
+
if (schema.properties) return "object";
|
|
1817
|
+
if (schema.items) return "array";
|
|
1818
|
+
if (Array.isArray(schema.oneOf)) return "oneOf";
|
|
1819
|
+
if (Array.isArray(schema.anyOf)) return "anyOf";
|
|
1820
|
+
if (Array.isArray(schema.allOf)) return "allOf";
|
|
1821
|
+
return "unknown";
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function buildSchemaChildren(schema) {
|
|
1825
|
+
if (!schema || typeof schema !== "object") return [];
|
|
1826
|
+
|
|
1827
|
+
const children = [];
|
|
1828
|
+
|
|
1829
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
1830
|
+
const requiredSet = new Set(
|
|
1831
|
+
Array.isArray(schema.required) ? schema.required : [],
|
|
1832
|
+
);
|
|
1833
|
+
for (const [name, childSchema] of Object.entries(schema.properties)) {
|
|
1834
|
+
children.push({
|
|
1835
|
+
name,
|
|
1836
|
+
schema: childSchema || {},
|
|
1837
|
+
required: requiredSet.has(name),
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
if (schema.items) {
|
|
1843
|
+
children.push({
|
|
1844
|
+
name: "items[]",
|
|
1845
|
+
schema: schema.items,
|
|
1846
|
+
required: true,
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
return children;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function renderSchemaFieldNode(field, depth) {
|
|
1854
|
+
const schema = field.schema || {};
|
|
1855
|
+
const name = escapeHtml(field.name || "field");
|
|
1856
|
+
const requiredLabel = field.required ? "required" : "optional";
|
|
1857
|
+
const type = escapeHtml(getSchemaTypeLabel(schema));
|
|
1858
|
+
const children = buildSchemaChildren(schema);
|
|
1859
|
+
const padding = depth * 14;
|
|
1860
|
+
|
|
1861
|
+
if (!children.length) {
|
|
1862
|
+
return (
|
|
1863
|
+
'<div class="py-2 border-b border-light-border/50 dark:border-dark-border/50" style="padding-left:' +
|
|
1864
|
+
padding +
|
|
1865
|
+
'px"><div class="flex justify-between"><div><code class="text-sm font-mono">' +
|
|
1866
|
+
name +
|
|
1867
|
+
'</code><span class="text-xs text-brand ml-2">' +
|
|
1868
|
+
requiredLabel +
|
|
1869
|
+
'</span></div><span class="text-xs font-mono opacity-60">' +
|
|
1870
|
+
type +
|
|
1871
|
+
"</span></div></div>"
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
let nested = "";
|
|
1876
|
+
for (const child of children) {
|
|
1877
|
+
nested += renderSchemaFieldNode(child, depth + 1);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
return (
|
|
1881
|
+
'<details class="border-b border-light-border/50 dark:border-dark-border/50" open>' +
|
|
1882
|
+
'<summary class="list-none cursor-pointer py-2 flex justify-between items-center" style="padding-left:' +
|
|
1883
|
+
padding +
|
|
1884
|
+
'px"><div class="flex items-center gap-2"><span class="text-xs opacity-70">\u25BE</span><code class="text-sm font-mono">' +
|
|
1885
|
+
name +
|
|
1886
|
+
'</code><span class="text-xs text-brand">' +
|
|
1887
|
+
requiredLabel +
|
|
1888
|
+
'</span></div><span class="text-xs font-mono opacity-60">' +
|
|
1889
|
+
type +
|
|
1890
|
+
"</span></summary>" +
|
|
1891
|
+
'<div class="pb-1">' +
|
|
1892
|
+
nested +
|
|
1893
|
+
"</div></details>"
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function renderRequestBodySchemaSection(schema) {
|
|
1898
|
+
if (!schema || typeof schema !== "object") return "";
|
|
1899
|
+
const rootChildren = buildSchemaChildren(schema);
|
|
1900
|
+
if (!rootChildren.length) return "";
|
|
1901
|
+
|
|
1902
|
+
let rows = "";
|
|
1903
|
+
for (const child of rootChildren) {
|
|
1904
|
+
rows += renderSchemaFieldNode(child, 0);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
return (
|
|
1908
|
+
'<div><h3 class="text-sm font-semibold mb-3 flex items-center border-b border-light-border dark:border-dark-border pb-2">Request Body</h3>' +
|
|
1909
|
+
rows +
|
|
1910
|
+
"</div>"
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function renderTryItParameterInputs(pathParams, queryParams) {
|
|
1915
|
+
const container = document.getElementById("request-param-inputs");
|
|
1916
|
+
if (!container || !selected) return;
|
|
1917
|
+
|
|
1918
|
+
const values = getParameterValues(selected);
|
|
1919
|
+
container.innerHTML = "";
|
|
1920
|
+
|
|
1921
|
+
const sections = [
|
|
1922
|
+
{ title: "Path Values", params: pathParams },
|
|
1923
|
+
{ title: "Query Values", params: queryParams },
|
|
1924
|
+
];
|
|
1925
|
+
|
|
1926
|
+
for (const section of sections) {
|
|
1927
|
+
if (!section.params.length) continue;
|
|
1928
|
+
|
|
1929
|
+
const group = document.createElement("div");
|
|
1930
|
+
group.className = "space-y-2";
|
|
1931
|
+
|
|
1932
|
+
const title = document.createElement("p");
|
|
1933
|
+
title.className = "text-xs font-semibold uppercase tracking-wider opacity-60";
|
|
1934
|
+
title.textContent = section.title;
|
|
1935
|
+
group.appendChild(title);
|
|
1936
|
+
|
|
1937
|
+
for (const param of section.params) {
|
|
1938
|
+
const field = document.createElement("div");
|
|
1939
|
+
field.className = "space-y-1";
|
|
1940
|
+
|
|
1941
|
+
const label = document.createElement("label");
|
|
1942
|
+
label.className = "text-xs opacity-80 flex items-center gap-2";
|
|
1943
|
+
|
|
1944
|
+
const labelName = document.createElement("span");
|
|
1945
|
+
labelName.className = "font-mono";
|
|
1946
|
+
labelName.textContent = param.name;
|
|
1947
|
+
|
|
1948
|
+
const required = document.createElement("span");
|
|
1949
|
+
required.className = "text-[10px] text-brand";
|
|
1950
|
+
required.textContent = param.required ? "required" : "optional";
|
|
1951
|
+
|
|
1952
|
+
label.appendChild(labelName);
|
|
1953
|
+
label.appendChild(required);
|
|
1954
|
+
|
|
1955
|
+
const input = document.createElement("input");
|
|
1956
|
+
input.type = "text";
|
|
1957
|
+
input.value = values[param.name] || "";
|
|
1958
|
+
input.placeholder =
|
|
1959
|
+
section.title === "Path Values" ? param.name : "optional";
|
|
1960
|
+
input.className =
|
|
1961
|
+
"w-full text-sm px-3 py-2 rounded border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg focus:outline-none focus:border-brand dark:focus:border-brand transition-colors font-mono";
|
|
1962
|
+
|
|
1963
|
+
input.addEventListener("input", () => {
|
|
1964
|
+
values[param.name] = input.value;
|
|
1965
|
+
updateRequestPreview();
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
field.appendChild(label);
|
|
1969
|
+
field.appendChild(input);
|
|
1970
|
+
group.appendChild(field);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
container.appendChild(group);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function renderHeaderInputs() {
|
|
1978
|
+
const container = document.getElementById("header-inputs");
|
|
1979
|
+
if (!container) return;
|
|
1980
|
+
|
|
1981
|
+
container.innerHTML = "";
|
|
1982
|
+
requestHeaders.forEach((entry, index) => {
|
|
1983
|
+
const row = document.createElement("div");
|
|
1984
|
+
row.className = "grid grid-cols-[1fr_1fr_auto] gap-2";
|
|
1985
|
+
|
|
1986
|
+
const keyInput = document.createElement("input");
|
|
1987
|
+
keyInput.type = "text";
|
|
1988
|
+
keyInput.value = entry.key || "";
|
|
1989
|
+
keyInput.placeholder = "Header";
|
|
1990
|
+
keyInput.className =
|
|
1991
|
+
"w-full text-xs px-2.5 py-2 rounded border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg focus:outline-none focus:border-brand dark:focus:border-brand transition-colors font-mono";
|
|
1992
|
+
keyInput.addEventListener("input", () => {
|
|
1993
|
+
entry.key = keyInput.value;
|
|
1994
|
+
updateRequestPreview();
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
const valueInput = document.createElement("input");
|
|
1998
|
+
valueInput.type = "text";
|
|
1999
|
+
valueInput.value = entry.value || "";
|
|
2000
|
+
valueInput.placeholder =
|
|
2001
|
+
String(entry.key || "").toLowerCase() === "authorization"
|
|
2002
|
+
? "Bearer token"
|
|
2003
|
+
: "Value";
|
|
2004
|
+
valueInput.className =
|
|
2005
|
+
"w-full text-xs px-2.5 py-2 rounded border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg focus:outline-none focus:border-brand dark:focus:border-brand transition-colors font-mono";
|
|
2006
|
+
valueInput.addEventListener("input", () => {
|
|
2007
|
+
entry.value = valueInput.value;
|
|
2008
|
+
updateRequestPreview();
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
const removeButton = document.createElement("button");
|
|
2012
|
+
removeButton.type = "button";
|
|
2013
|
+
removeButton.className =
|
|
2014
|
+
"p-1.5 rounded-full border border-light-border dark:border-dark-border bg-light-bg/90 dark:bg-dark-bg/90 opacity-90 hover:opacity-100 hover:border-brand/60 transition-colors";
|
|
2015
|
+
removeButton.setAttribute("aria-label", "Remove Header");
|
|
2016
|
+
removeButton.setAttribute("title", "Remove Header");
|
|
2017
|
+
removeButton.innerHTML =
|
|
2018
|
+
'<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 12h12"></path></svg>';
|
|
2019
|
+
removeButton.addEventListener("click", () => {
|
|
2020
|
+
requestHeaders.splice(index, 1);
|
|
2021
|
+
renderHeaderInputs();
|
|
2022
|
+
updateRequestPreview();
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
row.appendChild(keyInput);
|
|
2026
|
+
row.appendChild(valueInput);
|
|
2027
|
+
row.appendChild(removeButton);
|
|
2028
|
+
container.appendChild(row);
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function hasHeaderName(headers, expectedName) {
|
|
2033
|
+
const target = expectedName.toLowerCase();
|
|
2034
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function getRequestHeadersObject() {
|
|
2038
|
+
const headers = {};
|
|
2039
|
+
for (const entry of requestHeaders) {
|
|
2040
|
+
const key = String(entry.key || "").trim();
|
|
2041
|
+
const value = String(entry.value || "").trim();
|
|
2042
|
+
if (!key || !value) continue;
|
|
2043
|
+
headers[key] = value;
|
|
2044
|
+
}
|
|
2045
|
+
return headers;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function buildCurl(op, headers, body, requestPath) {
|
|
2049
|
+
const url = window.location.origin + requestPath;
|
|
2050
|
+
const lines = ['curl -X ' + op.method + ' "' + url + '"'];
|
|
2051
|
+
|
|
2052
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
2053
|
+
const safeName = String(name).replace(/"/g, '\\"');
|
|
2054
|
+
const safeValue = String(value).replace(/"/g, '\\"');
|
|
2055
|
+
lines.push(' -H "' + safeName + ": " + safeValue + '"');
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
if (body) {
|
|
2059
|
+
lines.push(" -d '" + body.replace(/'/g, "'\\\\''") + "'");
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
return lines.join(" \\\\\\n");
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function formatBodyJsonInput() {
|
|
2066
|
+
const bodyInput = document.getElementById("body-input");
|
|
2067
|
+
if (!bodyInput) return;
|
|
2068
|
+
const current = bodyInput.value.trim();
|
|
2069
|
+
if (!current) return;
|
|
2070
|
+
try {
|
|
2071
|
+
bodyInput.value = JSON.stringify(JSON.parse(current), null, 2);
|
|
2072
|
+
} catch {}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function escapeHtml(value) {
|
|
2076
|
+
return String(value)
|
|
2077
|
+
.replace(/&/g, "&")
|
|
2078
|
+
.replace(/</g, "<")
|
|
2079
|
+
.replace(/>/g, ">");
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function toPrettyJson(value) {
|
|
2083
|
+
const trimmed = (value || "").trim();
|
|
2084
|
+
if (!trimmed) return null;
|
|
2085
|
+
try {
|
|
2086
|
+
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
|
2087
|
+
} catch {
|
|
2088
|
+
return null;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
function highlightJson(jsonText) {
|
|
2093
|
+
const escaped = escapeHtml(jsonText);
|
|
2094
|
+
return escaped.replace(
|
|
2095
|
+
/("(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\"])*"(\\s*:)?|\\btrue\\b|\\bfalse\\b|\\bnull\\b|-?\\d+(?:\\.\\d+)?(?:[eE][+\\-]?\\d+)?)/g,
|
|
2096
|
+
(match) => {
|
|
2097
|
+
let cls = "json-number";
|
|
2098
|
+
if (match.startsWith('"')) {
|
|
2099
|
+
cls = match.endsWith(":") ? "json-key" : "json-string";
|
|
2100
|
+
} else if (match === "true" || match === "false") {
|
|
2101
|
+
cls = "json-boolean";
|
|
2102
|
+
} else if (match === "null") {
|
|
2103
|
+
cls = "json-null";
|
|
2104
|
+
}
|
|
2105
|
+
return '<span class="' + cls + '">' + match + "</span>";
|
|
2106
|
+
},
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
function updateBodyJsonPresentation() {
|
|
2111
|
+
const bodyInput = document.getElementById("body-input");
|
|
2112
|
+
const highlight = document.getElementById("body-highlight");
|
|
2113
|
+
const bodySection = document.getElementById("request-body-section");
|
|
2114
|
+
|
|
2115
|
+
if (!bodyInput || !highlight || !bodySection) return;
|
|
2116
|
+
if (bodySection.classList.contains("hidden")) {
|
|
2117
|
+
highlight.innerHTML = "";
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const raw = bodyInput.value || "";
|
|
2122
|
+
if (!raw.trim()) {
|
|
2123
|
+
const placeholder = bodyInput.getAttribute("placeholder") || "";
|
|
2124
|
+
highlight.innerHTML = '<span class="opacity-40">' + escapeHtml(placeholder) + "</span>";
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
const prettyJson = toPrettyJson(raw);
|
|
2129
|
+
if (!prettyJson) {
|
|
2130
|
+
highlight.innerHTML = escapeHtml(raw);
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
highlight.innerHTML = highlightJson(raw);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function syncBodyEditorScroll() {
|
|
2138
|
+
const bodyInput = document.getElementById("body-input");
|
|
2139
|
+
const highlight = document.getElementById("body-highlight");
|
|
2140
|
+
if (!bodyInput || !highlight) return;
|
|
2141
|
+
highlight.scrollTop = bodyInput.scrollTop;
|
|
2142
|
+
highlight.scrollLeft = bodyInput.scrollLeft;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
function updateExpandEditorPresentation() {
|
|
2146
|
+
const editor = document.getElementById("expand-editor");
|
|
2147
|
+
const highlight = document.getElementById("expand-editor-highlight");
|
|
2148
|
+
if (!editor || !highlight) return;
|
|
2149
|
+
const raw = editor.value || "";
|
|
2150
|
+
if (!raw.trim()) {
|
|
2151
|
+
highlight.innerHTML = "";
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
const prettyJson = toPrettyJson(raw);
|
|
2155
|
+
highlight.innerHTML = prettyJson ? highlightJson(raw) : escapeHtml(raw);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function syncExpandEditorScroll() {
|
|
2159
|
+
const editor = document.getElementById("expand-editor");
|
|
2160
|
+
const highlight = document.getElementById("expand-editor-highlight");
|
|
2161
|
+
if (!editor || !highlight) return;
|
|
2162
|
+
highlight.scrollTop = editor.scrollTop;
|
|
2163
|
+
highlight.scrollLeft = editor.scrollLeft;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function formatResponseText(responseText) {
|
|
2167
|
+
const trimmed = (responseText || "").trim();
|
|
2168
|
+
if (!trimmed) return { text: "(empty)", isJson: false };
|
|
2169
|
+
try {
|
|
2170
|
+
return {
|
|
2171
|
+
text: JSON.stringify(JSON.parse(trimmed), null, 2),
|
|
2172
|
+
isJson: true,
|
|
2173
|
+
};
|
|
2174
|
+
} catch {
|
|
2175
|
+
return {
|
|
2176
|
+
text: responseText,
|
|
2177
|
+
isJson: false,
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function setResponseContent(headerText, bodyText, isJson) {
|
|
2183
|
+
const result = document.getElementById("result");
|
|
2184
|
+
if (!result) return;
|
|
2185
|
+
const fullText = String(headerText || "") + String(bodyText || "");
|
|
2186
|
+
result.dataset.raw = fullText;
|
|
2187
|
+
result.dataset.header = String(headerText || "");
|
|
2188
|
+
result.dataset.body = String(bodyText || "");
|
|
2189
|
+
result.dataset.isJson = isJson ? "true" : "false";
|
|
2190
|
+
if (isJson) {
|
|
2191
|
+
result.innerHTML = escapeHtml(String(headerText || "")) + highlightJson(String(bodyText || ""));
|
|
2192
|
+
} else {
|
|
2193
|
+
result.textContent = fullText;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function updateRequestPreview() {
|
|
2198
|
+
if (!selected) return;
|
|
2199
|
+
|
|
2200
|
+
const { path, query } = getOperationParameterGroups(selected);
|
|
2201
|
+
const values = getParameterValues(selected);
|
|
2202
|
+
const requestPath = buildRequestPath(selected, path, query, values);
|
|
2203
|
+
const bodyInput = document.getElementById("body-input");
|
|
2204
|
+
const body = bodyInput ? bodyInput.value.trim() : "";
|
|
2205
|
+
const headers = getRequestHeadersObject();
|
|
2206
|
+
if (body && !hasHeaderName(headers, "Content-Type")) {
|
|
2207
|
+
headers["Content-Type"] = "application/json";
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
document.getElementById("endpoint-path").textContent = requestPath;
|
|
2211
|
+
document.getElementById("curl-code").textContent = buildCurl(
|
|
2212
|
+
selected,
|
|
2213
|
+
headers,
|
|
2214
|
+
body,
|
|
2215
|
+
requestPath,
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function renderEndpoint() {
|
|
2220
|
+
if (!selected) return;
|
|
2221
|
+
const endpointCard = document.getElementById("endpoint-card");
|
|
2222
|
+
if (endpointCard) {
|
|
2223
|
+
endpointCard.classList.remove("enter-fade-up");
|
|
2224
|
+
// Restart CSS animation for each operation switch
|
|
2225
|
+
void endpointCard.offsetWidth;
|
|
2226
|
+
endpointCard.classList.add("enter-fade-up");
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
const op = selected.operation || {};
|
|
2230
|
+
const reqSchema = op.requestBody && op.requestBody.content && op.requestBody.content["application/json"] && op.requestBody.content["application/json"].schema;
|
|
2231
|
+
const requestBodySection = document.getElementById("request-body-section");
|
|
2232
|
+
const bodyInput = document.getElementById("body-input");
|
|
2233
|
+
const expandBodyBtn = document.getElementById("expand-body-btn");
|
|
2234
|
+
const supportsBody = hasMeaningfulRequestBodySchema(reqSchema);
|
|
2235
|
+
|
|
2236
|
+
if (requestBodySection) {
|
|
2237
|
+
requestBodySection.classList.toggle("hidden", !supportsBody);
|
|
2238
|
+
}
|
|
2239
|
+
if (supportsBody && bodyInput) {
|
|
2240
|
+
const existingDraft = getBodyDraft(selected);
|
|
2241
|
+
if (typeof existingDraft === "string") {
|
|
2242
|
+
bodyInput.value = existingDraft;
|
|
2243
|
+
} else {
|
|
2244
|
+
const prefill = buildRequiredBodyPrefill(reqSchema);
|
|
2245
|
+
bodyInput.value = prefill;
|
|
2246
|
+
setBodyDraft(selected, prefill);
|
|
2247
|
+
}
|
|
2248
|
+
} else if (!supportsBody && bodyInput) {
|
|
2249
|
+
bodyInput.value = "";
|
|
2250
|
+
}
|
|
2251
|
+
if (expandBodyBtn) {
|
|
2252
|
+
expandBodyBtn.disabled = !supportsBody;
|
|
2253
|
+
}
|
|
2254
|
+
setResponseContent("", "", false);
|
|
2255
|
+
|
|
2256
|
+
document.getElementById("tag-title").textContent = selected.tag;
|
|
2257
|
+
document.getElementById("tag-description").textContent = op.description || "Interactive API documentation.";
|
|
2258
|
+
const methodNode = document.getElementById("endpoint-method");
|
|
2259
|
+
methodNode.textContent = selected.method;
|
|
2260
|
+
methodNode.className = "px-2.5 py-0.5 rounded-full text-xs font-mono font-medium " + (methodBadge[selected.method] || "bg-zinc-100 text-zinc-700 dark:bg-zinc-500/10 dark:text-zinc-400");
|
|
2261
|
+
document.getElementById("endpoint-title").textContent = selected.name;
|
|
2262
|
+
document.getElementById("endpoint-path").textContent = selected.path;
|
|
2263
|
+
|
|
2264
|
+
const { all: params, query, path, headers } =
|
|
2265
|
+
getOperationParameterGroups(selected);
|
|
2266
|
+
|
|
2267
|
+
let html = "";
|
|
2268
|
+
html += renderParamSection("Path Parameters", path);
|
|
2269
|
+
html += renderParamSection("Query Parameters", query);
|
|
2270
|
+
html += renderParamSection("Header Parameters", headers);
|
|
2271
|
+
|
|
2272
|
+
html += renderRequestBodySchemaSection(reqSchema);
|
|
2273
|
+
document.getElementById("params-column").innerHTML = html || '<div class="text-sm opacity-70">No parameters</div>';
|
|
2274
|
+
renderTryItParameterInputs(path, query);
|
|
2275
|
+
renderHeaderInputs();
|
|
2276
|
+
updateRequestPreview();
|
|
2277
|
+
updateBodyJsonPresentation();
|
|
2278
|
+
syncBodyEditorScroll();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
document.getElementById("copy-curl").addEventListener("click", async () => {
|
|
2282
|
+
try { await navigator.clipboard.writeText(document.getElementById("curl-code").textContent || ""); } catch {}
|
|
2283
|
+
});
|
|
2284
|
+
document.getElementById("sidebar-search").addEventListener("input", (event) => {
|
|
2285
|
+
sidebarSearchQuery = event.currentTarget.value || "";
|
|
2286
|
+
renderSidebar();
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
document.getElementById("send-btn").addEventListener("click", async () => {
|
|
2290
|
+
if (!selected) return;
|
|
2291
|
+
const { path, query } = getOperationParameterGroups(selected);
|
|
2292
|
+
const values = getParameterValues(selected);
|
|
2293
|
+
const missingPathParams = path.filter((param) => {
|
|
2294
|
+
if (param.required === false) return false;
|
|
2295
|
+
const value = values[param.name];
|
|
2296
|
+
return value === undefined || value === null || String(value).trim() === "";
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
if (missingPathParams.length > 0) {
|
|
2300
|
+
setResponseContent(
|
|
2301
|
+
"",
|
|
2302
|
+
"Missing required path parameter(s): " +
|
|
2303
|
+
missingPathParams.map((param) => param.name).join(", "),
|
|
2304
|
+
false,
|
|
2305
|
+
);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
const requestPath = buildRequestPath(selected, path, query, values);
|
|
2310
|
+
formatBodyJsonInput();
|
|
2311
|
+
updateBodyJsonPresentation();
|
|
2312
|
+
const op = selected.operation || {};
|
|
2313
|
+
const reqSchema = op.requestBody && op.requestBody.content && op.requestBody.content["application/json"] && op.requestBody.content["application/json"].schema;
|
|
2314
|
+
const supportsBody = hasMeaningfulRequestBodySchema(reqSchema);
|
|
2315
|
+
const bodyInput = document.getElementById("body-input");
|
|
2316
|
+
const body =
|
|
2317
|
+
supportsBody && bodyInput ? bodyInput.value.trim() : "";
|
|
2318
|
+
const headers = getRequestHeadersObject();
|
|
2319
|
+
if (body && !hasHeaderName(headers, "Content-Type")) {
|
|
2320
|
+
headers["Content-Type"] = "application/json";
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
try {
|
|
2324
|
+
const response = await fetch(requestPath, { method: selected.method, headers, body: body || undefined });
|
|
2325
|
+
const text = await response.text();
|
|
2326
|
+
const contentType = response.headers.get("content-type") || "unknown";
|
|
2327
|
+
const formattedResponse = formatResponseText(text);
|
|
2328
|
+
const headerText =
|
|
2329
|
+
"Status: " + response.status + " " + response.statusText + "\\n" +
|
|
2330
|
+
"Content-Type: " + contentType + "\\n\\n";
|
|
2331
|
+
setResponseContent(
|
|
2332
|
+
headerText,
|
|
2333
|
+
formattedResponse.text,
|
|
2334
|
+
formattedResponse.isJson,
|
|
2335
|
+
);
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
setResponseContent("", "Request failed: " + String(error), false);
|
|
2338
|
+
}
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
function openExpandModal(mode) {
|
|
2342
|
+
const modal = document.getElementById("expand-modal");
|
|
2343
|
+
const title = document.getElementById("expand-modal-title");
|
|
2344
|
+
const editorShell = document.getElementById("expand-editor-shell");
|
|
2345
|
+
const editor = document.getElementById("expand-editor");
|
|
2346
|
+
const viewer = document.getElementById("expand-viewer");
|
|
2347
|
+
const apply = document.getElementById("expand-apply");
|
|
2348
|
+
const bodyInput = document.getElementById("body-input");
|
|
2349
|
+
const result = document.getElementById("result");
|
|
2350
|
+
if (!modal || !title || !editorShell || !editor || !viewer || !apply) return;
|
|
2351
|
+
|
|
2352
|
+
expandModalMode = mode;
|
|
2353
|
+
modal.classList.remove("hidden");
|
|
2354
|
+
modal.classList.add("flex");
|
|
2355
|
+
|
|
2356
|
+
if (mode === "body") {
|
|
2357
|
+
title.textContent = "Request Body";
|
|
2358
|
+
editorShell.classList.remove("hidden");
|
|
2359
|
+
viewer.classList.add("hidden");
|
|
2360
|
+
apply.classList.remove("hidden");
|
|
2361
|
+
editor.value = bodyInput ? bodyInput.value : "";
|
|
2362
|
+
updateExpandEditorPresentation();
|
|
2363
|
+
syncExpandEditorScroll();
|
|
2364
|
+
} else {
|
|
2365
|
+
title.textContent = "Response";
|
|
2366
|
+
viewer.classList.remove("hidden");
|
|
2367
|
+
editorShell.classList.add("hidden");
|
|
2368
|
+
apply.classList.add("hidden");
|
|
2369
|
+
const hasResponse = Boolean(result && result.dataset && result.dataset.raw);
|
|
2370
|
+
if (!hasResponse) {
|
|
2371
|
+
viewer.textContent = "(empty response yet)";
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const header = result.dataset.header || "";
|
|
2376
|
+
const body = result.dataset.body || "";
|
|
2377
|
+
const isJson = result.dataset.isJson === "true";
|
|
2378
|
+
if (isJson) {
|
|
2379
|
+
viewer.innerHTML = escapeHtml(header) + highlightJson(body);
|
|
2380
|
+
} else {
|
|
2381
|
+
viewer.textContent = result.dataset.raw || "(empty response yet)";
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function closeExpandModal() {
|
|
2387
|
+
const modal = document.getElementById("expand-modal");
|
|
2388
|
+
if (!modal) return;
|
|
2389
|
+
modal.classList.add("hidden");
|
|
2390
|
+
modal.classList.remove("flex");
|
|
2391
|
+
expandModalMode = null;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
document.getElementById("add-header-btn").addEventListener("click", () => {
|
|
2395
|
+
requestHeaders.push({ key: "", value: "" });
|
|
2396
|
+
renderHeaderInputs();
|
|
2397
|
+
updateRequestPreview();
|
|
2398
|
+
});
|
|
2399
|
+
document.getElementById("body-input").addEventListener("input", () => {
|
|
2400
|
+
if (selected) {
|
|
2401
|
+
setBodyDraft(selected, document.getElementById("body-input").value);
|
|
2402
|
+
}
|
|
2403
|
+
updateRequestPreview();
|
|
2404
|
+
updateBodyJsonPresentation();
|
|
2405
|
+
syncBodyEditorScroll();
|
|
2406
|
+
});
|
|
2407
|
+
document.getElementById("body-input").addEventListener("scroll", () => {
|
|
2408
|
+
syncBodyEditorScroll();
|
|
2409
|
+
});
|
|
2410
|
+
document.getElementById("body-input").addEventListener("keydown", (event) => {
|
|
2411
|
+
if (event.key !== "Tab") return;
|
|
2412
|
+
event.preventDefault();
|
|
2413
|
+
const input = event.currentTarget;
|
|
2414
|
+
const start = input.selectionStart;
|
|
2415
|
+
const end = input.selectionEnd;
|
|
2416
|
+
const value = input.value;
|
|
2417
|
+
const tab = " ";
|
|
2418
|
+
input.value = value.slice(0, start) + tab + value.slice(end);
|
|
2419
|
+
input.selectionStart = input.selectionEnd = start + tab.length;
|
|
2420
|
+
if (selected) {
|
|
2421
|
+
setBodyDraft(selected, input.value);
|
|
2422
|
+
}
|
|
2423
|
+
updateRequestPreview();
|
|
2424
|
+
updateBodyJsonPresentation();
|
|
2425
|
+
syncBodyEditorScroll();
|
|
2426
|
+
});
|
|
2427
|
+
document.getElementById("body-input").addEventListener("blur", () => {
|
|
2428
|
+
formatBodyJsonInput();
|
|
2429
|
+
if (selected) {
|
|
2430
|
+
setBodyDraft(selected, document.getElementById("body-input").value);
|
|
2431
|
+
}
|
|
2432
|
+
updateRequestPreview();
|
|
2433
|
+
updateBodyJsonPresentation();
|
|
2434
|
+
syncBodyEditorScroll();
|
|
2435
|
+
});
|
|
2436
|
+
document.getElementById("expand-editor").addEventListener("input", () => {
|
|
2437
|
+
updateExpandEditorPresentation();
|
|
2438
|
+
syncExpandEditorScroll();
|
|
2439
|
+
});
|
|
2440
|
+
document.getElementById("expand-editor").addEventListener("scroll", () => {
|
|
2441
|
+
syncExpandEditorScroll();
|
|
2442
|
+
});
|
|
2443
|
+
document.getElementById("expand-editor").addEventListener("keydown", (event) => {
|
|
2444
|
+
if (event.key !== "Tab") return;
|
|
2445
|
+
event.preventDefault();
|
|
2446
|
+
const editor = event.currentTarget;
|
|
2447
|
+
const start = editor.selectionStart;
|
|
2448
|
+
const end = editor.selectionEnd;
|
|
2449
|
+
const value = editor.value;
|
|
2450
|
+
const tab = " ";
|
|
2451
|
+
editor.value = value.slice(0, start) + tab + value.slice(end);
|
|
2452
|
+
editor.selectionStart = editor.selectionEnd = start + tab.length;
|
|
2453
|
+
updateExpandEditorPresentation();
|
|
2454
|
+
syncExpandEditorScroll();
|
|
2455
|
+
});
|
|
2456
|
+
document.getElementById("expand-editor").addEventListener("blur", () => {
|
|
2457
|
+
const editor = document.getElementById("expand-editor");
|
|
2458
|
+
const current = editor.value.trim();
|
|
2459
|
+
if (current) {
|
|
2460
|
+
try {
|
|
2461
|
+
editor.value = JSON.stringify(JSON.parse(current), null, 2);
|
|
2462
|
+
} catch {}
|
|
2463
|
+
}
|
|
2464
|
+
updateExpandEditorPresentation();
|
|
2465
|
+
syncExpandEditorScroll();
|
|
2466
|
+
});
|
|
2467
|
+
document.getElementById("expand-body-btn").addEventListener("click", () => {
|
|
2468
|
+
openExpandModal("body");
|
|
2469
|
+
});
|
|
2470
|
+
document.getElementById("expand-response-btn").addEventListener("click", () => {
|
|
2471
|
+
openExpandModal("response");
|
|
2472
|
+
});
|
|
2473
|
+
document.getElementById("expand-close").addEventListener("click", closeExpandModal);
|
|
2474
|
+
document.getElementById("expand-apply").addEventListener("click", () => {
|
|
2475
|
+
if (expandModalMode !== "body") {
|
|
2476
|
+
closeExpandModal();
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
const editor = document.getElementById("expand-editor");
|
|
2481
|
+
const bodyInput = document.getElementById("body-input");
|
|
2482
|
+
if (editor && bodyInput) {
|
|
2483
|
+
bodyInput.value = editor.value;
|
|
2484
|
+
formatBodyJsonInput();
|
|
2485
|
+
if (selected) {
|
|
2486
|
+
setBodyDraft(selected, bodyInput.value);
|
|
2487
|
+
}
|
|
2488
|
+
updateRequestPreview();
|
|
2489
|
+
updateBodyJsonPresentation();
|
|
2490
|
+
syncBodyEditorScroll();
|
|
2491
|
+
}
|
|
2492
|
+
closeExpandModal();
|
|
2493
|
+
});
|
|
2494
|
+
document.getElementById("expand-modal").addEventListener("click", (event) => {
|
|
2495
|
+
if (event.target === event.currentTarget) {
|
|
2496
|
+
closeExpandModal();
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
document.getElementById("sidebar-open").addEventListener("click", () => {
|
|
2500
|
+
setMobileSidebarOpen(true);
|
|
2501
|
+
});
|
|
2502
|
+
document.getElementById("sidebar-close").addEventListener("click", () => {
|
|
2503
|
+
setMobileSidebarOpen(false);
|
|
2504
|
+
});
|
|
2505
|
+
document.getElementById("mobile-backdrop").addEventListener("click", () => {
|
|
2506
|
+
setMobileSidebarOpen(false);
|
|
2507
|
+
});
|
|
2508
|
+
window.addEventListener("resize", () => {
|
|
2509
|
+
if (window.innerWidth >= 768 && isMobileSidebarOpen) {
|
|
2510
|
+
setMobileSidebarOpen(false);
|
|
2511
|
+
}
|
|
2512
|
+
});
|
|
2513
|
+
document.addEventListener("keydown", (event) => {
|
|
2514
|
+
if (event.key === "Escape") {
|
|
2515
|
+
if (isMobileSidebarOpen) {
|
|
2516
|
+
setMobileSidebarOpen(false);
|
|
2517
|
+
}
|
|
2518
|
+
closeExpandModal();
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2522
|
+
const themeToggleBtn = document.getElementById('theme-toggle');
|
|
2523
|
+
const htmlElement = document.documentElement;
|
|
2524
|
+
themeToggleBtn.addEventListener('click', () => {
|
|
2525
|
+
htmlElement.classList.toggle('dark');
|
|
2526
|
+
if (htmlElement.classList.contains('dark')) {
|
|
2527
|
+
localStorage.setItem('theme', 'dark');
|
|
2528
|
+
} else {
|
|
2529
|
+
localStorage.setItem('theme', 'light');
|
|
630
2530
|
}
|
|
2531
|
+
});
|
|
2532
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
|
2533
|
+
if (!('theme' in localStorage)) {
|
|
2534
|
+
if (e.matches) htmlElement.classList.add('dark');
|
|
2535
|
+
else htmlElement.classList.remove('dark');
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
setMobileSidebarOpen(false);
|
|
2540
|
+
renderSidebar();
|
|
2541
|
+
renderEndpoint();
|
|
2542
|
+
</script>
|
|
2543
|
+
</body>
|
|
2544
|
+
</html>`;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// src/openapi/generator.ts
|
|
2548
|
+
function isJSONSchemaCapable(schema) {
|
|
2549
|
+
const standard = schema?.["~standard"];
|
|
2550
|
+
const converter = standard?.jsonSchema;
|
|
2551
|
+
return !!standard && typeof standard === "object" && standard.version === 1 && !!converter && typeof converter.input === "function" && typeof converter.output === "function";
|
|
2552
|
+
}
|
|
2553
|
+
function normalizeRoutePathForOpenAPI(path) {
|
|
2554
|
+
let wildcardCount = 0;
|
|
2555
|
+
const pathParamNames = [];
|
|
2556
|
+
const segments = path.split("/").map((segment) => {
|
|
2557
|
+
const greedyParamMatch = /^:([A-Za-z0-9_]+)\+$/.exec(segment);
|
|
2558
|
+
if (greedyParamMatch?.[1]) {
|
|
2559
|
+
pathParamNames.push(greedyParamMatch[1]);
|
|
2560
|
+
return `{${greedyParamMatch[1]}}`;
|
|
631
2561
|
}
|
|
632
|
-
|
|
633
|
-
if (
|
|
634
|
-
|
|
2562
|
+
const paramMatch = /^:([A-Za-z0-9_]+)$/.exec(segment);
|
|
2563
|
+
if (paramMatch?.[1]) {
|
|
2564
|
+
pathParamNames.push(paramMatch[1]);
|
|
2565
|
+
return `{${paramMatch[1]}}`;
|
|
2566
|
+
}
|
|
2567
|
+
if (segment === "*") {
|
|
2568
|
+
wildcardCount += 1;
|
|
2569
|
+
const wildcardParamName = wildcardCount === 1 ? "wildcard" : `wildcard${wildcardCount}`;
|
|
2570
|
+
pathParamNames.push(wildcardParamName);
|
|
2571
|
+
return `{${wildcardParamName}}`;
|
|
2572
|
+
}
|
|
2573
|
+
return segment;
|
|
2574
|
+
});
|
|
2575
|
+
return {
|
|
2576
|
+
openapiPath: segments.join("/"),
|
|
2577
|
+
pathParamNames
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
function toOpenAPIPath(path) {
|
|
2581
|
+
return normalizeRoutePathForOpenAPI(path).openapiPath;
|
|
2582
|
+
}
|
|
2583
|
+
function createOperationId(method, path) {
|
|
2584
|
+
const normalized = `${method.toLowerCase()}_${path}`.replace(/[:{}]/g, "").replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
|
|
2585
|
+
return normalized || `${method.toLowerCase()}_operation`;
|
|
2586
|
+
}
|
|
2587
|
+
function inferTagFromPath(path) {
|
|
2588
|
+
const segments = path.split("/").filter(Boolean);
|
|
2589
|
+
for (const segment of segments) {
|
|
2590
|
+
if (!segment.startsWith(":") && segment !== "*") {
|
|
2591
|
+
return segment.toLowerCase();
|
|
635
2592
|
}
|
|
636
|
-
return score;
|
|
637
|
-
}
|
|
638
|
-
isStaticSegment(segment) {
|
|
639
|
-
return !segment.startsWith(":") && !segment.includes("*");
|
|
640
|
-
}
|
|
641
|
-
isParamSegment(segment) {
|
|
642
|
-
return segment.startsWith(":");
|
|
643
2593
|
}
|
|
644
|
-
|
|
645
|
-
|
|
2594
|
+
return "default";
|
|
2595
|
+
}
|
|
2596
|
+
function extractPathParamNames(path) {
|
|
2597
|
+
return normalizeRoutePathForOpenAPI(path).pathParamNames;
|
|
2598
|
+
}
|
|
2599
|
+
function addMissingPathParameters(operation, routePath) {
|
|
2600
|
+
const existingPathNames = new Set((operation.parameters || []).filter((p) => p.in === "path").map((p) => String(p.name)));
|
|
2601
|
+
for (const pathName of extractPathParamNames(routePath)) {
|
|
2602
|
+
if (existingPathNames.has(pathName))
|
|
2603
|
+
continue;
|
|
2604
|
+
(operation.parameters ||= []).push({
|
|
2605
|
+
name: pathName,
|
|
2606
|
+
in: "path",
|
|
2607
|
+
required: true,
|
|
2608
|
+
schema: { type: "string" }
|
|
2609
|
+
});
|
|
646
2610
|
}
|
|
647
|
-
|
|
648
|
-
|
|
2611
|
+
}
|
|
2612
|
+
function isNoBodyResponseStatus(status) {
|
|
2613
|
+
const numericStatus = Number(status);
|
|
2614
|
+
if (!Number.isInteger(numericStatus))
|
|
2615
|
+
return false;
|
|
2616
|
+
return numericStatus >= 100 && numericStatus < 200 || numericStatus === 204 || numericStatus === 205 || numericStatus === 304;
|
|
2617
|
+
}
|
|
2618
|
+
function getResponseDescription(status) {
|
|
2619
|
+
if (status === "204")
|
|
2620
|
+
return "No Content";
|
|
2621
|
+
if (status === "205")
|
|
2622
|
+
return "Reset Content";
|
|
2623
|
+
if (status === "304")
|
|
2624
|
+
return "Not Modified";
|
|
2625
|
+
const numericStatus = Number(status);
|
|
2626
|
+
if (Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus < 200) {
|
|
2627
|
+
return "Informational";
|
|
2628
|
+
}
|
|
2629
|
+
return "OK";
|
|
2630
|
+
}
|
|
2631
|
+
function convertInputSchema(routePath, inputSchema, target, warnings) {
|
|
2632
|
+
if (!isJSONSchemaCapable(inputSchema)) {
|
|
2633
|
+
return null;
|
|
649
2634
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const scoreB = this.getRouteSpecificity(pathB);
|
|
656
|
-
return scoreB - scoreA;
|
|
657
|
-
});
|
|
2635
|
+
try {
|
|
2636
|
+
return inputSchema["~standard"].jsonSchema.input({ target });
|
|
2637
|
+
} catch (error) {
|
|
2638
|
+
warnings.push(`[OpenAPI] Failed input schema conversion for ${routePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2639
|
+
return null;
|
|
658
2640
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
2641
|
+
}
|
|
2642
|
+
function convertOutputSchema(routePath, statusCode, outputSchema, target, warnings) {
|
|
2643
|
+
if (!isJSONSchemaCapable(outputSchema)) {
|
|
2644
|
+
return null;
|
|
662
2645
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
[wrappedHandler],
|
|
669
|
-
options.path
|
|
670
|
-
];
|
|
671
|
-
this.routes.push(routeEntry);
|
|
672
|
-
this.sortRoutes();
|
|
673
|
-
return routeEntry;
|
|
674
|
-
}
|
|
675
|
-
createRouteRegex(path) {
|
|
676
|
-
return RegExp(`^${path.replace(/\/+(\/|$)/g, "$1").replace(/(\/?\.?):(\w+)\+/g, "($1(?<$2>*))").replace(/(\/?\.?):(\w+)/g, "($1(?<$2>[^$1/]+?))").replace(/\./g, "\\.").replace(/(\/?)\*/g, "($1.*)?")}/*$`);
|
|
2646
|
+
try {
|
|
2647
|
+
return outputSchema["~standard"].jsonSchema.output({ target });
|
|
2648
|
+
} catch (error) {
|
|
2649
|
+
warnings.push(`[OpenAPI] Failed output schema conversion for ${routePath} (${statusCode}): ${error instanceof Error ? error.message : String(error)}`);
|
|
2650
|
+
return null;
|
|
677
2651
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
if (!request.query && request.url) {
|
|
692
|
-
const url = new URL(request.url);
|
|
693
|
-
const query = {};
|
|
694
|
-
for (const [key, value] of url.searchParams) {
|
|
695
|
-
if (key in query) {
|
|
696
|
-
if (Array.isArray(query[key])) {
|
|
697
|
-
query[key].push(value);
|
|
698
|
-
} else {
|
|
699
|
-
query[key] = [query[key], value];
|
|
700
|
-
}
|
|
701
|
-
} else {
|
|
702
|
-
query[key] = value;
|
|
2652
|
+
}
|
|
2653
|
+
function isRecord(value) {
|
|
2654
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2655
|
+
}
|
|
2656
|
+
function addStructuredInputToOperation(operation, inputJSONSchema) {
|
|
2657
|
+
if (!isRecord(inputJSONSchema))
|
|
2658
|
+
return;
|
|
2659
|
+
if (inputJSONSchema.type !== "object" || !isRecord(inputJSONSchema.properties)) {
|
|
2660
|
+
operation.requestBody = {
|
|
2661
|
+
required: true,
|
|
2662
|
+
content: {
|
|
2663
|
+
"application/json": {
|
|
2664
|
+
schema: inputJSONSchema
|
|
703
2665
|
}
|
|
704
2666
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
2667
|
+
};
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
const rootRequired = new Set(Array.isArray(inputJSONSchema.required) ? inputJSONSchema.required : []);
|
|
2671
|
+
const properties = inputJSONSchema.properties;
|
|
2672
|
+
const parameters = Array.isArray(operation.parameters) ? operation.parameters : [];
|
|
2673
|
+
const parameterSections = [
|
|
2674
|
+
{ key: "params", in: "path" },
|
|
2675
|
+
{ key: "query", in: "query" },
|
|
2676
|
+
{ key: "headers", in: "header" },
|
|
2677
|
+
{ key: "cookies", in: "cookie" }
|
|
2678
|
+
];
|
|
2679
|
+
for (const section of parameterSections) {
|
|
2680
|
+
const sectionSchema = properties[section.key];
|
|
2681
|
+
if (!isRecord(sectionSchema))
|
|
2682
|
+
continue;
|
|
2683
|
+
if (sectionSchema.type !== "object" || !isRecord(sectionSchema.properties))
|
|
2684
|
+
continue;
|
|
2685
|
+
const sectionRequired = new Set(Array.isArray(sectionSchema.required) ? sectionSchema.required : []);
|
|
2686
|
+
for (const [name, schema] of Object.entries(sectionSchema.properties)) {
|
|
2687
|
+
parameters.push({
|
|
2688
|
+
name,
|
|
2689
|
+
in: section.in,
|
|
2690
|
+
required: section.in === "path" ? true : sectionRequired.has(name),
|
|
2691
|
+
schema: isRecord(schema) ? schema : {}
|
|
2692
|
+
});
|
|
709
2693
|
}
|
|
710
2694
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
2695
|
+
if (parameters.length > 0) {
|
|
2696
|
+
const deduped = new Map;
|
|
2697
|
+
for (const parameter of parameters) {
|
|
2698
|
+
deduped.set(`${parameter.in}:${parameter.name}`, parameter);
|
|
2699
|
+
}
|
|
2700
|
+
operation.parameters = [...deduped.values()];
|
|
2701
|
+
}
|
|
2702
|
+
const bodySchema = properties.body;
|
|
2703
|
+
if (bodySchema) {
|
|
2704
|
+
operation.requestBody = {
|
|
2705
|
+
required: rootRequired.has("body"),
|
|
2706
|
+
content: {
|
|
2707
|
+
"application/json": {
|
|
2708
|
+
schema: isRecord(bodySchema) ? bodySchema : {}
|
|
725
2709
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
2710
|
+
}
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
function addOutputSchemasToOperation(operation, routePath, routeSchema, target, warnings) {
|
|
2715
|
+
const output = routeSchema.output;
|
|
2716
|
+
if (!output) {
|
|
2717
|
+
operation.responses = {
|
|
2718
|
+
200: { description: "OK" }
|
|
2719
|
+
};
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
const responses = {};
|
|
2723
|
+
if (typeof output === "object" && output !== null && "~standard" in output) {
|
|
2724
|
+
const outputSchema = convertOutputSchema(routePath, "200", output, target, warnings);
|
|
2725
|
+
if (outputSchema) {
|
|
2726
|
+
responses["200"] = {
|
|
2727
|
+
description: "OK",
|
|
2728
|
+
content: {
|
|
2729
|
+
"application/json": {
|
|
2730
|
+
schema: outputSchema
|
|
732
2731
|
}
|
|
733
2732
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
2733
|
+
};
|
|
2734
|
+
} else {
|
|
2735
|
+
responses["200"] = { description: "OK" };
|
|
2736
|
+
}
|
|
2737
|
+
} else {
|
|
2738
|
+
for (const [statusCode, schema] of Object.entries(output)) {
|
|
2739
|
+
const status = String(statusCode);
|
|
2740
|
+
const outputSchema = convertOutputSchema(routePath, status, schema, target, warnings);
|
|
2741
|
+
const description = getResponseDescription(status);
|
|
2742
|
+
if (outputSchema && !isNoBodyResponseStatus(status)) {
|
|
2743
|
+
responses[status] = {
|
|
2744
|
+
description,
|
|
2745
|
+
content: {
|
|
2746
|
+
"application/json": {
|
|
2747
|
+
schema: outputSchema
|
|
745
2748
|
}
|
|
746
|
-
} catch {
|
|
747
|
-
request.content = null;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
let result;
|
|
751
|
-
const cacheOptions = options.cache;
|
|
752
|
-
const cacheFactory = async () => {
|
|
753
|
-
const res = await handler(request);
|
|
754
|
-
if (res instanceof Response) {
|
|
755
|
-
return {
|
|
756
|
-
_isResponse: true,
|
|
757
|
-
body: await res.text(),
|
|
758
|
-
status: res.status,
|
|
759
|
-
headers: Object.fromEntries(res.headers.entries())
|
|
760
|
-
};
|
|
761
2749
|
}
|
|
762
|
-
return res;
|
|
763
2750
|
};
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
result = await this.cacheManager.get(cacheKey, cacheFactory, cacheOptions);
|
|
769
|
-
} else if (cacheOptions && typeof cacheOptions === "object" && cacheOptions.ttl) {
|
|
770
|
-
const cacheKey = cacheOptions.key || this.cacheManager.generateKey(request, {
|
|
771
|
-
authUser: request.authUser
|
|
772
|
-
});
|
|
773
|
-
result = await this.cacheManager.get(cacheKey, cacheFactory, cacheOptions.ttl);
|
|
774
|
-
} else {
|
|
775
|
-
result = await handler(request);
|
|
776
|
-
}
|
|
777
|
-
if (result && typeof result === "object" && result._isResponse === true) {
|
|
778
|
-
result = new Response(result.body, {
|
|
779
|
-
status: result.status,
|
|
780
|
-
headers: result.headers
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
let response;
|
|
784
|
-
if (options.rawResponse || result instanceof Response) {
|
|
785
|
-
response = result instanceof Response ? result : new Response(result);
|
|
786
|
-
} else {
|
|
787
|
-
response = createResponse(200, result, options.responseContentType);
|
|
788
|
-
}
|
|
789
|
-
response = await this.middlewareManager.executeFinally(response, request);
|
|
790
|
-
return response;
|
|
791
|
-
} catch (error) {
|
|
792
|
-
if (error instanceof Response) {
|
|
793
|
-
return error;
|
|
794
|
-
}
|
|
795
|
-
console.error("Route handler error:", error);
|
|
796
|
-
return APIError.internalServerError(error instanceof Error ? error.message : String(error), options.responseContentType);
|
|
2751
|
+
} else {
|
|
2752
|
+
responses[status] = {
|
|
2753
|
+
description
|
|
2754
|
+
};
|
|
797
2755
|
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
addRoute(routeEntry) {
|
|
801
|
-
this.routes.push(routeEntry);
|
|
802
|
-
this.sortRoutes();
|
|
2756
|
+
}
|
|
803
2757
|
}
|
|
804
|
-
|
|
805
|
-
|
|
2758
|
+
if (Object.keys(responses).length === 0) {
|
|
2759
|
+
responses["200"] = { description: "OK" };
|
|
806
2760
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
2761
|
+
operation.responses = responses;
|
|
2762
|
+
}
|
|
2763
|
+
function generateOpenAPIDocument(routes, options) {
|
|
2764
|
+
const warnings = [];
|
|
2765
|
+
const paths = {};
|
|
2766
|
+
for (const route of routes) {
|
|
2767
|
+
if (route.options.expose === false)
|
|
2768
|
+
continue;
|
|
2769
|
+
if (!route.method || !route.path)
|
|
2770
|
+
continue;
|
|
2771
|
+
const method = route.method.toLowerCase();
|
|
2772
|
+
if (method === "options")
|
|
2773
|
+
continue;
|
|
2774
|
+
const openapiPath = toOpenAPIPath(route.path);
|
|
2775
|
+
const operation = {
|
|
2776
|
+
operationId: createOperationId(method, openapiPath),
|
|
2777
|
+
tags: [route.options.schema?.tag || inferTagFromPath(route.path)]
|
|
2778
|
+
};
|
|
2779
|
+
const inputJSONSchema = convertInputSchema(route.path, route.options.schema?.input, options.target, warnings);
|
|
2780
|
+
if (inputJSONSchema) {
|
|
2781
|
+
addStructuredInputToOperation(operation, inputJSONSchema);
|
|
826
2782
|
}
|
|
827
|
-
|
|
2783
|
+
addMissingPathParameters(operation, route.path);
|
|
2784
|
+
addOutputSchemasToOperation(operation, route.path, route.options.schema || {}, options.target, warnings);
|
|
2785
|
+
paths[openapiPath] ||= {};
|
|
2786
|
+
paths[openapiPath][method] = operation;
|
|
2787
|
+
}
|
|
2788
|
+
const openapiVersion = options.target === "openapi-3.0" ? "3.0.3" : "3.1.0";
|
|
2789
|
+
const document = {
|
|
2790
|
+
openapi: openapiVersion,
|
|
2791
|
+
info: {
|
|
2792
|
+
title: options.info?.title || "Vector API",
|
|
2793
|
+
version: options.info?.version || "1.0.0",
|
|
2794
|
+
...options.info?.description ? { description: options.info.description } : {}
|
|
2795
|
+
},
|
|
2796
|
+
paths
|
|
2797
|
+
};
|
|
2798
|
+
return {
|
|
2799
|
+
document,
|
|
2800
|
+
warnings
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// src/core/server.ts
|
|
2805
|
+
var OPENAPI_TAILWIND_ASSET_PATH = "/_vector/openapi/tailwindcdn.js";
|
|
2806
|
+
var OPENAPI_TAILWIND_ASSET_RELATIVE_CANDIDATES = [
|
|
2807
|
+
"../openapi/assets/tailwindcdn.js",
|
|
2808
|
+
"../src/openapi/assets/tailwindcdn.js",
|
|
2809
|
+
"../../src/openapi/assets/tailwindcdn.js"
|
|
2810
|
+
];
|
|
2811
|
+
var OPENAPI_TAILWIND_ASSET_CWD_CANDIDATES = [
|
|
2812
|
+
"src/openapi/assets/tailwindcdn.js",
|
|
2813
|
+
"openapi/assets/tailwindcdn.js",
|
|
2814
|
+
"dist/openapi/assets/tailwindcdn.js"
|
|
2815
|
+
];
|
|
2816
|
+
var OPENAPI_TAILWIND_ASSET_INLINE_FALLBACK = "/* OpenAPI docs runtime asset missing: tailwind disabled */";
|
|
2817
|
+
function resolveOpenAPITailwindAssetFile() {
|
|
2818
|
+
for (const relativePath of OPENAPI_TAILWIND_ASSET_RELATIVE_CANDIDATES) {
|
|
2819
|
+
try {
|
|
2820
|
+
const fileUrl = new URL(relativePath, import.meta.url);
|
|
2821
|
+
if (existsSync2(fileUrl)) {
|
|
2822
|
+
return Bun.file(fileUrl);
|
|
2823
|
+
}
|
|
2824
|
+
} catch {}
|
|
828
2825
|
}
|
|
829
|
-
|
|
830
|
-
|
|
2826
|
+
const cwd = process.cwd();
|
|
2827
|
+
for (const relativePath of OPENAPI_TAILWIND_ASSET_CWD_CANDIDATES) {
|
|
2828
|
+
const absolutePath = join2(cwd, relativePath);
|
|
2829
|
+
if (existsSync2(absolutePath)) {
|
|
2830
|
+
return Bun.file(absolutePath);
|
|
2831
|
+
}
|
|
831
2832
|
}
|
|
2833
|
+
return null;
|
|
832
2834
|
}
|
|
2835
|
+
var OPENAPI_TAILWIND_ASSET_FILE = resolveOpenAPITailwindAssetFile();
|
|
2836
|
+
var DOCS_HTML_CACHE_CONTROL = "public, max-age=0, must-revalidate";
|
|
2837
|
+
var DOCS_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
833
2838
|
|
|
834
|
-
// src/core/server.ts
|
|
835
2839
|
class VectorServer {
|
|
836
2840
|
server = null;
|
|
837
2841
|
router;
|
|
838
2842
|
config;
|
|
839
|
-
|
|
2843
|
+
openapiConfig;
|
|
2844
|
+
openapiDocCache = null;
|
|
2845
|
+
openapiDocsHtmlCache = null;
|
|
2846
|
+
openapiWarningsLogged = false;
|
|
2847
|
+
openapiTailwindMissingLogged = false;
|
|
2848
|
+
corsHandler = null;
|
|
2849
|
+
corsHeadersEntries = null;
|
|
840
2850
|
constructor(router, config) {
|
|
841
2851
|
this.router = router;
|
|
842
2852
|
this.config = config;
|
|
2853
|
+
this.openapiConfig = this.normalizeOpenAPIConfig(config.openapi, config.development);
|
|
843
2854
|
if (config.cors) {
|
|
844
|
-
const
|
|
845
|
-
|
|
2855
|
+
const opts = this.normalizeCorsOptions(config.cors);
|
|
2856
|
+
const { preflight, corsify } = cors(opts);
|
|
2857
|
+
this.corsHandler = { preflight, corsify };
|
|
2858
|
+
const canUseStaticCorsHeaders = typeof opts.origin === "string" && (opts.origin !== "*" || !opts.credentials);
|
|
2859
|
+
if (canUseStaticCorsHeaders) {
|
|
2860
|
+
const corsHeaders = {
|
|
2861
|
+
"access-control-allow-origin": opts.origin,
|
|
2862
|
+
"access-control-allow-methods": opts.allowMethods,
|
|
2863
|
+
"access-control-allow-headers": opts.allowHeaders,
|
|
2864
|
+
"access-control-expose-headers": opts.exposeHeaders,
|
|
2865
|
+
"access-control-max-age": String(opts.maxAge)
|
|
2866
|
+
};
|
|
2867
|
+
if (opts.credentials) {
|
|
2868
|
+
corsHeaders["access-control-allow-credentials"] = "true";
|
|
2869
|
+
}
|
|
2870
|
+
this.corsHeadersEntries = Object.entries(corsHeaders);
|
|
2871
|
+
}
|
|
2872
|
+
this.router.setCorsHeaders(this.corsHeadersEntries);
|
|
2873
|
+
this.router.setCorsHandler(this.corsHeadersEntries ? null : this.corsHandler.corsify);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
normalizeOpenAPIConfig(openapi, development) {
|
|
2877
|
+
const isDev = development !== false && true;
|
|
2878
|
+
const defaultEnabled = isDev;
|
|
2879
|
+
if (openapi === false) {
|
|
2880
|
+
return {
|
|
2881
|
+
enabled: false,
|
|
2882
|
+
path: "/openapi.json",
|
|
2883
|
+
target: "openapi-3.0",
|
|
2884
|
+
docs: { enabled: false, path: "/docs" }
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
if (openapi === true) {
|
|
2888
|
+
return {
|
|
2889
|
+
enabled: true,
|
|
2890
|
+
path: "/openapi.json",
|
|
2891
|
+
target: "openapi-3.0",
|
|
2892
|
+
docs: { enabled: false, path: "/docs" }
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
const openapiObject = openapi || {};
|
|
2896
|
+
const docsValue = openapiObject.docs;
|
|
2897
|
+
const docs = typeof docsValue === "boolean" ? { enabled: docsValue, path: "/docs" } : {
|
|
2898
|
+
enabled: docsValue?.enabled === true,
|
|
2899
|
+
path: docsValue?.path || "/docs"
|
|
2900
|
+
};
|
|
2901
|
+
return {
|
|
2902
|
+
enabled: openapiObject.enabled ?? defaultEnabled,
|
|
2903
|
+
path: openapiObject.path || "/openapi.json",
|
|
2904
|
+
target: openapiObject.target || "openapi-3.0",
|
|
2905
|
+
docs,
|
|
2906
|
+
info: openapiObject.info
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
isDocsReservedPath(path) {
|
|
2910
|
+
return path === this.openapiConfig.path || this.openapiConfig.docs.enabled && path === this.openapiConfig.docs.path;
|
|
2911
|
+
}
|
|
2912
|
+
getOpenAPIDocument() {
|
|
2913
|
+
if (this.openapiDocCache) {
|
|
2914
|
+
return this.openapiDocCache;
|
|
2915
|
+
}
|
|
2916
|
+
const routes = this.router.getRouteDefinitions().filter((route) => !this.isDocsReservedPath(route.path));
|
|
2917
|
+
const result = generateOpenAPIDocument(routes, {
|
|
2918
|
+
target: this.openapiConfig.target,
|
|
2919
|
+
info: this.openapiConfig.info
|
|
2920
|
+
});
|
|
2921
|
+
if (!this.openapiWarningsLogged && result.warnings.length > 0) {
|
|
2922
|
+
for (const warning of result.warnings) {
|
|
2923
|
+
console.warn(warning);
|
|
2924
|
+
}
|
|
2925
|
+
this.openapiWarningsLogged = true;
|
|
2926
|
+
}
|
|
2927
|
+
this.openapiDocCache = result.document;
|
|
2928
|
+
return this.openapiDocCache;
|
|
2929
|
+
}
|
|
2930
|
+
getOpenAPIDocsHtmlCacheEntry() {
|
|
2931
|
+
if (this.openapiDocsHtmlCache) {
|
|
2932
|
+
return this.openapiDocsHtmlCache;
|
|
2933
|
+
}
|
|
2934
|
+
const html = renderOpenAPIDocsHtml(this.getOpenAPIDocument(), this.openapiConfig.path, OPENAPI_TAILWIND_ASSET_PATH);
|
|
2935
|
+
const gzip = Bun.gzipSync(html);
|
|
2936
|
+
const etag = `"${Bun.hash(html).toString(16)}"`;
|
|
2937
|
+
this.openapiDocsHtmlCache = { html, gzip, etag };
|
|
2938
|
+
return this.openapiDocsHtmlCache;
|
|
2939
|
+
}
|
|
2940
|
+
requestAcceptsGzip(request) {
|
|
2941
|
+
const acceptEncoding = request.headers.get("accept-encoding");
|
|
2942
|
+
return Boolean(acceptEncoding && /\bgzip\b/i.test(acceptEncoding));
|
|
2943
|
+
}
|
|
2944
|
+
validateReservedOpenAPIPaths() {
|
|
2945
|
+
if (!this.openapiConfig.enabled) {
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const reserved = new Set([this.openapiConfig.path]);
|
|
2949
|
+
if (this.openapiConfig.docs.enabled) {
|
|
2950
|
+
reserved.add(this.openapiConfig.docs.path);
|
|
2951
|
+
reserved.add(OPENAPI_TAILWIND_ASSET_PATH);
|
|
2952
|
+
}
|
|
2953
|
+
const methodConflicts = this.router.getRouteDefinitions().filter((route) => reserved.has(route.path)).map((route) => `${route.method} ${route.path}`);
|
|
2954
|
+
const staticConflicts = Object.entries(this.router.getRouteTable()).filter(([path, value]) => reserved.has(path) && value instanceof Response).map(([path]) => `STATIC ${path}`);
|
|
2955
|
+
const conflicts = [...methodConflicts, ...staticConflicts];
|
|
2956
|
+
if (conflicts.length > 0) {
|
|
2957
|
+
throw new Error(`OpenAPI reserved path conflict: ${conflicts.join(", ")}. Change your route path(s) or reconfigure openapi.path/docs.path.`);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
tryHandleOpenAPIRequest(request) {
|
|
2961
|
+
if (!this.openapiConfig.enabled || request.method !== "GET") {
|
|
2962
|
+
return null;
|
|
2963
|
+
}
|
|
2964
|
+
const pathname = new URL(request.url).pathname;
|
|
2965
|
+
if (pathname === this.openapiConfig.path) {
|
|
2966
|
+
return Response.json(this.getOpenAPIDocument());
|
|
2967
|
+
}
|
|
2968
|
+
if (this.openapiConfig.docs.enabled && pathname === this.openapiConfig.docs.path) {
|
|
2969
|
+
const { html, gzip, etag } = this.getOpenAPIDocsHtmlCacheEntry();
|
|
2970
|
+
if (request.headers.get("if-none-match") === etag) {
|
|
2971
|
+
return new Response(null, {
|
|
2972
|
+
status: 304,
|
|
2973
|
+
headers: {
|
|
2974
|
+
etag,
|
|
2975
|
+
"cache-control": DOCS_HTML_CACHE_CONTROL,
|
|
2976
|
+
vary: "accept-encoding"
|
|
2977
|
+
}
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
if (this.requestAcceptsGzip(request)) {
|
|
2981
|
+
return new Response(gzip, {
|
|
2982
|
+
status: 200,
|
|
2983
|
+
headers: {
|
|
2984
|
+
"content-type": "text/html; charset=utf-8",
|
|
2985
|
+
"content-encoding": "gzip",
|
|
2986
|
+
etag,
|
|
2987
|
+
"cache-control": DOCS_HTML_CACHE_CONTROL,
|
|
2988
|
+
vary: "accept-encoding"
|
|
2989
|
+
}
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2992
|
+
return new Response(html, {
|
|
2993
|
+
status: 200,
|
|
2994
|
+
headers: {
|
|
2995
|
+
"content-type": "text/html; charset=utf-8",
|
|
2996
|
+
etag,
|
|
2997
|
+
"cache-control": DOCS_HTML_CACHE_CONTROL,
|
|
2998
|
+
vary: "accept-encoding"
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
if (this.openapiConfig.docs.enabled && pathname === OPENAPI_TAILWIND_ASSET_PATH) {
|
|
3003
|
+
if (!OPENAPI_TAILWIND_ASSET_FILE) {
|
|
3004
|
+
if (!this.openapiTailwindMissingLogged) {
|
|
3005
|
+
this.openapiTailwindMissingLogged = true;
|
|
3006
|
+
console.warn('[OpenAPI] Missing docs runtime asset "tailwindcdn.js". Serving inline fallback script instead.');
|
|
3007
|
+
}
|
|
3008
|
+
return new Response(OPENAPI_TAILWIND_ASSET_INLINE_FALLBACK, {
|
|
3009
|
+
status: 200,
|
|
3010
|
+
headers: {
|
|
3011
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
3012
|
+
"cache-control": DOCS_ASSET_CACHE_CONTROL
|
|
3013
|
+
}
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
return new Response(OPENAPI_TAILWIND_ASSET_FILE, {
|
|
3017
|
+
status: 200,
|
|
3018
|
+
headers: {
|
|
3019
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
3020
|
+
"cache-control": DOCS_ASSET_CACHE_CONTROL
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
846
3023
|
}
|
|
3024
|
+
return null;
|
|
847
3025
|
}
|
|
848
3026
|
normalizeCorsOptions(options) {
|
|
849
3027
|
return {
|
|
@@ -855,22 +3033,35 @@ class VectorServer {
|
|
|
855
3033
|
maxAge: options.maxAge || 86400
|
|
856
3034
|
};
|
|
857
3035
|
}
|
|
3036
|
+
applyCors(response, request) {
|
|
3037
|
+
if (this.corsHeadersEntries) {
|
|
3038
|
+
for (const [k, v] of this.corsHeadersEntries) {
|
|
3039
|
+
response.headers.set(k, v);
|
|
3040
|
+
}
|
|
3041
|
+
return response;
|
|
3042
|
+
}
|
|
3043
|
+
if (this.corsHandler && request) {
|
|
3044
|
+
return this.corsHandler.corsify(response, request);
|
|
3045
|
+
}
|
|
3046
|
+
return response;
|
|
3047
|
+
}
|
|
858
3048
|
async start() {
|
|
859
|
-
const port = this.config.port
|
|
3049
|
+
const port = this.config.port ?? 3000;
|
|
860
3050
|
const hostname = this.config.hostname || "localhost";
|
|
861
|
-
|
|
3051
|
+
this.validateReservedOpenAPIPaths();
|
|
3052
|
+
const fallbackFetch = async (request) => {
|
|
862
3053
|
try {
|
|
863
3054
|
if (this.corsHandler && request.method === "OPTIONS") {
|
|
864
3055
|
return this.corsHandler.preflight(request);
|
|
865
3056
|
}
|
|
866
|
-
|
|
867
|
-
if (
|
|
868
|
-
|
|
3057
|
+
const openapiResponse = this.tryHandleOpenAPIRequest(request);
|
|
3058
|
+
if (openapiResponse) {
|
|
3059
|
+
return this.applyCors(openapiResponse, request);
|
|
869
3060
|
}
|
|
870
|
-
return
|
|
3061
|
+
return this.applyCors(STATIC_RESPONSES.NOT_FOUND.clone(), request);
|
|
871
3062
|
} catch (error) {
|
|
872
3063
|
console.error("Server error:", error);
|
|
873
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
3064
|
+
return this.applyCors(new Response("Internal Server Error", { status: 500 }), request);
|
|
874
3065
|
}
|
|
875
3066
|
};
|
|
876
3067
|
try {
|
|
@@ -878,11 +3069,12 @@ class VectorServer {
|
|
|
878
3069
|
port,
|
|
879
3070
|
hostname,
|
|
880
3071
|
reusePort: this.config.reusePort !== false,
|
|
881
|
-
|
|
3072
|
+
routes: this.router.getRouteTable(),
|
|
3073
|
+
fetch: fallbackFetch,
|
|
882
3074
|
idleTimeout: this.config.idleTimeout || 60,
|
|
883
|
-
error: (error) => {
|
|
3075
|
+
error: (error, request) => {
|
|
884
3076
|
console.error("[ERROR] Server error:", error);
|
|
885
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
3077
|
+
return this.applyCors(new Response("Internal Server Error", { status: 500 }), request);
|
|
886
3078
|
}
|
|
887
3079
|
});
|
|
888
3080
|
if (!this.server || !this.server.port) {
|
|
@@ -907,6 +3099,9 @@ class VectorServer {
|
|
|
907
3099
|
if (this.server) {
|
|
908
3100
|
this.server.stop();
|
|
909
3101
|
this.server = null;
|
|
3102
|
+
this.openapiDocCache = null;
|
|
3103
|
+
this.openapiDocsHtmlCache = null;
|
|
3104
|
+
this.openapiWarningsLogged = false;
|
|
910
3105
|
console.log("Server stopped");
|
|
911
3106
|
}
|
|
912
3107
|
}
|
|
@@ -914,7 +3109,7 @@ class VectorServer {
|
|
|
914
3109
|
return this.server;
|
|
915
3110
|
}
|
|
916
3111
|
getPort() {
|
|
917
|
-
return this.server?.port
|
|
3112
|
+
return this.server?.port ?? this.config.port ?? 3000;
|
|
918
3113
|
}
|
|
919
3114
|
getHostname() {
|
|
920
3115
|
return this.server?.hostname || this.config.hostname || "localhost";
|
|
@@ -966,10 +3161,13 @@ class Vector {
|
|
|
966
3161
|
return this._cacheHandler;
|
|
967
3162
|
}
|
|
968
3163
|
addRoute(options, handler) {
|
|
969
|
-
|
|
3164
|
+
this.router.route(options, handler);
|
|
970
3165
|
}
|
|
971
3166
|
async startServer(config) {
|
|
972
3167
|
this.config = { ...this.config, ...config };
|
|
3168
|
+
const routeDefaults = { ...this.config.defaults?.route };
|
|
3169
|
+
this.router.setRouteBooleanDefaults(routeDefaults);
|
|
3170
|
+
this.router.setDevelopmentMode(this.config.development);
|
|
973
3171
|
this.middlewareManager.clear();
|
|
974
3172
|
if (this.config.autoDiscover !== false) {
|
|
975
3173
|
this.router.clearRoutes();
|
|
@@ -1007,9 +3205,8 @@ class Vector {
|
|
|
1007
3205
|
const exported = route.name === "default" ? module.default : module[route.name];
|
|
1008
3206
|
if (exported) {
|
|
1009
3207
|
if (this.isRouteDefinition(exported)) {
|
|
1010
|
-
|
|
1011
|
-
this.
|
|
1012
|
-
this.logRouteLoaded(routeDef.options);
|
|
3208
|
+
this.router.route(exported.options, exported.handler);
|
|
3209
|
+
this.logRouteLoaded(exported.options);
|
|
1013
3210
|
} else if (this.isRouteEntry(exported)) {
|
|
1014
3211
|
this.router.addRoute(exported);
|
|
1015
3212
|
this.logRouteLoaded(exported);
|
|
@@ -1022,7 +3219,6 @@ class Vector {
|
|
|
1022
3219
|
console.error(`Failed to load route ${route.name} from ${route.path}:`, error);
|
|
1023
3220
|
}
|
|
1024
3221
|
}
|
|
1025
|
-
this.router.sortRoutes();
|
|
1026
3222
|
}
|
|
1027
3223
|
} catch (error) {
|
|
1028
3224
|
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
|
|
@@ -1033,14 +3229,14 @@ class Vector {
|
|
|
1033
3229
|
async loadRoute(routeModule) {
|
|
1034
3230
|
if (typeof routeModule === "function") {
|
|
1035
3231
|
const routeEntry = routeModule();
|
|
1036
|
-
if (
|
|
3232
|
+
if (this.isRouteEntry(routeEntry)) {
|
|
1037
3233
|
this.router.addRoute(routeEntry);
|
|
1038
3234
|
}
|
|
1039
3235
|
} else if (routeModule && typeof routeModule === "object") {
|
|
1040
3236
|
for (const [, value] of Object.entries(routeModule)) {
|
|
1041
3237
|
if (typeof value === "function") {
|
|
1042
3238
|
const routeEntry = value();
|
|
1043
|
-
if (
|
|
3239
|
+
if (this.isRouteEntry(routeEntry)) {
|
|
1044
3240
|
this.router.addRoute(routeEntry);
|
|
1045
3241
|
}
|
|
1046
3242
|
}
|
|
@@ -1048,10 +3244,14 @@ class Vector {
|
|
|
1048
3244
|
}
|
|
1049
3245
|
}
|
|
1050
3246
|
isRouteEntry(value) {
|
|
1051
|
-
|
|
3247
|
+
if (!Array.isArray(value) || value.length < 3) {
|
|
3248
|
+
return false;
|
|
3249
|
+
}
|
|
3250
|
+
const [method, matcher, handlers, path] = value;
|
|
3251
|
+
return typeof method === "string" && matcher instanceof RegExp && Array.isArray(handlers) && handlers.length > 0 && handlers.every((handler) => typeof handler === "function") && (path === undefined || typeof path === "string");
|
|
1052
3252
|
}
|
|
1053
3253
|
isRouteDefinition(value) {
|
|
1054
|
-
return value && typeof value === "object" && "entry" in value && "options" in value && "handler" in value;
|
|
3254
|
+
return value !== null && typeof value === "object" && "entry" in value && "options" in value && "handler" in value && typeof value.handler === "function";
|
|
1055
3255
|
}
|
|
1056
3256
|
logRouteLoaded(_) {}
|
|
1057
3257
|
stop() {
|
|
@@ -1079,7 +3279,7 @@ class Vector {
|
|
|
1079
3279
|
var getVectorInstance = Vector.getInstance;
|
|
1080
3280
|
|
|
1081
3281
|
// src/core/config-loader.ts
|
|
1082
|
-
import { existsSync as
|
|
3282
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1083
3283
|
import { resolve as resolve2, isAbsolute } from "path";
|
|
1084
3284
|
class ConfigLoader {
|
|
1085
3285
|
configPath;
|
|
@@ -1090,16 +3290,16 @@ class ConfigLoader {
|
|
|
1090
3290
|
this.configPath = isAbsolute(path) ? path : resolve2(process.cwd(), path);
|
|
1091
3291
|
}
|
|
1092
3292
|
async load() {
|
|
1093
|
-
if (
|
|
3293
|
+
if (existsSync3(this.configPath)) {
|
|
1094
3294
|
try {
|
|
1095
3295
|
const userConfigPath = toFileUrl(this.configPath);
|
|
1096
3296
|
const userConfig = await import(userConfigPath);
|
|
1097
3297
|
this.config = userConfig.default || userConfig;
|
|
1098
3298
|
this.configSource = "user";
|
|
1099
3299
|
} catch (error) {
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
console.error(
|
|
3300
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
3301
|
+
console.error(`[Vector] Failed to load config from ${this.configPath}: ${msg}`);
|
|
3302
|
+
console.error("[Vector] Server is using default configuration. Fix your config file and restart.");
|
|
1103
3303
|
this.config = {};
|
|
1104
3304
|
}
|
|
1105
3305
|
} else {
|
|
@@ -1119,6 +3319,8 @@ class ConfigLoader {
|
|
|
1119
3319
|
config.development = this.config.development;
|
|
1120
3320
|
config.routesDir = this.config.routesDir || "./routes";
|
|
1121
3321
|
config.idleTimeout = this.config.idleTimeout;
|
|
3322
|
+
config.defaults = this.config.defaults;
|
|
3323
|
+
config.openapi = this.config.openapi;
|
|
1122
3324
|
}
|
|
1123
3325
|
config.autoDiscover = true;
|
|
1124
3326
|
if (this.config?.cors) {
|
|
@@ -1154,6 +3356,35 @@ class ConfigLoader {
|
|
|
1154
3356
|
}
|
|
1155
3357
|
}
|
|
1156
3358
|
|
|
3359
|
+
// src/cli/option-resolution.ts
|
|
3360
|
+
function resolveRoutesDir(configRoutesDir, hasRoutesOption, cliRoutes) {
|
|
3361
|
+
if (hasRoutesOption) {
|
|
3362
|
+
return cliRoutes;
|
|
3363
|
+
}
|
|
3364
|
+
return configRoutesDir ?? cliRoutes;
|
|
3365
|
+
}
|
|
3366
|
+
function parseAndValidatePort(value) {
|
|
3367
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
3368
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
3369
|
+
throw new Error(`Invalid port value: ${String(value)}`);
|
|
3370
|
+
}
|
|
3371
|
+
return parsed;
|
|
3372
|
+
}
|
|
3373
|
+
function resolvePort(configPort, hasPortOption, cliPort) {
|
|
3374
|
+
if (hasPortOption) {
|
|
3375
|
+
return parseAndValidatePort(cliPort);
|
|
3376
|
+
}
|
|
3377
|
+
const resolved = configPort ?? cliPort;
|
|
3378
|
+
return parseAndValidatePort(resolved);
|
|
3379
|
+
}
|
|
3380
|
+
function resolveHost(configHost, hasHostOption, cliHost) {
|
|
3381
|
+
const resolved = hasHostOption ? cliHost : configHost ?? cliHost;
|
|
3382
|
+
if (typeof resolved !== "string" || resolved.length === 0) {
|
|
3383
|
+
throw new Error(`Invalid host value: ${String(resolved)}`);
|
|
3384
|
+
}
|
|
3385
|
+
return resolved;
|
|
3386
|
+
}
|
|
3387
|
+
|
|
1157
3388
|
// src/cli/index.ts
|
|
1158
3389
|
var args = typeof Bun !== "undefined" ? Bun.argv.slice(2) : process.argv.slice(2);
|
|
1159
3390
|
var { values, positionals } = parseArgs({
|
|
@@ -1192,6 +3423,9 @@ var { values, positionals } = parseArgs({
|
|
|
1192
3423
|
allowPositionals: true
|
|
1193
3424
|
});
|
|
1194
3425
|
var command = positionals[0] || "dev";
|
|
3426
|
+
var hasRoutesOption = args.some((arg) => arg === "--routes" || arg === "-r" || arg.startsWith("--routes="));
|
|
3427
|
+
var hasHostOption = args.some((arg) => arg === "--host" || arg === "-h" || arg.startsWith("--host="));
|
|
3428
|
+
var hasPortOption = args.some((arg) => arg === "--port" || arg === "-p" || arg.startsWith("--port="));
|
|
1195
3429
|
async function runDev() {
|
|
1196
3430
|
const isDev = command === "dev";
|
|
1197
3431
|
let server = null;
|
|
@@ -1203,11 +3437,13 @@ async function runDev() {
|
|
|
1203
3437
|
}, 1e4);
|
|
1204
3438
|
});
|
|
1205
3439
|
const serverStartPromise = (async () => {
|
|
1206
|
-
const
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
config
|
|
1210
|
-
config.
|
|
3440
|
+
const explicitConfigPath = values.config;
|
|
3441
|
+
const configLoader = new ConfigLoader(explicitConfigPath);
|
|
3442
|
+
const loadedConfig = await configLoader.load();
|
|
3443
|
+
const config = { ...loadedConfig };
|
|
3444
|
+
config.port = resolvePort(config.port, hasPortOption, values.port);
|
|
3445
|
+
config.hostname = resolveHost(config.hostname, hasHostOption, values.host);
|
|
3446
|
+
config.routesDir = resolveRoutesDir(config.routesDir, hasRoutesOption, values.routes);
|
|
1211
3447
|
config.development = config.development ?? isDev;
|
|
1212
3448
|
config.autoDiscover = true;
|
|
1213
3449
|
if (config.cors === undefined && values.cors) {
|
|
@@ -1255,7 +3491,9 @@ Listening on ${cyan}http://${config.hostname}:${config.port}${reset}
|
|
|
1255
3491
|
const now = Date.now();
|
|
1256
3492
|
if (isReloading || now - lastReloadTime < 1000)
|
|
1257
3493
|
return;
|
|
1258
|
-
|
|
3494
|
+
const segments = filename ? filename.split(/[/\\]/) : [];
|
|
3495
|
+
const excluded = segments.some((s) => ["node_modules", ".git", ".vector", "dist"].includes(s));
|
|
3496
|
+
if (filename && (filename.endsWith(".ts") || filename.endsWith(".js") || filename.endsWith(".json")) && !excluded && !filename.includes("bun.lockb") && !filename.endsWith(".generated.ts")) {
|
|
1259
3497
|
changedFiles.add(filename);
|
|
1260
3498
|
if (reloadTimeout) {
|
|
1261
3499
|
clearTimeout(reloadTimeout);
|
|
@@ -1270,13 +3508,6 @@ Listening on ${cyan}http://${config.hostname}:${config.port}${reset}
|
|
|
1270
3508
|
vector.stop();
|
|
1271
3509
|
}
|
|
1272
3510
|
await new Promise((resolve3) => setTimeout(resolve3, 100));
|
|
1273
|
-
if (__require.cache) {
|
|
1274
|
-
for (const key in __require.cache) {
|
|
1275
|
-
if (!key.includes("node_modules")) {
|
|
1276
|
-
delete __require.cache[key];
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
3511
|
try {
|
|
1281
3512
|
const result2 = await startServer();
|
|
1282
3513
|
server = result2.server;
|
|
@@ -1308,70 +3539,15 @@ ${red}Error: ${error.message || error}${reset}
|
|
|
1308
3539
|
process.exit(1);
|
|
1309
3540
|
}
|
|
1310
3541
|
}
|
|
1311
|
-
async function runBuild() {
|
|
1312
|
-
try {
|
|
1313
|
-
const { RouteScanner: RouteScanner2 } = await Promise.resolve().then(() => (init_route_scanner(), exports_route_scanner));
|
|
1314
|
-
const { RouteGenerator: RouteGenerator2 } = await Promise.resolve().then(() => (init_route_generator(), exports_route_generator));
|
|
1315
|
-
const scanner = new RouteScanner2(values.routes);
|
|
1316
|
-
const generator = new RouteGenerator2;
|
|
1317
|
-
const routes = await scanner.scan();
|
|
1318
|
-
await generator.generate(routes);
|
|
1319
|
-
if (typeof Bun !== "undefined") {
|
|
1320
|
-
const buildProcess = Bun.spawn([
|
|
1321
|
-
"bun",
|
|
1322
|
-
"build",
|
|
1323
|
-
"src/cli/index.ts",
|
|
1324
|
-
"--target",
|
|
1325
|
-
"bun",
|
|
1326
|
-
"--outfile",
|
|
1327
|
-
"dist/server.js",
|
|
1328
|
-
"--minify"
|
|
1329
|
-
]);
|
|
1330
|
-
const exitCode = await buildProcess.exited;
|
|
1331
|
-
if (exitCode !== 0) {
|
|
1332
|
-
throw new Error(`Build failed with exit code ${exitCode}`);
|
|
1333
|
-
}
|
|
1334
|
-
} else {
|
|
1335
|
-
const { spawnSync } = await import("child_process");
|
|
1336
|
-
const result = spawnSync("bun", [
|
|
1337
|
-
"build",
|
|
1338
|
-
"src/cli/index.ts",
|
|
1339
|
-
"--target",
|
|
1340
|
-
"bun",
|
|
1341
|
-
"--outfile",
|
|
1342
|
-
"dist/server.js",
|
|
1343
|
-
"--minify"
|
|
1344
|
-
], {
|
|
1345
|
-
stdio: "inherit",
|
|
1346
|
-
shell: true
|
|
1347
|
-
});
|
|
1348
|
-
if (result.status !== 0) {
|
|
1349
|
-
throw new Error(`Build failed with exit code ${result.status}`);
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
console.log(`
|
|
1353
|
-
Build complete: dist/server.js
|
|
1354
|
-
`);
|
|
1355
|
-
} catch (error) {
|
|
1356
|
-
const red = "\x1B[31m";
|
|
1357
|
-
const reset = "\x1B[0m";
|
|
1358
|
-
console.error(`
|
|
1359
|
-
${red}Error: ${error.message || error}${reset}
|
|
1360
|
-
`);
|
|
1361
|
-
process.exit(1);
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
3542
|
switch (command) {
|
|
1365
3543
|
case "dev":
|
|
1366
3544
|
await runDev();
|
|
1367
3545
|
break;
|
|
1368
|
-
case "
|
|
1369
|
-
await runBuild();
|
|
1370
|
-
break;
|
|
1371
|
-
case "start":
|
|
3546
|
+
case "start": {
|
|
1372
3547
|
process.env.NODE_ENV = "production";
|
|
1373
3548
|
await runDev();
|
|
1374
3549
|
break;
|
|
3550
|
+
}
|
|
1375
3551
|
default:
|
|
1376
3552
|
console.error(`Unknown command: ${command}`);
|
|
1377
3553
|
console.log(`
|
|
@@ -1379,15 +3555,14 @@ Usage: vector [command] [options]
|
|
|
1379
3555
|
|
|
1380
3556
|
Commands:
|
|
1381
3557
|
dev Start development server (default)
|
|
1382
|
-
build Build for production
|
|
1383
3558
|
start Start production server
|
|
1384
3559
|
|
|
1385
3560
|
Options:
|
|
1386
3561
|
-p, --port <port> Port to listen on (default: 3000)
|
|
1387
3562
|
-h, --host <host> Hostname to bind to (default: localhost)
|
|
1388
|
-
-r, --routes <dir> Routes directory (
|
|
3563
|
+
-r, --routes <dir> Routes directory (dev/start)
|
|
1389
3564
|
-w, --watch Watch for file changes (default: true)
|
|
1390
|
-
-c, --config <path> Path to config file (
|
|
3565
|
+
-c, --config <path> Path to config file (dev/start)
|
|
1391
3566
|
--cors Enable CORS (default: true)
|
|
1392
3567
|
`);
|
|
1393
3568
|
process.exit(1);
|