vector-framework 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -628
- package/dist/auth/protected.d.ts +1 -0
- package/dist/auth/protected.d.ts.map +1 -1
- package/dist/auth/protected.js +3 -0
- package/dist/auth/protected.js.map +1 -1
- package/dist/cache/manager.d.ts +1 -0
- package/dist/cache/manager.d.ts.map +1 -1
- package/dist/cache/manager.js +5 -7
- package/dist/cache/manager.js.map +1 -1
- package/dist/cli/graceful-shutdown.d.ts +15 -0
- package/dist/cli/graceful-shutdown.d.ts.map +1 -0
- package/dist/cli/graceful-shutdown.js +42 -0
- package/dist/cli/graceful-shutdown.js.map +1 -0
- package/dist/cli/index.js +46 -97
- 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 +3423 -660
- 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.map +1 -1
- package/dist/core/config-loader.js +7 -2
- package/dist/core/config-loader.js.map +1 -1
- package/dist/core/router.d.ts +41 -17
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +432 -153
- package/dist/core/router.js.map +1 -1
- package/dist/core/server.d.ts +17 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +471 -31
- package/dist/core/server.js.map +1 -1
- package/dist/core/vector.d.ts +8 -5
- package/dist/core/vector.d.ts.map +1 -1
- package/dist/core/vector.js +53 -14
- 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.map +1 -1
- package/dist/dev/route-scanner.js +1 -5
- 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 +34 -41
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1420 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1420 -8
- 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 +1425 -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 +502 -0
- package/dist/openapi/generator.js.map +1 -0
- package/dist/start-vector.d.ts +3 -0
- package/dist/start-vector.d.ts.map +1 -0
- package/dist/start-vector.js +38 -0
- package/dist/start-vector.js.map +1 -0
- package/dist/types/index.d.ts +95 -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/logger.js +1 -1
- package/dist/utils/path.d.ts +6 -0
- package/dist/utils/path.d.ts.map +1 -1
- package/dist/utils/path.js +5 -0
- 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 +3 -0
- package/dist/utils/validation.js.map +1 -1
- package/package.json +15 -12
- package/src/auth/protected.ts +7 -13
- package/src/cache/manager.ts +8 -18
- package/src/cli/graceful-shutdown.ts +60 -0
- package/src/cli/index.ts +52 -115
- package/src/cli/option-resolution.ts +40 -0
- package/src/constants/index.ts +7 -0
- package/src/core/config-loader.ts +7 -4
- package/src/core/router.ts +502 -156
- package/src/core/server.ts +610 -33
- package/src/core/vector.ts +87 -33
- package/src/dev/route-generator.ts +1 -3
- package/src/dev/route-scanner.ts +2 -9
- package/src/http.ts +85 -125
- package/src/index.ts +4 -3
- package/src/middleware/manager.ts +4 -0
- package/src/openapi/assets/favicon/android-chrome-192x192.png +0 -0
- package/src/openapi/assets/favicon/android-chrome-512x512.png +0 -0
- package/src/openapi/assets/favicon/apple-touch-icon.png +0 -0
- package/src/openapi/assets/favicon/favicon-16x16.png +0 -0
- package/src/openapi/assets/favicon/favicon-32x32.png +0 -0
- package/src/openapi/assets/favicon/favicon.ico +0 -0
- package/src/openapi/assets/favicon/site.webmanifest +11 -0
- package/src/openapi/assets/logo.svg +12 -0
- package/src/openapi/assets/logo_dark.svg +6 -0
- package/src/openapi/assets/logo_icon.png +0 -0
- package/src/openapi/assets/logo_white.svg +6 -0
- package/src/openapi/assets/tailwindcdn.js +83 -0
- package/src/openapi/docs-ui.ts +1435 -0
- package/src/openapi/generator.ts +586 -0
- package/src/start-vector.ts +50 -0
- package/src/types/index.ts +138 -17
- package/src/types/standard-schema.ts +147 -0
- package/src/utils/cors.ts +101 -0
- package/src/utils/logger.ts +1 -1
- package/src/utils/path.ts +6 -0
- package/src/utils/schema-validation.ts +123 -0
- package/src/utils/validation.ts +3 -0
package/dist/cli.js
CHANGED
|
@@ -1,272 +1,168 @@
|
|
|
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
3
|
|
|
31
|
-
// src/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
RouteGenerator: () => RouteGenerator
|
|
35
|
-
});
|
|
36
|
-
import { promises as fs } from "fs";
|
|
37
|
-
import { dirname, relative } from "path";
|
|
4
|
+
// src/cli/index.ts
|
|
5
|
+
import { watch } from "fs";
|
|
6
|
+
import { parseArgs } from "util";
|
|
38
7
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
8
|
+
// src/cli/option-resolution.ts
|
|
9
|
+
function resolveRoutesDir(configRoutesDir, hasRoutesOption, cliRoutes) {
|
|
10
|
+
if (hasRoutesOption) {
|
|
11
|
+
return cliRoutes;
|
|
43
12
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
13
|
+
return configRoutesDir ?? cliRoutes;
|
|
14
|
+
}
|
|
15
|
+
function parseAndValidatePort(value) {
|
|
16
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
17
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
18
|
+
throw new Error(`Invalid port value: ${String(value)}`);
|
|
19
|
+
}
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
function resolvePort(configPort, hasPortOption, cliPort) {
|
|
23
|
+
if (hasPortOption) {
|
|
24
|
+
return parseAndValidatePort(cliPort);
|
|
25
|
+
}
|
|
26
|
+
const resolved = configPort ?? cliPort;
|
|
27
|
+
return parseAndValidatePort(resolved);
|
|
28
|
+
}
|
|
29
|
+
function resolveHost(configHost, hasHostOption, cliHost) {
|
|
30
|
+
const resolved = hasHostOption ? cliHost : configHost ?? cliHost;
|
|
31
|
+
if (typeof resolved !== "string" || resolved.length === 0) {
|
|
32
|
+
throw new Error(`Invalid host value: ${String(resolved)}`);
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/cli/graceful-shutdown.ts
|
|
38
|
+
function installGracefulShutdownHandlers(options) {
|
|
39
|
+
const on = options.on ?? ((event, listener) => process.on(event, listener));
|
|
40
|
+
const off = options.off ?? ((event, listener) => process.off(event, listener));
|
|
41
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
42
|
+
const logError = options.logError ?? console.error;
|
|
43
|
+
let shuttingDown = false;
|
|
44
|
+
const handleSignal = async (signal) => {
|
|
45
|
+
if (shuttingDown) {
|
|
46
|
+
return;
|
|
54
47
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
imports.push(`import ${importName}, { ${namedImports.join(", ")} } from '${relativePath}';`);
|
|
64
|
-
} else {
|
|
65
|
-
imports.push(`import ${importName} from '${relativePath}';`);
|
|
48
|
+
shuttingDown = true;
|
|
49
|
+
try {
|
|
50
|
+
const target = options.getTarget();
|
|
51
|
+
if (target) {
|
|
52
|
+
if (typeof target.shutdown === "function") {
|
|
53
|
+
await target.shutdown();
|
|
54
|
+
} else if (typeof target.stop === "function") {
|
|
55
|
+
target.stop();
|
|
66
56
|
}
|
|
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
57
|
}
|
|
58
|
+
exit(0);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
logError(`[vector] Graceful shutdown failed after ${signal}:`, error);
|
|
61
|
+
exit(1);
|
|
74
62
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
};
|
|
64
|
+
const onSigint = () => {
|
|
65
|
+
handleSignal("SIGINT");
|
|
66
|
+
};
|
|
67
|
+
const onSigterm = () => {
|
|
68
|
+
handleSignal("SIGTERM");
|
|
69
|
+
};
|
|
70
|
+
on("SIGINT", onSigint);
|
|
71
|
+
on("SIGTERM", onSigterm);
|
|
72
|
+
return () => {
|
|
73
|
+
off("SIGINT", onSigint);
|
|
74
|
+
off("SIGTERM", onSigterm);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
80
77
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
];
|
|
78
|
+
// src/core/config-loader.ts
|
|
79
|
+
import { existsSync } from "fs";
|
|
80
|
+
import { resolve, isAbsolute } from "path";
|
|
85
81
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
}
|
|
82
|
+
// src/utils/path.ts
|
|
83
|
+
function toFileUrl(path) {
|
|
84
|
+
return process.platform === "win32" ? `file:///${path.replace(/\\/g, "/")}` : path;
|
|
85
|
+
}
|
|
86
|
+
function buildRouteRegex(path) {
|
|
87
|
+
return RegExp(`^${path.replace(/\/+(\/|$)/g, "$1").replace(/(\/?\.?):(\w+)\+/g, "($1(?<$2>[\\s\\S]+))").replace(/(\/?\.?):(\w+)/g, "($1(?<$2>[^$1/]+?))").replace(/\./g, "\\.").replace(/(\/?)\*/g, "($1.*)?")}/*$`);
|
|
110
88
|
}
|
|
111
|
-
var init_route_generator = () => {};
|
|
112
89
|
|
|
113
|
-
// src/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
}
|
|
90
|
+
// src/core/config-loader.ts
|
|
91
|
+
class ConfigLoader {
|
|
92
|
+
configPath;
|
|
93
|
+
config = null;
|
|
94
|
+
configSource = "default";
|
|
95
|
+
constructor(configPath) {
|
|
96
|
+
const path = configPath || "vector.config.ts";
|
|
97
|
+
this.configPath = isAbsolute(path) ? path : resolve(process.cwd(), path);
|
|
98
|
+
}
|
|
99
|
+
async load() {
|
|
100
|
+
if (existsSync(this.configPath)) {
|
|
150
101
|
try {
|
|
151
|
-
|
|
102
|
+
const userConfigPath = toFileUrl(this.configPath);
|
|
103
|
+
const userConfig = await import(userConfigPath);
|
|
104
|
+
this.config = userConfig.default || userConfig;
|
|
105
|
+
this.configSource = "user";
|
|
152
106
|
} catch (error) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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, "__GLOBSTAR__").replace(/\*/g, "[^/]*").replace(/__GLOBSTAR__/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
|
-
}
|
|
107
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
108
|
+
console.error(`[vector] Failed to load config from ${this.configPath}: ${msg}`);
|
|
109
|
+
console.error("[vector] Server is using default configuration. Fix your config file and restart.");
|
|
110
|
+
this.config = {};
|
|
170
111
|
}
|
|
171
|
-
|
|
112
|
+
} else {
|
|
113
|
+
this.config = {};
|
|
172
114
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
}
|
|
115
|
+
return await this.buildLegacyConfig();
|
|
116
|
+
}
|
|
117
|
+
getConfigSource() {
|
|
118
|
+
return this.configSource;
|
|
119
|
+
}
|
|
120
|
+
async buildLegacyConfig() {
|
|
121
|
+
const config = {};
|
|
122
|
+
if (this.config) {
|
|
123
|
+
config.port = this.config.port;
|
|
124
|
+
config.hostname = this.config.hostname;
|
|
125
|
+
config.reusePort = this.config.reusePort;
|
|
126
|
+
config.development = this.config.development;
|
|
127
|
+
config.routesDir = this.config.routesDir || "./routes";
|
|
128
|
+
config.routeExcludePatterns = this.config.routeExcludePatterns;
|
|
129
|
+
config.idleTimeout = this.config.idleTimeout;
|
|
130
|
+
config.defaults = this.config.defaults;
|
|
131
|
+
config.openapi = this.config.openapi;
|
|
132
|
+
config.startup = this.config.startup;
|
|
133
|
+
config.shutdown = this.config.shutdown;
|
|
231
134
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
135
|
+
config.autoDiscover = true;
|
|
136
|
+
if (this.config?.cors) {
|
|
137
|
+
if (typeof this.config.cors === "boolean") {
|
|
138
|
+
config.cors = this.config.cors ? {
|
|
139
|
+
origin: "*",
|
|
140
|
+
credentials: true,
|
|
141
|
+
allowHeaders: "Content-Type, Authorization",
|
|
142
|
+
allowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
143
|
+
exposeHeaders: "Authorization",
|
|
144
|
+
maxAge: 86400
|
|
145
|
+
} : undefined;
|
|
146
|
+
} else {
|
|
147
|
+
config.cors = this.config.cors;
|
|
238
148
|
}
|
|
239
149
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// src/cli/index.ts
|
|
244
|
-
import { watch } from "fs";
|
|
245
|
-
import { parseArgs } from "util";
|
|
246
|
-
|
|
247
|
-
// src/auth/protected.ts
|
|
248
|
-
class AuthManager {
|
|
249
|
-
protectedHandler = null;
|
|
250
|
-
setProtectedHandler(handler) {
|
|
251
|
-
this.protectedHandler = handler;
|
|
252
|
-
}
|
|
253
|
-
async authenticate(request) {
|
|
254
|
-
if (!this.protectedHandler) {
|
|
255
|
-
throw new Error("Protected handler not configured. Use vector.protected() to set authentication handler.");
|
|
150
|
+
if (this.config?.before) {
|
|
151
|
+
config.before = this.config.before;
|
|
256
152
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
request.authUser = authUser;
|
|
260
|
-
return authUser;
|
|
261
|
-
} catch (error) {
|
|
262
|
-
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
153
|
+
if (this.config?.after) {
|
|
154
|
+
config.finally = this.config.after;
|
|
263
155
|
}
|
|
156
|
+
return config;
|
|
264
157
|
}
|
|
265
|
-
|
|
266
|
-
return
|
|
158
|
+
async loadAuthHandler() {
|
|
159
|
+
return this.config?.auth || null;
|
|
267
160
|
}
|
|
268
|
-
|
|
269
|
-
return
|
|
161
|
+
async loadCacheHandler() {
|
|
162
|
+
return this.config?.cache || null;
|
|
163
|
+
}
|
|
164
|
+
getConfig() {
|
|
165
|
+
return this.config;
|
|
270
166
|
}
|
|
271
167
|
}
|
|
272
168
|
|
|
@@ -345,6 +241,41 @@ var CONTENT_TYPES = {
|
|
|
345
241
|
FORM_URLENCODED: "application/x-www-form-urlencoded",
|
|
346
242
|
MULTIPART: "multipart/form-data"
|
|
347
243
|
};
|
|
244
|
+
var STATIC_RESPONSES = {
|
|
245
|
+
NOT_FOUND: new Response(JSON.stringify({ error: true, message: "Not Found", statusCode: 404 }), {
|
|
246
|
+
status: 404,
|
|
247
|
+
headers: { "content-type": "application/json" }
|
|
248
|
+
})
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// src/auth/protected.ts
|
|
252
|
+
class AuthManager {
|
|
253
|
+
protectedHandler = null;
|
|
254
|
+
setProtectedHandler(handler) {
|
|
255
|
+
this.protectedHandler = handler;
|
|
256
|
+
}
|
|
257
|
+
clearProtectedHandler() {
|
|
258
|
+
this.protectedHandler = null;
|
|
259
|
+
}
|
|
260
|
+
async authenticate(request) {
|
|
261
|
+
if (!this.protectedHandler) {
|
|
262
|
+
throw new Error("Protected handler not configured. Use vector.protected() to set authentication handler.");
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const authUser = await this.protectedHandler(request);
|
|
266
|
+
request.authUser = authUser;
|
|
267
|
+
return authUser;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
isAuthenticated(request) {
|
|
273
|
+
return !!request.authUser;
|
|
274
|
+
}
|
|
275
|
+
getUser(request) {
|
|
276
|
+
return request.authUser || null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
348
279
|
|
|
349
280
|
// src/cache/manager.ts
|
|
350
281
|
class CacheManager {
|
|
@@ -355,6 +286,9 @@ class CacheManager {
|
|
|
355
286
|
setCacheHandler(handler) {
|
|
356
287
|
this.cacheHandler = handler;
|
|
357
288
|
}
|
|
289
|
+
clearCacheHandler() {
|
|
290
|
+
this.cacheHandler = null;
|
|
291
|
+
}
|
|
358
292
|
async get(key, factory, ttl = DEFAULT_CONFIG.CACHE_TTL) {
|
|
359
293
|
if (ttl <= 0) {
|
|
360
294
|
return factory();
|
|
@@ -444,33 +378,227 @@ class CacheManager {
|
|
|
444
378
|
}
|
|
445
379
|
generateKey(request, options) {
|
|
446
380
|
const url = request._parsedUrl ?? new URL(request.url);
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
url.pathname,
|
|
450
|
-
url.search,
|
|
451
|
-
options?.authUser?.id != null ? String(options.authUser.id) : "anonymous"
|
|
452
|
-
];
|
|
453
|
-
return parts.join(":");
|
|
381
|
+
const userId = options?.authUser?.id != null ? String(options.authUser.id) : "anonymous";
|
|
382
|
+
return `${request.method}:${url.pathname}:${url.search}:${userId}`;
|
|
454
383
|
}
|
|
455
384
|
}
|
|
456
385
|
|
|
457
|
-
// src/
|
|
458
|
-
|
|
459
|
-
|
|
386
|
+
// src/dev/route-generator.ts
|
|
387
|
+
import { promises as fs } from "fs";
|
|
388
|
+
import { dirname, relative } from "path";
|
|
460
389
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
addBefore(...handlers) {
|
|
466
|
-
this.beforeHandlers.push(...handlers);
|
|
467
|
-
}
|
|
468
|
-
addFinally(...handlers) {
|
|
469
|
-
this.finallyHandlers.push(...handlers);
|
|
390
|
+
class RouteGenerator {
|
|
391
|
+
outputPath;
|
|
392
|
+
constructor(outputPath = "./.vector/routes.generated.ts") {
|
|
393
|
+
this.outputPath = outputPath;
|
|
470
394
|
}
|
|
471
|
-
async
|
|
472
|
-
|
|
473
|
-
|
|
395
|
+
async generate(routes) {
|
|
396
|
+
const outputDir = dirname(this.outputPath);
|
|
397
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
398
|
+
const imports = [];
|
|
399
|
+
const groupedByFile = new Map;
|
|
400
|
+
for (const route of routes) {
|
|
401
|
+
if (!groupedByFile.has(route.path)) {
|
|
402
|
+
groupedByFile.set(route.path, []);
|
|
403
|
+
}
|
|
404
|
+
groupedByFile.get(route.path).push(route);
|
|
405
|
+
}
|
|
406
|
+
let importIndex = 0;
|
|
407
|
+
const routeEntries = [];
|
|
408
|
+
for (const [filePath, fileRoutes] of groupedByFile) {
|
|
409
|
+
const relativePath = relative(dirname(this.outputPath), filePath).replace(/\\/g, "/").replace(/\.(ts|js)$/, "");
|
|
410
|
+
const importName = `route_${importIndex++}`;
|
|
411
|
+
const namedImports = fileRoutes.filter((r) => r.name !== "default").map((r) => r.name);
|
|
412
|
+
if (fileRoutes.some((r) => r.name === "default")) {
|
|
413
|
+
if (namedImports.length > 0) {
|
|
414
|
+
imports.push(`import ${importName}, { ${namedImports.join(", ")} } from '${relativePath}';`);
|
|
415
|
+
} else {
|
|
416
|
+
imports.push(`import ${importName} from '${relativePath}';`);
|
|
417
|
+
}
|
|
418
|
+
} else if (namedImports.length > 0) {
|
|
419
|
+
imports.push(`import { ${namedImports.join(", ")} } from '${relativePath}';`);
|
|
420
|
+
}
|
|
421
|
+
for (const route of fileRoutes) {
|
|
422
|
+
const routeVar = route.name === "default" ? importName : route.name;
|
|
423
|
+
routeEntries.push(` ${routeVar},`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const content = `// This file is auto-generated. Do not edit manually.
|
|
427
|
+
// Generated at: ${new Date().toISOString()}
|
|
428
|
+
|
|
429
|
+
${imports.join(`
|
|
430
|
+
`)}
|
|
431
|
+
|
|
432
|
+
export const routes = [
|
|
433
|
+
${routeEntries.join(`
|
|
434
|
+
`)}
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
export default routes;
|
|
438
|
+
`;
|
|
439
|
+
await fs.writeFile(this.outputPath, content, "utf-8");
|
|
440
|
+
}
|
|
441
|
+
async generateDynamic(routes) {
|
|
442
|
+
const routeEntries = [];
|
|
443
|
+
for (const route of routes) {
|
|
444
|
+
const routeObj = JSON.stringify({
|
|
445
|
+
method: route.method,
|
|
446
|
+
path: route.options.path,
|
|
447
|
+
options: route.options
|
|
448
|
+
});
|
|
449
|
+
routeEntries.push(` await import('${route.path}').then(m => ({
|
|
450
|
+
...${routeObj},
|
|
451
|
+
handler: m.${route.name === "default" ? "default" : route.name}
|
|
452
|
+
}))`);
|
|
453
|
+
}
|
|
454
|
+
return `export const loadRoutes = async () => {
|
|
455
|
+
return Promise.all([
|
|
456
|
+
${routeEntries.join(`,
|
|
457
|
+
`)}
|
|
458
|
+
]);
|
|
459
|
+
};`;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/dev/route-scanner.ts
|
|
464
|
+
import { existsSync as existsSync2, promises as fs2 } from "fs";
|
|
465
|
+
import { join, relative as relative2, resolve as resolve2, sep } from "path";
|
|
466
|
+
|
|
467
|
+
class RouteScanner {
|
|
468
|
+
routesDir;
|
|
469
|
+
excludePatterns;
|
|
470
|
+
static DEFAULT_EXCLUDE_PATTERNS = [
|
|
471
|
+
"*.test.ts",
|
|
472
|
+
"*.test.js",
|
|
473
|
+
"*.test.tsx",
|
|
474
|
+
"*.test.jsx",
|
|
475
|
+
"*.spec.ts",
|
|
476
|
+
"*.spec.js",
|
|
477
|
+
"*.spec.tsx",
|
|
478
|
+
"*.spec.jsx",
|
|
479
|
+
"*.tests.ts",
|
|
480
|
+
"*.tests.js",
|
|
481
|
+
"**/__tests__/**",
|
|
482
|
+
"*.interface.ts",
|
|
483
|
+
"*.type.ts",
|
|
484
|
+
"*.d.ts"
|
|
485
|
+
];
|
|
486
|
+
constructor(routesDir = "./routes", excludePatterns) {
|
|
487
|
+
this.routesDir = resolve2(process.cwd(), routesDir);
|
|
488
|
+
this.excludePatterns = excludePatterns || RouteScanner.DEFAULT_EXCLUDE_PATTERNS;
|
|
489
|
+
}
|
|
490
|
+
async scan() {
|
|
491
|
+
const routes = [];
|
|
492
|
+
if (!existsSync2(this.routesDir)) {
|
|
493
|
+
return [];
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
await this.scanDirectory(this.routesDir, routes);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (error.code === "ENOENT") {
|
|
499
|
+
console.warn(` \u2717 Routes directory not accessible: ${this.routesDir}`);
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
return routes;
|
|
505
|
+
}
|
|
506
|
+
isExcluded(filePath) {
|
|
507
|
+
const relativePath = relative2(this.routesDir, filePath);
|
|
508
|
+
for (const pattern of this.excludePatterns) {
|
|
509
|
+
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "__GLOBSTAR__").replace(/\*/g, "[^/]*").replace(/__GLOBSTAR__/g, ".*").replace(/\?/g, ".");
|
|
510
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
511
|
+
const filename = relativePath.split(sep).pop() || "";
|
|
512
|
+
if (regex.test(relativePath) || regex.test(filename)) {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
async scanDirectory(dir, routes, basePath = "") {
|
|
519
|
+
const entries = await fs2.readdir(dir);
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
const fullPath = join(dir, entry);
|
|
522
|
+
const stats = await fs2.stat(fullPath);
|
|
523
|
+
if (stats.isDirectory()) {
|
|
524
|
+
const newBasePath = basePath ? `${basePath}/${entry}` : entry;
|
|
525
|
+
await this.scanDirectory(fullPath, routes, newBasePath);
|
|
526
|
+
} else if (entry.endsWith(".ts") || entry.endsWith(".js")) {
|
|
527
|
+
if (this.isExcluded(fullPath)) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const routePath = relative2(this.routesDir, fullPath).replace(/\.(ts|js)$/, "").split(sep).join("/");
|
|
531
|
+
try {
|
|
532
|
+
const importPath = process.platform === "win32" ? `file:///${fullPath.replace(/\\/g, "/")}` : fullPath;
|
|
533
|
+
const module = await import(importPath);
|
|
534
|
+
if (module.default && typeof module.default === "function") {
|
|
535
|
+
routes.push({
|
|
536
|
+
name: "default",
|
|
537
|
+
path: fullPath,
|
|
538
|
+
method: "GET",
|
|
539
|
+
options: {
|
|
540
|
+
method: "GET",
|
|
541
|
+
path: `/${routePath}`,
|
|
542
|
+
expose: true
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
for (const [name, value] of Object.entries(module)) {
|
|
547
|
+
if (name === "default")
|
|
548
|
+
continue;
|
|
549
|
+
if (value && typeof value === "object" && "entry" in value && "options" in value && "handler" in value) {
|
|
550
|
+
const routeDef = value;
|
|
551
|
+
routes.push({
|
|
552
|
+
name,
|
|
553
|
+
path: fullPath,
|
|
554
|
+
method: routeDef.options.method,
|
|
555
|
+
options: routeDef.options
|
|
556
|
+
});
|
|
557
|
+
} else if (Array.isArray(value) && value.length >= 4) {
|
|
558
|
+
const [method, , , path] = value;
|
|
559
|
+
routes.push({
|
|
560
|
+
name,
|
|
561
|
+
path: fullPath,
|
|
562
|
+
method,
|
|
563
|
+
options: {
|
|
564
|
+
method,
|
|
565
|
+
path,
|
|
566
|
+
expose: true
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch (error) {
|
|
572
|
+
console.error(`Failed to load route from ${fullPath}:`, error);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
enableWatch(callback) {
|
|
578
|
+
if (typeof Bun !== "undefined" && Bun.env.NODE_ENV === "development") {
|
|
579
|
+
console.log(`Watching for route changes in ${this.routesDir}`);
|
|
580
|
+
setInterval(async () => {
|
|
581
|
+
await callback();
|
|
582
|
+
}, 1000);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/middleware/manager.ts
|
|
588
|
+
class MiddlewareManager {
|
|
589
|
+
beforeHandlers = [];
|
|
590
|
+
finallyHandlers = [];
|
|
591
|
+
addBefore(...handlers) {
|
|
592
|
+
this.beforeHandlers.push(...handlers);
|
|
593
|
+
}
|
|
594
|
+
addFinally(...handlers) {
|
|
595
|
+
this.finallyHandlers.push(...handlers);
|
|
596
|
+
}
|
|
597
|
+
async executeBefore(request) {
|
|
598
|
+
if (this.beforeHandlers.length === 0)
|
|
599
|
+
return request;
|
|
600
|
+
let currentRequest = request;
|
|
601
|
+
for (const handler of this.beforeHandlers) {
|
|
474
602
|
const result = await handler(currentRequest);
|
|
475
603
|
if (result instanceof Response) {
|
|
476
604
|
return result;
|
|
@@ -480,6 +608,8 @@ class MiddlewareManager {
|
|
|
480
608
|
return currentRequest;
|
|
481
609
|
}
|
|
482
610
|
async executeFinally(response, request) {
|
|
611
|
+
if (this.finallyHandlers.length === 0)
|
|
612
|
+
return response;
|
|
483
613
|
let currentResponse = response;
|
|
484
614
|
for (const handler of this.finallyHandlers) {
|
|
485
615
|
try {
|
|
@@ -502,69 +632,17 @@ class MiddlewareManager {
|
|
|
502
632
|
}
|
|
503
633
|
}
|
|
504
634
|
|
|
505
|
-
// src/utils/path.ts
|
|
506
|
-
function toFileUrl(path) {
|
|
507
|
-
return process.platform === "win32" ? `file:///${path.replace(/\\/g, "/")}` : path;
|
|
508
|
-
}
|
|
509
|
-
function buildRouteRegex(path) {
|
|
510
|
-
return RegExp(`^${path.replace(/\/+(\/|$)/g, "$1").replace(/(\/?\.?):(\w+)\+/g, "($1(?<$2>[\\s\\S]+))").replace(/(\/?\.?):(\w+)/g, "($1(?<$2>[^$1/]+?))").replace(/\./g, "\\.").replace(/(\/?)\*/g, "($1.*)?")}/*$`);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// node_modules/itty-router/index.mjs
|
|
514
|
-
var r = (e = "text/plain; charset=utf-8", t) => (r2, o = {}) => {
|
|
515
|
-
if (r2 === undefined || r2 instanceof Response)
|
|
516
|
-
return r2;
|
|
517
|
-
const a = new Response(t?.(r2) ?? r2, o.url ? undefined : o);
|
|
518
|
-
return a.headers.set("content-type", e), a;
|
|
519
|
-
};
|
|
520
|
-
var o = r("application/json; charset=utf-8", JSON.stringify);
|
|
521
|
-
var p = r("text/plain; charset=utf-8", String);
|
|
522
|
-
var f = r("text/html");
|
|
523
|
-
var u = r("image/jpeg");
|
|
524
|
-
var h = r("image/png");
|
|
525
|
-
var g = r("image/webp");
|
|
526
|
-
var y = (e = {}) => {
|
|
527
|
-
const { origin: t = "*", credentials: r2 = false, allowMethods: o2 = "*", allowHeaders: a, exposeHeaders: s, maxAge: c } = e, n = (e2) => {
|
|
528
|
-
const o3 = e2?.headers.get("origin");
|
|
529
|
-
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;
|
|
530
|
-
}, l = (e2, t2) => {
|
|
531
|
-
for (const [r3, o3] of Object.entries(t2))
|
|
532
|
-
o3 && e2.headers.append(r3, o3);
|
|
533
|
-
return e2;
|
|
534
|
-
};
|
|
535
|
-
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) => {
|
|
536
|
-
if (e2.method == "OPTIONS") {
|
|
537
|
-
const t2 = new Response(null, { status: 204 });
|
|
538
|
-
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 });
|
|
539
|
-
}
|
|
540
|
-
} };
|
|
541
|
-
};
|
|
542
|
-
|
|
543
635
|
// src/http.ts
|
|
544
|
-
var { preflight, corsify } = y({
|
|
545
|
-
origin: "*",
|
|
546
|
-
credentials: true,
|
|
547
|
-
allowHeaders: "Content-Type, Authorization",
|
|
548
|
-
allowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
549
|
-
exposeHeaders: "Authorization",
|
|
550
|
-
maxAge: 86400
|
|
551
|
-
});
|
|
552
|
-
function hasBigInt(value, depth = 0) {
|
|
553
|
-
if (typeof value === "bigint")
|
|
554
|
-
return true;
|
|
555
|
-
if (depth > 4 || value === null || typeof value !== "object")
|
|
556
|
-
return false;
|
|
557
|
-
for (const v of Object.values(value)) {
|
|
558
|
-
if (hasBigInt(v, depth + 1))
|
|
559
|
-
return true;
|
|
560
|
-
}
|
|
561
|
-
return false;
|
|
562
|
-
}
|
|
563
636
|
function stringifyData(data) {
|
|
564
637
|
const val = data ?? null;
|
|
565
|
-
|
|
638
|
+
try {
|
|
566
639
|
return JSON.stringify(val);
|
|
567
|
-
|
|
640
|
+
} catch (e) {
|
|
641
|
+
if (e instanceof TypeError && /\bbigint\b/i.test(e.message)) {
|
|
642
|
+
return JSON.stringify(val, (_key, value) => typeof value === "bigint" ? value.toString() : value);
|
|
643
|
+
}
|
|
644
|
+
throw e;
|
|
645
|
+
}
|
|
568
646
|
}
|
|
569
647
|
function createErrorResponse(code, message, contentType) {
|
|
570
648
|
const errorBody = {
|
|
@@ -628,90 +706,232 @@ function createResponse(statusCode, data, contentType = CONTENT_TYPES.JSON) {
|
|
|
628
706
|
});
|
|
629
707
|
}
|
|
630
708
|
|
|
709
|
+
// src/utils/schema-validation.ts
|
|
710
|
+
function isStandardRouteSchema(schema) {
|
|
711
|
+
const standard = schema?.["~standard"];
|
|
712
|
+
return !!standard && typeof standard === "object" && typeof standard.validate === "function" && standard.version === 1;
|
|
713
|
+
}
|
|
714
|
+
async function runStandardValidation(schema, value) {
|
|
715
|
+
const result = await schema["~standard"].validate(value);
|
|
716
|
+
const issues = result?.issues;
|
|
717
|
+
if (Array.isArray(issues) && issues.length > 0) {
|
|
718
|
+
return { success: false, issues };
|
|
719
|
+
}
|
|
720
|
+
return { success: true, value: result?.value };
|
|
721
|
+
}
|
|
722
|
+
function extractThrownIssues(error) {
|
|
723
|
+
if (Array.isArray(error)) {
|
|
724
|
+
return error;
|
|
725
|
+
}
|
|
726
|
+
if (error && typeof error === "object" && Array.isArray(error.issues)) {
|
|
727
|
+
return error.issues;
|
|
728
|
+
}
|
|
729
|
+
if (error && typeof error === "object" && error.cause && Array.isArray(error.cause.issues)) {
|
|
730
|
+
return error.cause.issues;
|
|
731
|
+
}
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
function normalizePath(path) {
|
|
735
|
+
if (!Array.isArray(path))
|
|
736
|
+
return [];
|
|
737
|
+
const normalized = [];
|
|
738
|
+
for (let i = 0;i < path.length; i++) {
|
|
739
|
+
const segment = path[i];
|
|
740
|
+
let value = segment;
|
|
741
|
+
if (segment && typeof segment === "object" && "key" in segment) {
|
|
742
|
+
value = segment.key;
|
|
743
|
+
}
|
|
744
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
745
|
+
normalized.push(value);
|
|
746
|
+
} else if (typeof value === "symbol") {
|
|
747
|
+
normalized.push(String(value));
|
|
748
|
+
} else if (value !== undefined && value !== null) {
|
|
749
|
+
normalized.push(String(value));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return normalized;
|
|
753
|
+
}
|
|
754
|
+
function normalizeValidationIssues(issues, includeRawIssues) {
|
|
755
|
+
const normalized = [];
|
|
756
|
+
for (let i = 0;i < issues.length; i++) {
|
|
757
|
+
const issue = issues[i];
|
|
758
|
+
const maybeIssue = issue;
|
|
759
|
+
const normalizedIssue = {
|
|
760
|
+
message: typeof maybeIssue?.message === "string" && maybeIssue.message.length > 0 ? maybeIssue.message : "Invalid value",
|
|
761
|
+
path: normalizePath(maybeIssue?.path)
|
|
762
|
+
};
|
|
763
|
+
if (typeof maybeIssue?.code === "string") {
|
|
764
|
+
normalizedIssue.code = maybeIssue.code;
|
|
765
|
+
}
|
|
766
|
+
if (includeRawIssues) {
|
|
767
|
+
normalizedIssue.raw = issue;
|
|
768
|
+
}
|
|
769
|
+
normalized.push(normalizedIssue);
|
|
770
|
+
}
|
|
771
|
+
return normalized;
|
|
772
|
+
}
|
|
773
|
+
function createValidationErrorPayload(target, issues) {
|
|
774
|
+
return {
|
|
775
|
+
error: true,
|
|
776
|
+
message: "Validation failed",
|
|
777
|
+
statusCode: 422,
|
|
778
|
+
source: "validation",
|
|
779
|
+
target,
|
|
780
|
+
issues,
|
|
781
|
+
timestamp: new Date().toISOString()
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
631
785
|
// src/core/router.ts
|
|
632
786
|
class VectorRouter {
|
|
633
787
|
middlewareManager;
|
|
634
788
|
authManager;
|
|
635
789
|
cacheManager;
|
|
636
|
-
|
|
637
|
-
|
|
790
|
+
routeBooleanDefaults = {};
|
|
791
|
+
developmentMode = undefined;
|
|
792
|
+
routeDefinitions = [];
|
|
793
|
+
routeTable = Object.create(null);
|
|
794
|
+
routeMatchers = [];
|
|
795
|
+
corsHeadersEntries = null;
|
|
796
|
+
corsHandler = null;
|
|
638
797
|
constructor(middlewareManager, authManager, cacheManager) {
|
|
639
798
|
this.middlewareManager = middlewareManager;
|
|
640
799
|
this.authManager = authManager;
|
|
641
800
|
this.cacheManager = cacheManager;
|
|
642
801
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
802
|
+
setCorsHeaders(entries) {
|
|
803
|
+
this.corsHeadersEntries = entries;
|
|
804
|
+
}
|
|
805
|
+
setCorsHandler(handler) {
|
|
806
|
+
this.corsHandler = handler;
|
|
807
|
+
}
|
|
808
|
+
setRouteBooleanDefaults(defaults) {
|
|
809
|
+
this.routeBooleanDefaults = { ...defaults };
|
|
810
|
+
}
|
|
811
|
+
setDevelopmentMode(mode) {
|
|
812
|
+
this.developmentMode = mode;
|
|
813
|
+
}
|
|
814
|
+
applyRouteBooleanDefaults(options) {
|
|
815
|
+
const resolved = { ...options };
|
|
816
|
+
const defaults = this.routeBooleanDefaults;
|
|
817
|
+
const keys = ["auth", "expose", "rawRequest", "validate", "rawResponse"];
|
|
818
|
+
for (const key of keys) {
|
|
819
|
+
if (resolved[key] === undefined && defaults[key] !== undefined) {
|
|
820
|
+
resolved[key] = defaults[key];
|
|
660
821
|
}
|
|
661
822
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
823
|
+
return resolved;
|
|
824
|
+
}
|
|
825
|
+
route(options, handler) {
|
|
826
|
+
const resolvedOptions = this.applyRouteBooleanDefaults(options);
|
|
827
|
+
const method = resolvedOptions.method.toUpperCase();
|
|
828
|
+
const path = resolvedOptions.path;
|
|
829
|
+
const wrappedHandler = this.wrapHandler(resolvedOptions, handler);
|
|
830
|
+
const methodMap = this.getOrCreateMethodMap(path);
|
|
831
|
+
methodMap[method] = wrappedHandler;
|
|
832
|
+
this.routeDefinitions.push({
|
|
833
|
+
method,
|
|
834
|
+
path,
|
|
835
|
+
options: resolvedOptions
|
|
836
|
+
});
|
|
668
837
|
}
|
|
669
|
-
|
|
670
|
-
|
|
838
|
+
addRoute(entry) {
|
|
839
|
+
const [method, , handlers, path] = entry;
|
|
840
|
+
if (!path)
|
|
841
|
+
return;
|
|
842
|
+
const methodMap = this.getOrCreateMethodMap(path);
|
|
843
|
+
methodMap[method.toUpperCase()] = handlers[0];
|
|
844
|
+
const normalizedMethod = method.toUpperCase();
|
|
845
|
+
this.routeDefinitions.push({
|
|
846
|
+
method: normalizedMethod,
|
|
847
|
+
path,
|
|
848
|
+
options: {
|
|
849
|
+
method: normalizedMethod,
|
|
850
|
+
path,
|
|
851
|
+
expose: true
|
|
852
|
+
}
|
|
853
|
+
});
|
|
671
854
|
}
|
|
672
|
-
|
|
673
|
-
|
|
855
|
+
bulkAddRoutes(entries) {
|
|
856
|
+
for (const entry of entries) {
|
|
857
|
+
this.addRoute(entry);
|
|
858
|
+
}
|
|
674
859
|
}
|
|
675
|
-
|
|
676
|
-
|
|
860
|
+
addStaticRoute(path, response) {
|
|
861
|
+
const existing = this.routeTable[path];
|
|
862
|
+
if (existing && !(existing instanceof Response)) {
|
|
863
|
+
throw new Error(`Cannot register static route for path "${path}" because method routes already exist.`);
|
|
864
|
+
}
|
|
865
|
+
this.routeTable[path] = response;
|
|
866
|
+
this.removeRouteMatcher(path);
|
|
677
867
|
}
|
|
678
|
-
|
|
679
|
-
return
|
|
868
|
+
getRouteTable() {
|
|
869
|
+
return this.routeTable;
|
|
680
870
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
871
|
+
getRoutes() {
|
|
872
|
+
const routes = [];
|
|
873
|
+
for (const matcher of this.routeMatchers) {
|
|
874
|
+
const value = this.routeTable[matcher.path];
|
|
875
|
+
if (!value || value instanceof Response)
|
|
876
|
+
continue;
|
|
877
|
+
for (const [method, handler] of Object.entries(value)) {
|
|
878
|
+
routes.push([method, matcher.regex, [handler], matcher.path]);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return routes;
|
|
689
882
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return route[PATH_INDEX] || "";
|
|
883
|
+
getRouteDefinitions() {
|
|
884
|
+
return [...this.routeDefinitions];
|
|
693
885
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
886
|
+
clearRoutes() {
|
|
887
|
+
this.routeTable = Object.create(null);
|
|
888
|
+
this.routeMatchers = [];
|
|
889
|
+
this.routeDefinitions = [];
|
|
890
|
+
}
|
|
891
|
+
sortRoutes() {}
|
|
892
|
+
async handle(request) {
|
|
893
|
+
let url;
|
|
894
|
+
try {
|
|
895
|
+
url = new URL(request.url);
|
|
896
|
+
} catch {
|
|
897
|
+
return APIError.badRequest("Malformed request URL");
|
|
898
|
+
}
|
|
899
|
+
request._parsedUrl = url;
|
|
900
|
+
const pathname = url.pathname;
|
|
901
|
+
for (const matcher of this.routeMatchers) {
|
|
902
|
+
const path = matcher.path;
|
|
903
|
+
const value = this.routeTable[path];
|
|
904
|
+
if (!value)
|
|
905
|
+
continue;
|
|
906
|
+
if (value instanceof Response)
|
|
907
|
+
continue;
|
|
908
|
+
const methodMap = value;
|
|
909
|
+
if (request.method === "OPTIONS" || request.method in methodMap) {
|
|
910
|
+
const match = pathname.match(matcher.regex);
|
|
911
|
+
if (match) {
|
|
912
|
+
try {
|
|
913
|
+
request.params = match.groups ?? {};
|
|
914
|
+
} catch {}
|
|
915
|
+
const handler = methodMap[request.method] ?? methodMap["GET"];
|
|
916
|
+
if (handler) {
|
|
917
|
+
const response = await handler(request);
|
|
918
|
+
if (response)
|
|
919
|
+
return response;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return STATIC_RESPONSES.NOT_FOUND.clone();
|
|
708
925
|
}
|
|
709
926
|
prepareRequest(request, options) {
|
|
710
927
|
if (!request.context) {
|
|
711
928
|
request.context = {};
|
|
712
929
|
}
|
|
713
|
-
|
|
714
|
-
|
|
930
|
+
const hasEmptyParamsObject = !!request.params && typeof request.params === "object" && !Array.isArray(request.params) && Object.keys(request.params).length === 0;
|
|
931
|
+
if (options?.params !== undefined && (request.params === undefined || hasEmptyParamsObject)) {
|
|
932
|
+
try {
|
|
933
|
+
request.params = options.params;
|
|
934
|
+
} catch {}
|
|
715
935
|
}
|
|
716
936
|
if (options?.route !== undefined) {
|
|
717
937
|
request.route = options.route;
|
|
@@ -719,21 +939,37 @@ class VectorRouter {
|
|
|
719
939
|
if (options?.metadata !== undefined) {
|
|
720
940
|
request.metadata = options.metadata;
|
|
721
941
|
}
|
|
722
|
-
if (
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
942
|
+
if (request.query == null && request.url) {
|
|
943
|
+
try {
|
|
944
|
+
Object.defineProperty(request, "query", {
|
|
945
|
+
get() {
|
|
946
|
+
const url = this._parsedUrl ?? new URL(this.url);
|
|
947
|
+
const query = VectorRouter.parseQuery(url);
|
|
948
|
+
Object.defineProperty(this, "query", {
|
|
949
|
+
value: query,
|
|
950
|
+
writable: true,
|
|
951
|
+
configurable: true,
|
|
952
|
+
enumerable: true
|
|
953
|
+
});
|
|
954
|
+
return query;
|
|
955
|
+
},
|
|
956
|
+
set(value) {
|
|
957
|
+
Object.defineProperty(this, "query", {
|
|
958
|
+
value,
|
|
959
|
+
writable: true,
|
|
960
|
+
configurable: true,
|
|
961
|
+
enumerable: true
|
|
962
|
+
});
|
|
963
|
+
},
|
|
964
|
+
configurable: true,
|
|
965
|
+
enumerable: true
|
|
966
|
+
});
|
|
967
|
+
} catch {
|
|
968
|
+
const url = request._parsedUrl ?? new URL(request.url);
|
|
969
|
+
try {
|
|
970
|
+
request.query = VectorRouter.parseQuery(url);
|
|
971
|
+
} catch {}
|
|
735
972
|
}
|
|
736
|
-
request.query = query;
|
|
737
973
|
}
|
|
738
974
|
if (!Object.getOwnPropertyDescriptor(request, "cookies")) {
|
|
739
975
|
Object.defineProperty(request, "cookies", {
|
|
@@ -761,71 +997,111 @@ class VectorRouter {
|
|
|
761
997
|
});
|
|
762
998
|
}
|
|
763
999
|
}
|
|
1000
|
+
resolveFallbackParams(request, routeMatcher) {
|
|
1001
|
+
if (!routeMatcher) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const currentParams = request.params;
|
|
1005
|
+
if (currentParams && typeof currentParams === "object" && !Array.isArray(currentParams) && Object.keys(currentParams).length > 0) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
let pathname;
|
|
1009
|
+
try {
|
|
1010
|
+
pathname = (request._parsedUrl ?? new URL(request.url)).pathname;
|
|
1011
|
+
} catch {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const matched = pathname.match(routeMatcher);
|
|
1015
|
+
if (!matched?.groups) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
return matched.groups;
|
|
1019
|
+
}
|
|
764
1020
|
wrapHandler(options, handler) {
|
|
1021
|
+
const routePath = options.path;
|
|
1022
|
+
const routeMatcher = routePath.includes(":") ? buildRouteRegex(routePath) : null;
|
|
765
1023
|
return async (request) => {
|
|
766
1024
|
const vectorRequest = request;
|
|
1025
|
+
const fallbackParams = this.resolveFallbackParams(request, routeMatcher);
|
|
767
1026
|
this.prepareRequest(vectorRequest, {
|
|
1027
|
+
params: fallbackParams,
|
|
1028
|
+
route: routePath,
|
|
768
1029
|
metadata: options.metadata
|
|
769
1030
|
});
|
|
770
|
-
request = vectorRequest;
|
|
771
1031
|
try {
|
|
772
1032
|
if (options.expose === false) {
|
|
773
1033
|
return APIError.forbidden("Forbidden");
|
|
774
1034
|
}
|
|
775
|
-
const beforeResult = await this.middlewareManager.executeBefore(
|
|
1035
|
+
const beforeResult = await this.middlewareManager.executeBefore(vectorRequest);
|
|
776
1036
|
if (beforeResult instanceof Response) {
|
|
777
1037
|
return beforeResult;
|
|
778
1038
|
}
|
|
779
|
-
|
|
1039
|
+
const req = beforeResult;
|
|
780
1040
|
if (options.auth) {
|
|
781
1041
|
try {
|
|
782
|
-
await this.authManager.authenticate(
|
|
1042
|
+
await this.authManager.authenticate(req);
|
|
783
1043
|
} catch (error) {
|
|
784
1044
|
return APIError.unauthorized(error instanceof Error ? error.message : "Authentication failed", options.responseContentType);
|
|
785
1045
|
}
|
|
786
1046
|
}
|
|
787
|
-
if (!options.rawRequest &&
|
|
1047
|
+
if (!options.rawRequest && req.method !== "GET" && req.method !== "HEAD") {
|
|
1048
|
+
let parsedContent = null;
|
|
788
1049
|
try {
|
|
789
|
-
const contentType =
|
|
790
|
-
if (contentType?.
|
|
791
|
-
|
|
792
|
-
} else if (contentType?.
|
|
793
|
-
|
|
794
|
-
} else if (contentType?.
|
|
795
|
-
|
|
1050
|
+
const contentType = req.headers.get("content-type");
|
|
1051
|
+
if (contentType?.startsWith("application/json")) {
|
|
1052
|
+
parsedContent = await req.json();
|
|
1053
|
+
} else if (contentType?.startsWith("application/x-www-form-urlencoded")) {
|
|
1054
|
+
parsedContent = Object.fromEntries(await req.formData());
|
|
1055
|
+
} else if (contentType?.startsWith("multipart/form-data")) {
|
|
1056
|
+
parsedContent = await req.formData();
|
|
796
1057
|
} else {
|
|
797
|
-
|
|
1058
|
+
parsedContent = await req.text();
|
|
798
1059
|
}
|
|
799
1060
|
} catch {
|
|
800
|
-
|
|
1061
|
+
parsedContent = null;
|
|
801
1062
|
}
|
|
1063
|
+
this.setContentAndBodyAlias(req, parsedContent);
|
|
1064
|
+
}
|
|
1065
|
+
const inputValidationResponse = await this.validateInputSchema(req, options);
|
|
1066
|
+
if (inputValidationResponse) {
|
|
1067
|
+
return inputValidationResponse;
|
|
802
1068
|
}
|
|
803
1069
|
let result;
|
|
804
1070
|
const cacheOptions = options.cache;
|
|
805
|
-
const cacheFactory = async () => {
|
|
806
|
-
const res = await handler(request);
|
|
807
|
-
if (res instanceof Response) {
|
|
808
|
-
return {
|
|
809
|
-
_isResponse: true,
|
|
810
|
-
body: await res.text(),
|
|
811
|
-
status: res.status,
|
|
812
|
-
headers: Object.fromEntries(res.headers.entries())
|
|
813
|
-
};
|
|
814
|
-
}
|
|
815
|
-
return res;
|
|
816
|
-
};
|
|
817
1071
|
if (cacheOptions && typeof cacheOptions === "number" && cacheOptions > 0) {
|
|
818
|
-
const cacheKey = this.cacheManager.generateKey(
|
|
819
|
-
authUser:
|
|
1072
|
+
const cacheKey = this.cacheManager.generateKey(req, {
|
|
1073
|
+
authUser: req.authUser
|
|
820
1074
|
});
|
|
821
|
-
result = await this.cacheManager.get(cacheKey,
|
|
1075
|
+
result = await this.cacheManager.get(cacheKey, async () => {
|
|
1076
|
+
const res = await handler(req);
|
|
1077
|
+
if (res instanceof Response) {
|
|
1078
|
+
return {
|
|
1079
|
+
_isResponse: true,
|
|
1080
|
+
body: await res.text(),
|
|
1081
|
+
status: res.status,
|
|
1082
|
+
headers: Object.fromEntries(res.headers.entries())
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
return res;
|
|
1086
|
+
}, cacheOptions);
|
|
822
1087
|
} else if (cacheOptions && typeof cacheOptions === "object" && cacheOptions.ttl) {
|
|
823
|
-
const cacheKey = cacheOptions.key || this.cacheManager.generateKey(
|
|
824
|
-
authUser:
|
|
1088
|
+
const cacheKey = cacheOptions.key || this.cacheManager.generateKey(req, {
|
|
1089
|
+
authUser: req.authUser
|
|
825
1090
|
});
|
|
826
|
-
result = await this.cacheManager.get(cacheKey,
|
|
1091
|
+
result = await this.cacheManager.get(cacheKey, async () => {
|
|
1092
|
+
const res = await handler(req);
|
|
1093
|
+
if (res instanceof Response) {
|
|
1094
|
+
return {
|
|
1095
|
+
_isResponse: true,
|
|
1096
|
+
body: await res.text(),
|
|
1097
|
+
status: res.status,
|
|
1098
|
+
headers: Object.fromEntries(res.headers.entries())
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
return res;
|
|
1102
|
+
}, cacheOptions.ttl);
|
|
827
1103
|
} else {
|
|
828
|
-
result = await handler(
|
|
1104
|
+
result = await handler(req);
|
|
829
1105
|
}
|
|
830
1106
|
if (result && typeof result === "object" && result._isResponse === true) {
|
|
831
1107
|
result = new Response(result.body, {
|
|
@@ -839,7 +1115,18 @@ class VectorRouter {
|
|
|
839
1115
|
} else {
|
|
840
1116
|
response = createResponse(200, result, options.responseContentType);
|
|
841
1117
|
}
|
|
842
|
-
response = await this.middlewareManager.executeFinally(response,
|
|
1118
|
+
response = await this.middlewareManager.executeFinally(response, req);
|
|
1119
|
+
const entries = this.corsHeadersEntries;
|
|
1120
|
+
if (entries) {
|
|
1121
|
+
for (const [k, v] of entries) {
|
|
1122
|
+
response.headers.set(k, v);
|
|
1123
|
+
}
|
|
1124
|
+
} else {
|
|
1125
|
+
const dynamicCors = this.corsHandler;
|
|
1126
|
+
if (dynamicCors) {
|
|
1127
|
+
response = dynamicCors(response, req);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
843
1130
|
return response;
|
|
844
1131
|
} catch (error) {
|
|
845
1132
|
if (error instanceof Response) {
|
|
@@ -850,69 +1137,2338 @@ class VectorRouter {
|
|
|
850
1137
|
}
|
|
851
1138
|
};
|
|
852
1139
|
}
|
|
853
|
-
|
|
854
|
-
this.
|
|
855
|
-
|
|
1140
|
+
isDevelopmentMode() {
|
|
1141
|
+
if (this.developmentMode !== undefined) {
|
|
1142
|
+
return this.developmentMode;
|
|
1143
|
+
}
|
|
1144
|
+
const nodeEnv = typeof Bun !== "undefined" ? Bun.env.NODE_ENV : "development";
|
|
1145
|
+
return nodeEnv !== "production";
|
|
1146
|
+
}
|
|
1147
|
+
async buildInputValidationPayload(request, options) {
|
|
1148
|
+
let body = request.content;
|
|
1149
|
+
if (options.rawRequest && request.method !== "GET" && request.method !== "HEAD") {
|
|
1150
|
+
try {
|
|
1151
|
+
body = await request.clone().text();
|
|
1152
|
+
} catch {
|
|
1153
|
+
body = null;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
params: request.params ?? {},
|
|
1158
|
+
query: request.query ?? {},
|
|
1159
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
1160
|
+
cookies: request.cookies ?? {},
|
|
1161
|
+
body
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
applyValidatedInput(request, validatedValue) {
|
|
1165
|
+
request.validatedInput = validatedValue;
|
|
1166
|
+
if (!validatedValue || typeof validatedValue !== "object") {
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const validated = validatedValue;
|
|
1170
|
+
if ("params" in validated) {
|
|
1171
|
+
try {
|
|
1172
|
+
request.params = validated.params;
|
|
1173
|
+
} catch {}
|
|
1174
|
+
}
|
|
1175
|
+
if ("query" in validated) {
|
|
1176
|
+
try {
|
|
1177
|
+
request.query = validated.query;
|
|
1178
|
+
} catch {}
|
|
1179
|
+
}
|
|
1180
|
+
if ("cookies" in validated) {
|
|
1181
|
+
try {
|
|
1182
|
+
request.cookies = validated.cookies;
|
|
1183
|
+
} catch {}
|
|
1184
|
+
}
|
|
1185
|
+
if ("body" in validated) {
|
|
1186
|
+
this.setContentAndBodyAlias(request, validated.body);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
setContentAndBodyAlias(request, value) {
|
|
1190
|
+
try {
|
|
1191
|
+
request.content = value;
|
|
1192
|
+
} catch {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
this.setBodyAlias(request, value);
|
|
1196
|
+
}
|
|
1197
|
+
setBodyAlias(request, value) {
|
|
1198
|
+
try {
|
|
1199
|
+
request.body = value;
|
|
1200
|
+
} catch {}
|
|
1201
|
+
}
|
|
1202
|
+
async validateInputSchema(request, options) {
|
|
1203
|
+
const inputSchema = options.schema?.input;
|
|
1204
|
+
if (!inputSchema) {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
if (options.validate === false) {
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
if (!isStandardRouteSchema(inputSchema)) {
|
|
1211
|
+
return APIError.internalServerError("Invalid route schema configuration", options.responseContentType);
|
|
1212
|
+
}
|
|
1213
|
+
const includeRawIssues = this.isDevelopmentMode();
|
|
1214
|
+
const payload = await this.buildInputValidationPayload(request, options);
|
|
1215
|
+
try {
|
|
1216
|
+
const validation = await runStandardValidation(inputSchema, payload);
|
|
1217
|
+
if (validation.success === false) {
|
|
1218
|
+
const issues = normalizeValidationIssues(validation.issues, includeRawIssues);
|
|
1219
|
+
return createResponse(422, createValidationErrorPayload("input", issues), options.responseContentType);
|
|
1220
|
+
}
|
|
1221
|
+
this.applyValidatedInput(request, validation.value);
|
|
1222
|
+
return null;
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
const thrownIssues = extractThrownIssues(error);
|
|
1225
|
+
if (thrownIssues) {
|
|
1226
|
+
const issues = normalizeValidationIssues(thrownIssues, includeRawIssues);
|
|
1227
|
+
return createResponse(422, createValidationErrorPayload("input", issues), options.responseContentType);
|
|
1228
|
+
}
|
|
1229
|
+
return APIError.internalServerError(error instanceof Error ? error.message : "Validation failed", options.responseContentType);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
getOrCreateMethodMap(path) {
|
|
1233
|
+
const existing = this.routeTable[path];
|
|
1234
|
+
if (existing instanceof Response) {
|
|
1235
|
+
throw new Error(`Cannot register method route for path "${path}" because a static route already exists.`);
|
|
1236
|
+
}
|
|
1237
|
+
if (existing) {
|
|
1238
|
+
return existing;
|
|
1239
|
+
}
|
|
1240
|
+
const methodMap = Object.create(null);
|
|
1241
|
+
this.routeTable[path] = methodMap;
|
|
1242
|
+
this.addRouteMatcher(path);
|
|
1243
|
+
return methodMap;
|
|
1244
|
+
}
|
|
1245
|
+
addRouteMatcher(path) {
|
|
1246
|
+
if (this.routeMatchers.some((matcher) => matcher.path === path)) {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
this.routeMatchers.push({
|
|
1250
|
+
path,
|
|
1251
|
+
regex: buildRouteRegex(path),
|
|
1252
|
+
specificity: this.routeSpecificityScore(path)
|
|
1253
|
+
});
|
|
1254
|
+
this.routeMatchers.sort((a, b) => {
|
|
1255
|
+
if (a.specificity !== b.specificity) {
|
|
1256
|
+
return b.specificity - a.specificity;
|
|
1257
|
+
}
|
|
1258
|
+
return a.path.localeCompare(b.path);
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
removeRouteMatcher(path) {
|
|
1262
|
+
this.routeMatchers = this.routeMatchers.filter((matcher) => matcher.path !== path);
|
|
1263
|
+
}
|
|
1264
|
+
static parseQuery(url) {
|
|
1265
|
+
const query = {};
|
|
1266
|
+
for (const [key, value] of url.searchParams) {
|
|
1267
|
+
if (key in query) {
|
|
1268
|
+
const existing = query[key];
|
|
1269
|
+
if (Array.isArray(existing)) {
|
|
1270
|
+
existing.push(value);
|
|
1271
|
+
} else {
|
|
1272
|
+
query[key] = [existing, value];
|
|
1273
|
+
}
|
|
1274
|
+
} else {
|
|
1275
|
+
query[key] = value;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return query;
|
|
1279
|
+
}
|
|
1280
|
+
routeSpecificityScore(path) {
|
|
1281
|
+
const STATIC_SEGMENT_WEIGHT = 1000;
|
|
1282
|
+
const PARAM_SEGMENT_WEIGHT = 10;
|
|
1283
|
+
const WILDCARD_WEIGHT = 1;
|
|
1284
|
+
const EXACT_MATCH_BONUS = 1e4;
|
|
1285
|
+
const segments = path.split("/").filter(Boolean);
|
|
1286
|
+
let score = 0;
|
|
1287
|
+
for (const segment of segments) {
|
|
1288
|
+
if (segment.includes("*")) {
|
|
1289
|
+
score += WILDCARD_WEIGHT;
|
|
1290
|
+
} else if (segment.startsWith(":")) {
|
|
1291
|
+
score += PARAM_SEGMENT_WEIGHT;
|
|
1292
|
+
} else {
|
|
1293
|
+
score += STATIC_SEGMENT_WEIGHT;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
score += path.length;
|
|
1297
|
+
if (!path.includes(":") && !path.includes("*")) {
|
|
1298
|
+
score += EXACT_MATCH_BONUS;
|
|
1299
|
+
}
|
|
1300
|
+
return score;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// src/core/server.ts
|
|
1305
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1306
|
+
import { join as join2 } from "path";
|
|
1307
|
+
|
|
1308
|
+
// src/utils/cors.ts
|
|
1309
|
+
function getAllowedOrigin(origin, config) {
|
|
1310
|
+
if (!origin) {
|
|
1311
|
+
if (typeof config.origin === "string") {
|
|
1312
|
+
if (config.origin === "*" && config.credentials)
|
|
1313
|
+
return null;
|
|
1314
|
+
return config.origin;
|
|
1315
|
+
}
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
if (typeof config.origin === "string") {
|
|
1319
|
+
if (config.origin === "*") {
|
|
1320
|
+
return config.credentials ? origin : "*";
|
|
1321
|
+
}
|
|
1322
|
+
return config.origin === origin ? origin : null;
|
|
1323
|
+
}
|
|
1324
|
+
if (Array.isArray(config.origin)) {
|
|
1325
|
+
return config.origin.includes(origin) ? origin : null;
|
|
1326
|
+
}
|
|
1327
|
+
if (typeof config.origin === "function") {
|
|
1328
|
+
return config.origin(origin) ? origin : null;
|
|
1329
|
+
}
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
function shouldVaryByOrigin(config) {
|
|
1333
|
+
return typeof config.origin === "string" && config.origin === "*" && config.credentials || Array.isArray(config.origin) || typeof config.origin === "function";
|
|
1334
|
+
}
|
|
1335
|
+
function buildCorsHeaders(origin, config, varyByOrigin) {
|
|
1336
|
+
const headers = {};
|
|
1337
|
+
if (origin) {
|
|
1338
|
+
headers["access-control-allow-origin"] = origin;
|
|
1339
|
+
headers["access-control-allow-methods"] = config.allowMethods;
|
|
1340
|
+
headers["access-control-allow-headers"] = config.allowHeaders;
|
|
1341
|
+
headers["access-control-expose-headers"] = config.exposeHeaders;
|
|
1342
|
+
headers["access-control-max-age"] = String(config.maxAge);
|
|
1343
|
+
if (config.credentials) {
|
|
1344
|
+
headers["access-control-allow-credentials"] = "true";
|
|
1345
|
+
}
|
|
1346
|
+
if (varyByOrigin) {
|
|
1347
|
+
headers.vary = "Origin";
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return headers;
|
|
1351
|
+
}
|
|
1352
|
+
function mergeVary(existing, nextValue) {
|
|
1353
|
+
if (!existing)
|
|
1354
|
+
return nextValue;
|
|
1355
|
+
const parts = existing.split(",").map((v) => v.trim()).filter(Boolean);
|
|
1356
|
+
const lower = parts.map((v) => v.toLowerCase());
|
|
1357
|
+
if (!lower.includes(nextValue.toLowerCase())) {
|
|
1358
|
+
parts.push(nextValue);
|
|
1359
|
+
}
|
|
1360
|
+
return parts.join(", ");
|
|
1361
|
+
}
|
|
1362
|
+
function cors(config) {
|
|
1363
|
+
return {
|
|
1364
|
+
preflight(request) {
|
|
1365
|
+
const origin = request.headers.get("origin") ?? undefined;
|
|
1366
|
+
const allowed = getAllowedOrigin(origin, config);
|
|
1367
|
+
const varyByOrigin = Boolean(origin && allowed && shouldVaryByOrigin(config));
|
|
1368
|
+
return new Response(null, {
|
|
1369
|
+
status: 204,
|
|
1370
|
+
headers: buildCorsHeaders(allowed, config, varyByOrigin)
|
|
1371
|
+
});
|
|
1372
|
+
},
|
|
1373
|
+
corsify(response, request) {
|
|
1374
|
+
const origin = request.headers.get("origin") ?? undefined;
|
|
1375
|
+
const allowed = getAllowedOrigin(origin, config);
|
|
1376
|
+
if (!allowed)
|
|
1377
|
+
return response;
|
|
1378
|
+
const varyByOrigin = Boolean(origin && shouldVaryByOrigin(config));
|
|
1379
|
+
const headers = buildCorsHeaders(allowed, config, varyByOrigin);
|
|
1380
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1381
|
+
if (k === "vary") {
|
|
1382
|
+
response.headers.set("vary", mergeVary(response.headers.get("vary"), v));
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
response.headers.set(k, v);
|
|
1386
|
+
}
|
|
1387
|
+
return response;
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// src/openapi/docs-ui.ts
|
|
1393
|
+
function renderOpenAPIDocsHtml(spec, openapiPath, tailwindScriptPath, logoDarkPath, logoWhitePath, appleTouchIconPath, favicon32Path, favicon16Path, webManifestPath) {
|
|
1394
|
+
const specJson = JSON.stringify(spec).replace(/<\/script/gi, "<\\/script");
|
|
1395
|
+
const openapiPathJson = JSON.stringify(openapiPath);
|
|
1396
|
+
const tailwindScriptPathJson = JSON.stringify(tailwindScriptPath);
|
|
1397
|
+
const logoDarkPathJson = JSON.stringify(logoDarkPath);
|
|
1398
|
+
const logoWhitePathJson = JSON.stringify(logoWhitePath);
|
|
1399
|
+
const appleTouchIconPathJson = JSON.stringify(appleTouchIconPath);
|
|
1400
|
+
const favicon32PathJson = JSON.stringify(favicon32Path);
|
|
1401
|
+
const favicon16PathJson = JSON.stringify(favicon16Path);
|
|
1402
|
+
const webManifestPathJson = JSON.stringify(webManifestPath);
|
|
1403
|
+
return `<!DOCTYPE html>
|
|
1404
|
+
<html lang="en">
|
|
1405
|
+
<head>
|
|
1406
|
+
<meta charset="UTF-8">
|
|
1407
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1408
|
+
<title>Vector API Documentation</title>
|
|
1409
|
+
<link rel="apple-touch-icon" sizes="180x180" href=${appleTouchIconPathJson}>
|
|
1410
|
+
<link rel="icon" type="image/png" sizes="32x32" href=${favicon32PathJson}>
|
|
1411
|
+
<link rel="icon" type="image/png" sizes="16x16" href=${favicon16PathJson}>
|
|
1412
|
+
<link rel="manifest" href=${webManifestPathJson}>
|
|
1413
|
+
<script>
|
|
1414
|
+
if (localStorage.getItem('theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
1415
|
+
document.documentElement.classList.add('dark');
|
|
1416
|
+
} else {
|
|
1417
|
+
document.documentElement.classList.remove('dark');
|
|
1418
|
+
}
|
|
1419
|
+
</script>
|
|
1420
|
+
<script src=${tailwindScriptPathJson}></script>
|
|
1421
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
1422
|
+
<script>
|
|
1423
|
+
tailwind.config = {
|
|
1424
|
+
darkMode: 'class',
|
|
1425
|
+
theme: {
|
|
1426
|
+
extend: {
|
|
1427
|
+
colors: {
|
|
1428
|
+
brand: {
|
|
1429
|
+
DEFAULT: '#00A1FF',
|
|
1430
|
+
mint: '#00FF8F',
|
|
1431
|
+
soft: '#E4F5FF',
|
|
1432
|
+
deep: '#007BC5',
|
|
1433
|
+
},
|
|
1434
|
+
dark: { bg: '#0A0A0A', surface: '#111111', border: '#1F1F1F', text: '#EDEDED' },
|
|
1435
|
+
light: { bg: '#FFFFFF', surface: '#F9F9F9', border: '#E5E5E5', text: '#111111' }
|
|
1436
|
+
},
|
|
1437
|
+
fontFamily: {
|
|
1438
|
+
sans: ['Inter', 'sans-serif'],
|
|
1439
|
+
mono: ['JetBrains Mono', 'monospace'],
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
</script>
|
|
1445
|
+
<style>
|
|
1446
|
+
:root {
|
|
1447
|
+
--motion-fast: 180ms;
|
|
1448
|
+
--motion-base: 280ms;
|
|
1449
|
+
--motion-slow: 420ms;
|
|
1450
|
+
--motion-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
1451
|
+
}
|
|
1452
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
1453
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
1454
|
+
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
|
|
1455
|
+
body { transition: background-color 150ms ease, color 150ms ease; }
|
|
1456
|
+
#sidebar-nav a,
|
|
1457
|
+
#send-btn,
|
|
1458
|
+
#copy-curl,
|
|
1459
|
+
#add-header-btn,
|
|
1460
|
+
#expand-body-btn,
|
|
1461
|
+
#expand-response-btn,
|
|
1462
|
+
#expand-close,
|
|
1463
|
+
#expand-apply {
|
|
1464
|
+
transition:
|
|
1465
|
+
transform var(--motion-fast) var(--motion-ease),
|
|
1466
|
+
opacity var(--motion-fast) var(--motion-ease),
|
|
1467
|
+
border-color var(--motion-fast) var(--motion-ease),
|
|
1468
|
+
background-color var(--motion-fast) var(--motion-ease),
|
|
1469
|
+
color var(--motion-fast) var(--motion-ease);
|
|
1470
|
+
will-change: transform, opacity;
|
|
1471
|
+
}
|
|
1472
|
+
#sidebar-nav a:hover,
|
|
1473
|
+
#send-btn:hover,
|
|
1474
|
+
#add-header-btn:hover,
|
|
1475
|
+
#expand-body-btn:hover,
|
|
1476
|
+
#expand-response-btn:hover {
|
|
1477
|
+
transform: translateY(-1px);
|
|
1478
|
+
}
|
|
1479
|
+
#endpoint-card {
|
|
1480
|
+
transition:
|
|
1481
|
+
box-shadow var(--motion-base) var(--motion-ease),
|
|
1482
|
+
transform var(--motion-base) var(--motion-ease),
|
|
1483
|
+
opacity var(--motion-base) var(--motion-ease);
|
|
1484
|
+
}
|
|
1485
|
+
.enter-fade-up {
|
|
1486
|
+
animation: enterFadeUp var(--motion-base) var(--motion-ease) both;
|
|
1487
|
+
}
|
|
1488
|
+
.enter-stagger {
|
|
1489
|
+
animation: enterStagger var(--motion-base) var(--motion-ease) both;
|
|
1490
|
+
animation-delay: var(--stagger-delay, 0ms);
|
|
1491
|
+
}
|
|
1492
|
+
@keyframes enterFadeUp {
|
|
1493
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
1494
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1495
|
+
}
|
|
1496
|
+
@keyframes enterStagger {
|
|
1497
|
+
from { opacity: 0; transform: translateX(-6px); }
|
|
1498
|
+
to { opacity: 1; transform: translateX(0); }
|
|
1499
|
+
}
|
|
1500
|
+
@keyframes spin {
|
|
1501
|
+
to { transform: rotate(360deg); }
|
|
1502
|
+
}
|
|
1503
|
+
.button-spinner {
|
|
1504
|
+
display: inline-block;
|
|
1505
|
+
width: 0.875rem;
|
|
1506
|
+
height: 0.875rem;
|
|
1507
|
+
border: 2px solid currentColor;
|
|
1508
|
+
border-right-color: transparent;
|
|
1509
|
+
border-radius: 9999px;
|
|
1510
|
+
animation: spin 700ms linear infinite;
|
|
1511
|
+
}
|
|
1512
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1513
|
+
*, *::before, *::after {
|
|
1514
|
+
animation: none !important;
|
|
1515
|
+
transition: none !important;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
.json-key { color: #007bc5; }
|
|
1519
|
+
.json-string { color: #334155; }
|
|
1520
|
+
.json-number { color: #00a1ff; }
|
|
1521
|
+
.json-boolean { color: #475569; }
|
|
1522
|
+
.json-null { color: #64748b; }
|
|
1523
|
+
.dark .json-key { color: #7dc9ff; }
|
|
1524
|
+
.dark .json-string { color: #d1d9e6; }
|
|
1525
|
+
.dark .json-number { color: #7dc9ff; }
|
|
1526
|
+
.dark .json-boolean { color: #93a4bf; }
|
|
1527
|
+
.dark .json-null { color: #7c8ba3; }
|
|
1528
|
+
</style>
|
|
1529
|
+
</head>
|
|
1530
|
+
<body class="bg-light-bg text-light-text dark:bg-dark-bg dark:text-dark-text font-sans antialiased flex h-screen overflow-hidden">
|
|
1531
|
+
<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>
|
|
1532
|
+
<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">
|
|
1533
|
+
<div class="h-14 flex items-center px-5 border-b border-light-border dark:border-dark-border">
|
|
1534
|
+
<div class="flex items-center">
|
|
1535
|
+
<img src=${logoDarkPathJson} alt="Vector" class="h-6 w-auto block dark:hidden" />
|
|
1536
|
+
<img src=${logoWhitePathJson} alt="Vector" class="h-6 w-auto hidden dark:block" />
|
|
1537
|
+
</div>
|
|
1538
|
+
<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">
|
|
1539
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1540
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
1541
|
+
</svg>
|
|
1542
|
+
</button>
|
|
1543
|
+
</div>
|
|
1544
|
+
<div class="p-4">
|
|
1545
|
+
<div class="relative">
|
|
1546
|
+
<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">
|
|
1547
|
+
<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>
|
|
1548
|
+
</svg>
|
|
1549
|
+
<input
|
|
1550
|
+
id="sidebar-search"
|
|
1551
|
+
type="text"
|
|
1552
|
+
placeholder="Search routes..."
|
|
1553
|
+
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"
|
|
1554
|
+
/>
|
|
1555
|
+
</div>
|
|
1556
|
+
</div>
|
|
1557
|
+
<nav class="flex-1 overflow-y-auto px-3 py-2 space-y-6 text-sm" id="sidebar-nav"></nav>
|
|
1558
|
+
</aside>
|
|
1559
|
+
|
|
1560
|
+
<main class="flex-1 flex flex-col min-w-0 relative">
|
|
1561
|
+
<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">
|
|
1562
|
+
<div class="md:hidden flex items-center gap-2">
|
|
1563
|
+
<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">
|
|
1564
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1565
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
|
1566
|
+
</svg>
|
|
1567
|
+
</button>
|
|
1568
|
+
<img src=${logoDarkPathJson} alt="Vector" class="h-5 w-auto block dark:hidden" />
|
|
1569
|
+
<img src=${logoWhitePathJson} alt="Vector" class="h-5 w-auto hidden dark:block" />
|
|
1570
|
+
</div>
|
|
1571
|
+
<div class="flex-1"></div>
|
|
1572
|
+
<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">
|
|
1573
|
+
<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>
|
|
1574
|
+
<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>
|
|
1575
|
+
</button>
|
|
1576
|
+
</header>
|
|
1577
|
+
|
|
1578
|
+
<div class="flex-1 overflow-y-auto pt-14 pb-24">
|
|
1579
|
+
<div class="max-w-[860px] mx-auto px-6 py-12 lg:py-16">
|
|
1580
|
+
<div class="mb-12">
|
|
1581
|
+
<h1 class="text-4xl font-bold tracking-tight mb-4" id="tag-title">API</h1>
|
|
1582
|
+
<p class="text-lg opacity-80 max-w-2xl leading-relaxed" id="tag-description">Interactive API documentation.</p>
|
|
1583
|
+
</div>
|
|
1584
|
+
<hr class="border-t border-light-border dark:border-dark-border mb-12">
|
|
1585
|
+
<div class="mb-20" id="endpoint-card">
|
|
1586
|
+
<div class="flex items-center gap-3 mb-4">
|
|
1587
|
+
<span id="endpoint-method" class="px-2.5 py-0.5 rounded-full text-xs font-mono font-medium"></span>
|
|
1588
|
+
<h2 class="text-xl font-semibold tracking-tight" id="endpoint-title">Operation</h2>
|
|
1589
|
+
</div>
|
|
1590
|
+
<p class="text-sm opacity-80 mb-8 font-mono" id="endpoint-path">/</p>
|
|
1591
|
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
|
1592
|
+
<div class="lg:col-span-5 space-y-8" id="params-column"></div>
|
|
1593
|
+
<div class="lg:col-span-7">
|
|
1594
|
+
<div class="rounded-lg border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg overflow-hidden group">
|
|
1595
|
+
<div class="flex items-center justify-between px-4 py-2 border-b border-light-border dark:border-dark-border bg-light-surface dark:bg-dark-surface">
|
|
1596
|
+
<span class="text-xs font-mono text-light-text/70 dark:text-dark-text/70">cURL</span>
|
|
1597
|
+
<button class="text-xs text-light-text/50 hover:text-light-text dark:text-dark-text/50 dark:hover:text-dark-text transition-colors" id="copy-curl">Copy</button>
|
|
1598
|
+
</div>
|
|
1599
|
+
<pre class="p-4 text-sm font-mono text-light-text dark:text-dark-text overflow-x-auto leading-relaxed"><code id="curl-code"></code></pre>
|
|
1600
|
+
</div>
|
|
1601
|
+
<div class="mt-4 p-4 rounded-lg border border-light-border dark:border-dark-border bg-light-surface dark:bg-dark-surface">
|
|
1602
|
+
<div class="flex items-center justify-between mb-3">
|
|
1603
|
+
<h4 class="text-sm font-medium">Try it out</h4>
|
|
1604
|
+
<button id="send-btn" class="px-4 py-1.5 bg-brand text-white text-sm font-semibold rounded hover:bg-brand-deep transition-colors">
|
|
1605
|
+
<span class="inline-flex items-center gap-2">
|
|
1606
|
+
<span id="send-btn-spinner" class="button-spinner hidden" aria-hidden="true"></span>
|
|
1607
|
+
<span id="send-btn-label">Submit</span>
|
|
1608
|
+
</span>
|
|
1609
|
+
</button>
|
|
1610
|
+
</div>
|
|
1611
|
+
<div class="space-y-4">
|
|
1612
|
+
<div>
|
|
1613
|
+
<div id="request-param-inputs" class="space-y-3"></div>
|
|
1614
|
+
</div>
|
|
1615
|
+
|
|
1616
|
+
<div>
|
|
1617
|
+
<div class="flex items-center justify-between mb-2">
|
|
1618
|
+
<p class="text-xs font-semibold uppercase tracking-wider opacity-60">Headers</p>
|
|
1619
|
+
<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">
|
|
1620
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1621
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14"></path>
|
|
1622
|
+
</svg>
|
|
1623
|
+
</button>
|
|
1624
|
+
</div>
|
|
1625
|
+
<div id="header-inputs" class="space-y-2"></div>
|
|
1626
|
+
</div>
|
|
1627
|
+
|
|
1628
|
+
<div id="request-body-section">
|
|
1629
|
+
<div class="flex items-center justify-between mb-2">
|
|
1630
|
+
<p class="text-xs font-semibold uppercase tracking-wider opacity-60">Request Body</p>
|
|
1631
|
+
</div>
|
|
1632
|
+
<div class="relative h-40 rounded border border-light-border dark:border-dark-border bg-light-bg dark:bg-dark-bg overflow-hidden">
|
|
1633
|
+
<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>
|
|
1634
|
+
<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>
|
|
1635
|
+
<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">
|
|
1636
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1637
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 9V4h5M20 15v5h-5M15 4h5v5M9 20H4v-5"></path>
|
|
1638
|
+
</svg>
|
|
1639
|
+
</button>
|
|
1640
|
+
</div>
|
|
1641
|
+
</div>
|
|
1642
|
+
|
|
1643
|
+
<div id="response-section">
|
|
1644
|
+
<div class="flex items-center justify-between mb-2">
|
|
1645
|
+
<p class="text-xs font-semibold uppercase tracking-wider opacity-60">Response</p>
|
|
1646
|
+
</div>
|
|
1647
|
+
<div class="relative">
|
|
1648
|
+
<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>
|
|
1649
|
+
<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">
|
|
1650
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1651
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 9V4h5M20 15v5h-5M15 4h5v5M9 20H4v-5"></path>
|
|
1652
|
+
</svg>
|
|
1653
|
+
</button>
|
|
1654
|
+
</div>
|
|
1655
|
+
</div>
|
|
1656
|
+
</div>
|
|
1657
|
+
</div>
|
|
1658
|
+
</div>
|
|
1659
|
+
</div>
|
|
1660
|
+
</div>
|
|
1661
|
+
</div>
|
|
1662
|
+
</div>
|
|
1663
|
+
</main>
|
|
1664
|
+
|
|
1665
|
+
<div id="expand-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 p-4">
|
|
1666
|
+
<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">
|
|
1667
|
+
<div class="flex items-center justify-between mb-3">
|
|
1668
|
+
<h3 id="expand-modal-title" class="text-sm font-semibold">Expanded View</h3>
|
|
1669
|
+
<div class="flex items-center gap-2">
|
|
1670
|
+
<button id="expand-apply" class="hidden text-sm px-3 py-1.5 rounded bg-brand text-white font-semibold hover:bg-brand-deep transition-colors">Apply</button>
|
|
1671
|
+
<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">
|
|
1672
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1673
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
1674
|
+
</svg>
|
|
1675
|
+
</button>
|
|
1676
|
+
</div>
|
|
1677
|
+
</div>
|
|
1678
|
+
<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">
|
|
1679
|
+
<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>
|
|
1680
|
+
<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>
|
|
1681
|
+
</div>
|
|
1682
|
+
<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>
|
|
1683
|
+
</div>
|
|
1684
|
+
</div>
|
|
1685
|
+
|
|
1686
|
+
<script>
|
|
1687
|
+
const spec = ${specJson};
|
|
1688
|
+
const openapiPath = ${openapiPathJson};
|
|
1689
|
+
const methodBadgeDefault = "bg-black/5 text-light-text/80 dark:bg-white/10 dark:text-dark-text/80";
|
|
1690
|
+
const methodBadge = {
|
|
1691
|
+
GET: "bg-brand-soft text-brand-deep dark:bg-brand/20 dark:text-brand",
|
|
1692
|
+
POST: "bg-brand-soft text-brand-deep dark:bg-brand/20 dark:text-brand",
|
|
1693
|
+
PUT: "bg-brand-soft text-brand-deep dark:bg-brand/20 dark:text-brand",
|
|
1694
|
+
PATCH: "bg-brand-soft text-brand-deep dark:bg-brand/20 dark:text-brand",
|
|
1695
|
+
DELETE: "bg-brand-soft text-brand-deep dark:bg-brand/20 dark:text-brand",
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
function getOperations() {
|
|
1699
|
+
const httpMethods = new Set([
|
|
1700
|
+
"get",
|
|
1701
|
+
"post",
|
|
1702
|
+
"put",
|
|
1703
|
+
"patch",
|
|
1704
|
+
"delete",
|
|
1705
|
+
"head",
|
|
1706
|
+
"options",
|
|
1707
|
+
]);
|
|
1708
|
+
|
|
1709
|
+
const humanizePath = (path) =>
|
|
1710
|
+
path
|
|
1711
|
+
.replace(/^\\/+/, "")
|
|
1712
|
+
.replace(/[{}]/g, "")
|
|
1713
|
+
.replace(/[\\/_]+/g, " ")
|
|
1714
|
+
.trim() || "root";
|
|
1715
|
+
|
|
1716
|
+
const toTitleCase = (value) =>
|
|
1717
|
+
value.replace(/\\w\\S*/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
|
|
1718
|
+
|
|
1719
|
+
const getDisplayName = (op, method, path) => {
|
|
1720
|
+
if (typeof op.summary === "string" && op.summary.trim()) {
|
|
1721
|
+
return op.summary.trim();
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
if (typeof op.operationId === "string" && op.operationId.trim()) {
|
|
1725
|
+
const withoutPrefix = op.operationId.replace(
|
|
1726
|
+
new RegExp("^" + method + "_+", "i"),
|
|
1727
|
+
"",
|
|
1728
|
+
);
|
|
1729
|
+
const readable = withoutPrefix.replace(/_+/g, " ").trim();
|
|
1730
|
+
if (readable) return toTitleCase(readable);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
return toTitleCase(humanizePath(path));
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
const ops = [];
|
|
1737
|
+
const paths = spec.paths || {};
|
|
1738
|
+
for (const path of Object.keys(paths)) {
|
|
1739
|
+
const methods = paths[path] || {};
|
|
1740
|
+
for (const method of Object.keys(methods)) {
|
|
1741
|
+
if (!httpMethods.has(method)) continue;
|
|
1742
|
+
const op = methods[method];
|
|
1743
|
+
ops.push({
|
|
1744
|
+
path,
|
|
1745
|
+
method: method.toUpperCase(),
|
|
1746
|
+
operation: op,
|
|
1747
|
+
tag: (op.tags && op.tags[0]) || "default",
|
|
1748
|
+
name: getDisplayName(op, method, path),
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return ops;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
const operations = getOperations();
|
|
1756
|
+
let selected = operations[0] || null;
|
|
1757
|
+
const operationParamValues = new Map();
|
|
1758
|
+
const operationBodyDrafts = new Map();
|
|
1759
|
+
const requestHeaders = [{ key: "Authorization", value: "" }];
|
|
1760
|
+
let expandModalMode = null;
|
|
1761
|
+
let isMobileSidebarOpen = false;
|
|
1762
|
+
let sidebarSearchQuery = "";
|
|
1763
|
+
|
|
1764
|
+
function setMobileSidebarOpen(open) {
|
|
1765
|
+
const sidebar = document.getElementById("docs-sidebar");
|
|
1766
|
+
const backdrop = document.getElementById("mobile-backdrop");
|
|
1767
|
+
const openBtn = document.getElementById("sidebar-open");
|
|
1768
|
+
if (!sidebar || !backdrop || !openBtn) return;
|
|
1769
|
+
|
|
1770
|
+
isMobileSidebarOpen = open;
|
|
1771
|
+
sidebar.classList.toggle("-translate-x-full", !open);
|
|
1772
|
+
backdrop.classList.toggle("opacity-0", !open);
|
|
1773
|
+
backdrop.classList.toggle("pointer-events-none", !open);
|
|
1774
|
+
openBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1775
|
+
document.body.classList.toggle("overflow-hidden", open);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function getOperationKey(op) {
|
|
1779
|
+
return op.method + " " + op.path;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function getOperationParameterGroups(op) {
|
|
1783
|
+
const params =
|
|
1784
|
+
op &&
|
|
1785
|
+
op.operation &&
|
|
1786
|
+
Array.isArray(op.operation.parameters)
|
|
1787
|
+
? op.operation.parameters
|
|
1788
|
+
: [];
|
|
1789
|
+
|
|
1790
|
+
return {
|
|
1791
|
+
all: params,
|
|
1792
|
+
path: params.filter((p) => p.in === "path"),
|
|
1793
|
+
query: params.filter((p) => p.in === "query"),
|
|
1794
|
+
headers: params.filter((p) => p.in === "header"),
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function getParameterValues(op) {
|
|
1799
|
+
const key = getOperationKey(op);
|
|
1800
|
+
if (!operationParamValues.has(key)) {
|
|
1801
|
+
operationParamValues.set(key, {});
|
|
1802
|
+
}
|
|
1803
|
+
return operationParamValues.get(key);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function getBodyDraft(op) {
|
|
1807
|
+
const key = getOperationKey(op);
|
|
1808
|
+
return operationBodyDrafts.get(key);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function setBodyDraft(op, bodyValue) {
|
|
1812
|
+
const key = getOperationKey(op);
|
|
1813
|
+
operationBodyDrafts.set(key, bodyValue);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function resolvePath(pathTemplate, pathParams, values) {
|
|
1817
|
+
let resolved = pathTemplate;
|
|
1818
|
+
for (const param of pathParams) {
|
|
1819
|
+
const rawValue = values[param.name];
|
|
1820
|
+
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
const placeholder = "{" + param.name + "}";
|
|
1824
|
+
resolved = resolved
|
|
1825
|
+
.split(placeholder)
|
|
1826
|
+
.join(encodeURIComponent(String(rawValue)));
|
|
1827
|
+
}
|
|
1828
|
+
return resolved;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function buildRequestPath(op, pathParams, queryParams, values) {
|
|
1832
|
+
const resolvedPath = resolvePath(op.path, pathParams, values);
|
|
1833
|
+
const query = new URLSearchParams();
|
|
1834
|
+
|
|
1835
|
+
for (const param of queryParams) {
|
|
1836
|
+
const rawValue = values[param.name];
|
|
1837
|
+
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
query.append(param.name, String(rawValue));
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const queryString = query.toString();
|
|
1844
|
+
return queryString ? resolvedPath + "?" + queryString : resolvedPath;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function schemaDefaultValue(schema) {
|
|
1848
|
+
if (!schema || typeof schema !== "object") return null;
|
|
1849
|
+
if (schema.default !== undefined) return schema.default;
|
|
1850
|
+
if (schema.example !== undefined) return schema.example;
|
|
1851
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
|
|
1852
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
|
1853
|
+
return schemaDefaultValue(schema.oneOf[0]);
|
|
1854
|
+
}
|
|
1855
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
1856
|
+
return schemaDefaultValue(schema.anyOf[0]);
|
|
1857
|
+
}
|
|
1858
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
1859
|
+
return schemaDefaultValue(schema.allOf[0]);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
switch (schema.type) {
|
|
1863
|
+
case "string":
|
|
1864
|
+
return "";
|
|
1865
|
+
case "number":
|
|
1866
|
+
case "integer":
|
|
1867
|
+
return 0;
|
|
1868
|
+
case "boolean":
|
|
1869
|
+
return false;
|
|
1870
|
+
case "array":
|
|
1871
|
+
return [];
|
|
1872
|
+
case "object": {
|
|
1873
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
1874
|
+
const properties = schema.properties && typeof schema.properties === "object"
|
|
1875
|
+
? schema.properties
|
|
1876
|
+
: {};
|
|
1877
|
+
const obj = {};
|
|
1878
|
+
for (const fieldName of required) {
|
|
1879
|
+
obj[fieldName] = schemaDefaultValue(properties[fieldName]);
|
|
1880
|
+
}
|
|
1881
|
+
return obj;
|
|
1882
|
+
}
|
|
1883
|
+
default:
|
|
1884
|
+
return null;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function buildRequiredBodyPrefill(schema) {
|
|
1889
|
+
if (!schema || typeof schema !== "object") return "";
|
|
1890
|
+
const prefillValue = schemaDefaultValue(schema);
|
|
1891
|
+
if (
|
|
1892
|
+
prefillValue &&
|
|
1893
|
+
typeof prefillValue === "object" &&
|
|
1894
|
+
!Array.isArray(prefillValue) &&
|
|
1895
|
+
Object.keys(prefillValue).length === 0
|
|
1896
|
+
) {
|
|
1897
|
+
return "";
|
|
1898
|
+
}
|
|
1899
|
+
try {
|
|
1900
|
+
return JSON.stringify(prefillValue, null, 2);
|
|
1901
|
+
} catch {
|
|
1902
|
+
return "";
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function hasMeaningfulRequestBodySchema(schema) {
|
|
1907
|
+
if (!schema || typeof schema !== "object") return false;
|
|
1908
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return true;
|
|
1909
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return true;
|
|
1910
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) return true;
|
|
1911
|
+
if (schema.type && schema.type !== "object") return true;
|
|
1912
|
+
if (schema.additionalProperties !== undefined) return true;
|
|
1913
|
+
if (Array.isArray(schema.required) && schema.required.length > 0) return true;
|
|
1914
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
1915
|
+
return Object.keys(schema.properties).length > 0;
|
|
1916
|
+
}
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function renderSidebar() {
|
|
1921
|
+
const nav = document.getElementById("sidebar-nav");
|
|
1922
|
+
const groups = new Map();
|
|
1923
|
+
const query = sidebarSearchQuery.trim().toLowerCase();
|
|
1924
|
+
const visibleOps = query
|
|
1925
|
+
? operations.filter((op) => {
|
|
1926
|
+
const haystack = [
|
|
1927
|
+
op.name,
|
|
1928
|
+
op.path,
|
|
1929
|
+
op.method,
|
|
1930
|
+
op.tag,
|
|
1931
|
+
]
|
|
1932
|
+
.join(" ")
|
|
1933
|
+
.toLowerCase();
|
|
1934
|
+
return haystack.includes(query);
|
|
1935
|
+
})
|
|
1936
|
+
: operations;
|
|
1937
|
+
|
|
1938
|
+
for (const op of visibleOps) {
|
|
1939
|
+
if (!groups.has(op.tag)) groups.set(op.tag, []);
|
|
1940
|
+
groups.get(op.tag).push(op);
|
|
1941
|
+
}
|
|
1942
|
+
nav.innerHTML = "";
|
|
1943
|
+
if (visibleOps.length === 0) {
|
|
1944
|
+
nav.innerHTML =
|
|
1945
|
+
'<p class="px-2 text-xs opacity-60">No routes match your search.</p>';
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
for (const [tag, ops] of groups.entries()) {
|
|
1949
|
+
ops.sort((a, b) => {
|
|
1950
|
+
const byName = a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
|
1951
|
+
if (byName !== 0) return byName;
|
|
1952
|
+
|
|
1953
|
+
const byPath = a.path.localeCompare(b.path, undefined, { sensitivity: "base" });
|
|
1954
|
+
if (byPath !== 0) return byPath;
|
|
1955
|
+
|
|
1956
|
+
return a.method.localeCompare(b.method, undefined, { sensitivity: "base" });
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
const block = document.createElement("div");
|
|
1960
|
+
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>';
|
|
1961
|
+
block.querySelector("h3").textContent = tag;
|
|
1962
|
+
const list = block.querySelector("ul");
|
|
1963
|
+
for (const op of ops) {
|
|
1964
|
+
const li = document.createElement("li");
|
|
1965
|
+
li.className = "enter-stagger";
|
|
1966
|
+
li.style.setProperty("--stagger-delay", String(Math.min(list.children.length * 22, 180)) + "ms");
|
|
1967
|
+
const a = document.createElement("a");
|
|
1968
|
+
a.href = "#";
|
|
1969
|
+
a.className = op === selected
|
|
1970
|
+
? "block px-2 py-1.5 rounded-md bg-brand-soft/70 dark:bg-brand/20 text-brand-deep dark:text-brand font-medium transition-colors"
|
|
1971
|
+
: "block px-2 py-1.5 rounded-md hover:bg-black/5 dark:hover:bg-white/5 transition-colors";
|
|
1972
|
+
|
|
1973
|
+
const row = document.createElement("span");
|
|
1974
|
+
row.className = "flex items-center gap-2";
|
|
1975
|
+
|
|
1976
|
+
const method = document.createElement("span");
|
|
1977
|
+
method.className = "px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold " + (methodBadge[op.method] || methodBadgeDefault);
|
|
1978
|
+
method.textContent = op.method;
|
|
1979
|
+
|
|
1980
|
+
const name = document.createElement("span");
|
|
1981
|
+
name.textContent = op.name;
|
|
1982
|
+
|
|
1983
|
+
row.appendChild(method);
|
|
1984
|
+
row.appendChild(name);
|
|
1985
|
+
a.appendChild(row);
|
|
1986
|
+
|
|
1987
|
+
a.onclick = (e) => {
|
|
1988
|
+
e.preventDefault();
|
|
1989
|
+
selected = op;
|
|
1990
|
+
renderSidebar();
|
|
1991
|
+
renderEndpoint();
|
|
1992
|
+
if (window.innerWidth < 768) {
|
|
1993
|
+
setMobileSidebarOpen(false);
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
li.appendChild(a);
|
|
1997
|
+
list.appendChild(li);
|
|
1998
|
+
}
|
|
1999
|
+
nav.appendChild(block);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function renderParamSection(title, params) {
|
|
2004
|
+
if (!params.length) return "";
|
|
2005
|
+
let rows = "";
|
|
2006
|
+
for (const p of params) {
|
|
2007
|
+
const type = escapeHtml((p.schema && p.schema.type) || "unknown");
|
|
2008
|
+
const name = escapeHtml(p.name || "");
|
|
2009
|
+
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>';
|
|
2010
|
+
}
|
|
2011
|
+
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>";
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function getSchemaTypeLabel(schema) {
|
|
2015
|
+
if (!schema || typeof schema !== "object") return "unknown";
|
|
2016
|
+
if (Array.isArray(schema.type)) return schema.type.join(" | ");
|
|
2017
|
+
if (schema.type) return String(schema.type);
|
|
2018
|
+
if (schema.properties) return "object";
|
|
2019
|
+
if (schema.items) return "array";
|
|
2020
|
+
if (Array.isArray(schema.oneOf)) return "oneOf";
|
|
2021
|
+
if (Array.isArray(schema.anyOf)) return "anyOf";
|
|
2022
|
+
if (Array.isArray(schema.allOf)) return "allOf";
|
|
2023
|
+
return "unknown";
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function buildSchemaChildren(schema) {
|
|
2027
|
+
if (!schema || typeof schema !== "object") return [];
|
|
2028
|
+
|
|
2029
|
+
const children = [];
|
|
2030
|
+
|
|
2031
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
2032
|
+
const requiredSet = new Set(
|
|
2033
|
+
Array.isArray(schema.required) ? schema.required : [],
|
|
2034
|
+
);
|
|
2035
|
+
for (const [name, childSchema] of Object.entries(schema.properties)) {
|
|
2036
|
+
children.push({
|
|
2037
|
+
name,
|
|
2038
|
+
schema: childSchema || {},
|
|
2039
|
+
required: requiredSet.has(name),
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
if (schema.items) {
|
|
2045
|
+
children.push({
|
|
2046
|
+
name: "items[]",
|
|
2047
|
+
schema: schema.items,
|
|
2048
|
+
required: true,
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
return children;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function renderSchemaFieldNode(field, depth) {
|
|
2056
|
+
const schema = field.schema || {};
|
|
2057
|
+
const name = escapeHtml(field.name || "field");
|
|
2058
|
+
const requiredLabel = field.required ? "required" : "optional";
|
|
2059
|
+
const type = escapeHtml(getSchemaTypeLabel(schema));
|
|
2060
|
+
const children = buildSchemaChildren(schema);
|
|
2061
|
+
const padding = depth * 14;
|
|
2062
|
+
|
|
2063
|
+
if (!children.length) {
|
|
2064
|
+
return (
|
|
2065
|
+
'<div class="py-2 border-b border-light-border/50 dark:border-dark-border/50" style="padding-left:' +
|
|
2066
|
+
padding +
|
|
2067
|
+
'px"><div class="flex justify-between"><div><code class="text-sm font-mono">' +
|
|
2068
|
+
name +
|
|
2069
|
+
'</code><span class="text-xs text-brand ml-2">' +
|
|
2070
|
+
requiredLabel +
|
|
2071
|
+
'</span></div><span class="text-xs font-mono opacity-60">' +
|
|
2072
|
+
type +
|
|
2073
|
+
"</span></div></div>"
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
let nested = "";
|
|
2078
|
+
for (const child of children) {
|
|
2079
|
+
nested += renderSchemaFieldNode(child, depth + 1);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
return (
|
|
2083
|
+
'<details class="border-b border-light-border/50 dark:border-dark-border/50" open>' +
|
|
2084
|
+
'<summary class="list-none cursor-pointer py-2 flex justify-between items-center" style="padding-left:' +
|
|
2085
|
+
padding +
|
|
2086
|
+
'px"><div class="flex items-center gap-2"><span class="text-xs opacity-70">\u25BE</span><code class="text-sm font-mono">' +
|
|
2087
|
+
name +
|
|
2088
|
+
'</code><span class="text-xs text-brand">' +
|
|
2089
|
+
requiredLabel +
|
|
2090
|
+
'</span></div><span class="text-xs font-mono opacity-60">' +
|
|
2091
|
+
type +
|
|
2092
|
+
"</span></summary>" +
|
|
2093
|
+
'<div class="pb-1">' +
|
|
2094
|
+
nested +
|
|
2095
|
+
"</div></details>"
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function renderRequestBodySchemaSection(schema) {
|
|
2100
|
+
if (!schema || typeof schema !== "object") return "";
|
|
2101
|
+
const rootChildren = buildSchemaChildren(schema);
|
|
2102
|
+
if (!rootChildren.length) return "";
|
|
2103
|
+
|
|
2104
|
+
let rows = "";
|
|
2105
|
+
for (const child of rootChildren) {
|
|
2106
|
+
rows += renderSchemaFieldNode(child, 0);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
return (
|
|
2110
|
+
'<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>' +
|
|
2111
|
+
rows +
|
|
2112
|
+
"</div>"
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
function renderResponseSchemasSection(responses) {
|
|
2117
|
+
if (!responses || typeof responses !== "object") return "";
|
|
2118
|
+
|
|
2119
|
+
const statusCodes = Object.keys(responses).sort((a, b) => {
|
|
2120
|
+
const aNum = Number(a);
|
|
2121
|
+
const bNum = Number(b);
|
|
2122
|
+
if (Number.isInteger(aNum) && Number.isInteger(bNum)) return aNum - bNum;
|
|
2123
|
+
if (Number.isInteger(aNum)) return -1;
|
|
2124
|
+
if (Number.isInteger(bNum)) return 1;
|
|
2125
|
+
return a.localeCompare(b);
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
let sections = "";
|
|
2129
|
+
for (const statusCode of statusCodes) {
|
|
2130
|
+
const responseDef = responses[statusCode];
|
|
2131
|
+
if (!responseDef || typeof responseDef !== "object") continue;
|
|
2132
|
+
|
|
2133
|
+
const jsonSchema =
|
|
2134
|
+
responseDef.content &&
|
|
2135
|
+
responseDef.content["application/json"] &&
|
|
2136
|
+
responseDef.content["application/json"].schema;
|
|
2137
|
+
|
|
2138
|
+
if (!jsonSchema || typeof jsonSchema !== "object") continue;
|
|
2139
|
+
|
|
2140
|
+
const rootChildren = buildSchemaChildren(jsonSchema);
|
|
2141
|
+
if (!rootChildren.length) continue;
|
|
2142
|
+
|
|
2143
|
+
let rows = "";
|
|
2144
|
+
for (const child of rootChildren) {
|
|
2145
|
+
rows += renderSchemaFieldNode(child, 0);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
sections +=
|
|
2149
|
+
'<div class="mb-4"><h4 class="text-xs font-mono uppercase tracking-wider opacity-70 mb-2">Status ' +
|
|
2150
|
+
escapeHtml(statusCode) +
|
|
2151
|
+
"</h4>" +
|
|
2152
|
+
rows +
|
|
2153
|
+
"</div>";
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
if (!sections) return "";
|
|
2157
|
+
|
|
2158
|
+
return (
|
|
2159
|
+
'<div><h3 class="text-sm font-semibold mb-3 flex items-center border-b border-light-border dark:border-dark-border pb-2">Response Schemas</h3>' +
|
|
2160
|
+
sections +
|
|
2161
|
+
"</div>"
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function renderTryItParameterInputs(pathParams, queryParams) {
|
|
2166
|
+
const container = document.getElementById("request-param-inputs");
|
|
2167
|
+
if (!container || !selected) return;
|
|
2168
|
+
|
|
2169
|
+
const values = getParameterValues(selected);
|
|
2170
|
+
container.innerHTML = "";
|
|
2171
|
+
|
|
2172
|
+
const sections = [
|
|
2173
|
+
{ title: "Path Values", params: pathParams },
|
|
2174
|
+
{ title: "Query Values", params: queryParams },
|
|
2175
|
+
];
|
|
2176
|
+
|
|
2177
|
+
for (const section of sections) {
|
|
2178
|
+
if (!section.params.length) continue;
|
|
2179
|
+
|
|
2180
|
+
const group = document.createElement("div");
|
|
2181
|
+
group.className = "space-y-2";
|
|
2182
|
+
|
|
2183
|
+
const title = document.createElement("p");
|
|
2184
|
+
title.className = "text-xs font-semibold uppercase tracking-wider opacity-60";
|
|
2185
|
+
title.textContent = section.title;
|
|
2186
|
+
group.appendChild(title);
|
|
2187
|
+
|
|
2188
|
+
for (const param of section.params) {
|
|
2189
|
+
const field = document.createElement("div");
|
|
2190
|
+
field.className = "space-y-1";
|
|
2191
|
+
|
|
2192
|
+
const label = document.createElement("label");
|
|
2193
|
+
label.className = "text-xs opacity-80 flex items-center gap-2";
|
|
2194
|
+
|
|
2195
|
+
const labelName = document.createElement("span");
|
|
2196
|
+
labelName.className = "font-mono";
|
|
2197
|
+
labelName.textContent = param.name;
|
|
2198
|
+
|
|
2199
|
+
const required = document.createElement("span");
|
|
2200
|
+
required.className = "text-[10px] text-brand";
|
|
2201
|
+
required.textContent = param.required ? "required" : "optional";
|
|
2202
|
+
|
|
2203
|
+
label.appendChild(labelName);
|
|
2204
|
+
label.appendChild(required);
|
|
2205
|
+
|
|
2206
|
+
const input = document.createElement("input");
|
|
2207
|
+
input.type = "text";
|
|
2208
|
+
input.value = values[param.name] || "";
|
|
2209
|
+
input.placeholder =
|
|
2210
|
+
section.title === "Path Values" ? param.name : "optional";
|
|
2211
|
+
input.className =
|
|
2212
|
+
"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";
|
|
2213
|
+
|
|
2214
|
+
input.addEventListener("input", () => {
|
|
2215
|
+
values[param.name] = input.value;
|
|
2216
|
+
updateRequestPreview();
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
field.appendChild(label);
|
|
2220
|
+
field.appendChild(input);
|
|
2221
|
+
group.appendChild(field);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
container.appendChild(group);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function renderHeaderInputs() {
|
|
2229
|
+
const container = document.getElementById("header-inputs");
|
|
2230
|
+
if (!container) return;
|
|
2231
|
+
|
|
2232
|
+
container.innerHTML = "";
|
|
2233
|
+
requestHeaders.forEach((entry, index) => {
|
|
2234
|
+
const row = document.createElement("div");
|
|
2235
|
+
row.className = "grid grid-cols-[1fr_1fr_auto] gap-2";
|
|
2236
|
+
|
|
2237
|
+
const keyInput = document.createElement("input");
|
|
2238
|
+
keyInput.type = "text";
|
|
2239
|
+
keyInput.value = entry.key || "";
|
|
2240
|
+
keyInput.placeholder = "Header";
|
|
2241
|
+
keyInput.className =
|
|
2242
|
+
"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";
|
|
2243
|
+
keyInput.addEventListener("input", () => {
|
|
2244
|
+
entry.key = keyInput.value;
|
|
2245
|
+
updateRequestPreview();
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
const valueInput = document.createElement("input");
|
|
2249
|
+
valueInput.type = "text";
|
|
2250
|
+
valueInput.value = entry.value || "";
|
|
2251
|
+
valueInput.placeholder =
|
|
2252
|
+
String(entry.key || "").toLowerCase() === "authorization"
|
|
2253
|
+
? "Bearer token"
|
|
2254
|
+
: "Value";
|
|
2255
|
+
valueInput.className =
|
|
2256
|
+
"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";
|
|
2257
|
+
valueInput.addEventListener("input", () => {
|
|
2258
|
+
entry.value = valueInput.value;
|
|
2259
|
+
updateRequestPreview();
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
const removeButton = document.createElement("button");
|
|
2263
|
+
removeButton.type = "button";
|
|
2264
|
+
removeButton.className =
|
|
2265
|
+
"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";
|
|
2266
|
+
removeButton.setAttribute("aria-label", "Remove Header");
|
|
2267
|
+
removeButton.setAttribute("title", "Remove Header");
|
|
2268
|
+
removeButton.innerHTML =
|
|
2269
|
+
'<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>';
|
|
2270
|
+
removeButton.addEventListener("click", () => {
|
|
2271
|
+
requestHeaders.splice(index, 1);
|
|
2272
|
+
renderHeaderInputs();
|
|
2273
|
+
updateRequestPreview();
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
row.appendChild(keyInput);
|
|
2277
|
+
row.appendChild(valueInput);
|
|
2278
|
+
row.appendChild(removeButton);
|
|
2279
|
+
container.appendChild(row);
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function hasHeaderName(headers, expectedName) {
|
|
2284
|
+
const target = expectedName.toLowerCase();
|
|
2285
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
function getRequestHeadersObject() {
|
|
2289
|
+
const headers = {};
|
|
2290
|
+
for (const entry of requestHeaders) {
|
|
2291
|
+
const key = String(entry.key || "").trim();
|
|
2292
|
+
const value = String(entry.value || "").trim();
|
|
2293
|
+
if (!key || !value) continue;
|
|
2294
|
+
headers[key] = value;
|
|
2295
|
+
}
|
|
2296
|
+
return headers;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function buildCurl(op, headers, body, requestPath) {
|
|
2300
|
+
const url = window.location.origin + requestPath;
|
|
2301
|
+
const lines = ['curl -X ' + op.method + ' "' + url + '"'];
|
|
2302
|
+
|
|
2303
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
2304
|
+
const safeName = String(name).replace(/"/g, '\\"');
|
|
2305
|
+
const safeValue = String(value).replace(/"/g, '\\"');
|
|
2306
|
+
lines.push(' -H "' + safeName + ": " + safeValue + '"');
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
if (body) {
|
|
2310
|
+
lines.push(" -d '" + body.replace(/'/g, "'\\\\''") + "'");
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
return lines.join(" \\\\\\n");
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function formatBodyJsonInput() {
|
|
2317
|
+
const bodyInput = document.getElementById("body-input");
|
|
2318
|
+
if (!bodyInput) return;
|
|
2319
|
+
const current = bodyInput.value.trim();
|
|
2320
|
+
if (!current) return;
|
|
2321
|
+
try {
|
|
2322
|
+
bodyInput.value = JSON.stringify(JSON.parse(current), null, 2);
|
|
2323
|
+
} catch {}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function escapeHtml(value) {
|
|
2327
|
+
return String(value)
|
|
2328
|
+
.replace(/&/g, "&")
|
|
2329
|
+
.replace(/</g, "<")
|
|
2330
|
+
.replace(/>/g, ">");
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
function toPrettyJson(value) {
|
|
2334
|
+
const trimmed = (value || "").trim();
|
|
2335
|
+
if (!trimmed) return null;
|
|
2336
|
+
try {
|
|
2337
|
+
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
|
2338
|
+
} catch {
|
|
2339
|
+
return null;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
function highlightJson(jsonText) {
|
|
2344
|
+
const escaped = escapeHtml(jsonText);
|
|
2345
|
+
return escaped.replace(
|
|
2346
|
+
/("(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\"])*"(\\s*:)?|\\btrue\\b|\\bfalse\\b|\\bnull\\b|-?\\d+(?:\\.\\d+)?(?:[eE][+\\-]?\\d+)?)/g,
|
|
2347
|
+
(match) => {
|
|
2348
|
+
let cls = "json-number";
|
|
2349
|
+
if (match.startsWith('"')) {
|
|
2350
|
+
cls = match.endsWith(":") ? "json-key" : "json-string";
|
|
2351
|
+
} else if (match === "true" || match === "false") {
|
|
2352
|
+
cls = "json-boolean";
|
|
2353
|
+
} else if (match === "null") {
|
|
2354
|
+
cls = "json-null";
|
|
2355
|
+
}
|
|
2356
|
+
return '<span class="' + cls + '">' + match + "</span>";
|
|
2357
|
+
},
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function updateBodyJsonPresentation() {
|
|
2362
|
+
const bodyInput = document.getElementById("body-input");
|
|
2363
|
+
const highlight = document.getElementById("body-highlight");
|
|
2364
|
+
const bodySection = document.getElementById("request-body-section");
|
|
2365
|
+
|
|
2366
|
+
if (!bodyInput || !highlight || !bodySection) return;
|
|
2367
|
+
if (bodySection.classList.contains("hidden")) {
|
|
2368
|
+
highlight.innerHTML = "";
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
const raw = bodyInput.value || "";
|
|
2373
|
+
if (!raw.trim()) {
|
|
2374
|
+
const placeholder = bodyInput.getAttribute("placeholder") || "";
|
|
2375
|
+
highlight.innerHTML = '<span class="opacity-40">' + escapeHtml(placeholder) + "</span>";
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const prettyJson = toPrettyJson(raw);
|
|
2380
|
+
if (!prettyJson) {
|
|
2381
|
+
highlight.innerHTML = escapeHtml(raw);
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
highlight.innerHTML = highlightJson(raw);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function syncBodyEditorScroll() {
|
|
2389
|
+
const bodyInput = document.getElementById("body-input");
|
|
2390
|
+
const highlight = document.getElementById("body-highlight");
|
|
2391
|
+
if (!bodyInput || !highlight) return;
|
|
2392
|
+
highlight.scrollTop = bodyInput.scrollTop;
|
|
2393
|
+
highlight.scrollLeft = bodyInput.scrollLeft;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
function updateExpandEditorPresentation() {
|
|
2397
|
+
const editor = document.getElementById("expand-editor");
|
|
2398
|
+
const highlight = document.getElementById("expand-editor-highlight");
|
|
2399
|
+
if (!editor || !highlight) return;
|
|
2400
|
+
const raw = editor.value || "";
|
|
2401
|
+
if (!raw.trim()) {
|
|
2402
|
+
highlight.innerHTML = "";
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
const prettyJson = toPrettyJson(raw);
|
|
2406
|
+
highlight.innerHTML = prettyJson ? highlightJson(raw) : escapeHtml(raw);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function syncExpandEditorScroll() {
|
|
2410
|
+
const editor = document.getElementById("expand-editor");
|
|
2411
|
+
const highlight = document.getElementById("expand-editor-highlight");
|
|
2412
|
+
if (!editor || !highlight) return;
|
|
2413
|
+
highlight.scrollTop = editor.scrollTop;
|
|
2414
|
+
highlight.scrollLeft = editor.scrollLeft;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
function formatResponseText(responseText) {
|
|
2418
|
+
const trimmed = (responseText || "").trim();
|
|
2419
|
+
if (!trimmed) return { text: "(empty)", isJson: false };
|
|
2420
|
+
try {
|
|
2421
|
+
return {
|
|
2422
|
+
text: JSON.stringify(JSON.parse(trimmed), null, 2),
|
|
2423
|
+
isJson: true,
|
|
2424
|
+
};
|
|
2425
|
+
} catch {
|
|
2426
|
+
return {
|
|
2427
|
+
text: responseText,
|
|
2428
|
+
isJson: false,
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function setResponseContent(headerText, bodyText, isJson) {
|
|
2434
|
+
const result = document.getElementById("result");
|
|
2435
|
+
if (!result) return;
|
|
2436
|
+
const fullText = String(headerText || "") + String(bodyText || "");
|
|
2437
|
+
result.dataset.raw = fullText;
|
|
2438
|
+
result.dataset.header = String(headerText || "");
|
|
2439
|
+
result.dataset.body = String(bodyText || "");
|
|
2440
|
+
result.dataset.isJson = isJson ? "true" : "false";
|
|
2441
|
+
if (isJson) {
|
|
2442
|
+
result.innerHTML = escapeHtml(String(headerText || "")) + highlightJson(String(bodyText || ""));
|
|
2443
|
+
} else {
|
|
2444
|
+
result.textContent = fullText;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function setSubmitLoading(isLoading) {
|
|
2449
|
+
const sendButton = document.getElementById("send-btn");
|
|
2450
|
+
const spinner = document.getElementById("send-btn-spinner");
|
|
2451
|
+
const label = document.getElementById("send-btn-label");
|
|
2452
|
+
if (!sendButton) return;
|
|
2453
|
+
|
|
2454
|
+
sendButton.disabled = isLoading;
|
|
2455
|
+
sendButton.classList.toggle("opacity-80", isLoading);
|
|
2456
|
+
sendButton.classList.toggle("cursor-wait", isLoading);
|
|
2457
|
+
if (spinner) spinner.classList.toggle("hidden", !isLoading);
|
|
2458
|
+
if (label) label.textContent = isLoading ? "Sending..." : "Submit";
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function updateRequestPreview() {
|
|
2462
|
+
if (!selected) return;
|
|
2463
|
+
|
|
2464
|
+
const { path, query } = getOperationParameterGroups(selected);
|
|
2465
|
+
const values = getParameterValues(selected);
|
|
2466
|
+
const requestPath = buildRequestPath(selected, path, query, values);
|
|
2467
|
+
const bodyInput = document.getElementById("body-input");
|
|
2468
|
+
const body = bodyInput ? bodyInput.value.trim() : "";
|
|
2469
|
+
const headers = getRequestHeadersObject();
|
|
2470
|
+
if (body && !hasHeaderName(headers, "Content-Type")) {
|
|
2471
|
+
headers["Content-Type"] = "application/json";
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
document.getElementById("endpoint-path").textContent = requestPath;
|
|
2475
|
+
document.getElementById("curl-code").textContent = buildCurl(
|
|
2476
|
+
selected,
|
|
2477
|
+
headers,
|
|
2478
|
+
body,
|
|
2479
|
+
requestPath,
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function renderEndpoint() {
|
|
2484
|
+
if (!selected) return;
|
|
2485
|
+
const endpointCard = document.getElementById("endpoint-card");
|
|
2486
|
+
if (endpointCard) {
|
|
2487
|
+
endpointCard.classList.remove("enter-fade-up");
|
|
2488
|
+
// Restart CSS animation for each operation switch
|
|
2489
|
+
void endpointCard.offsetWidth;
|
|
2490
|
+
endpointCard.classList.add("enter-fade-up");
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
const op = selected.operation || {};
|
|
2494
|
+
const reqSchema = op.requestBody && op.requestBody.content && op.requestBody.content["application/json"] && op.requestBody.content["application/json"].schema;
|
|
2495
|
+
const requestBodySection = document.getElementById("request-body-section");
|
|
2496
|
+
const bodyInput = document.getElementById("body-input");
|
|
2497
|
+
const expandBodyBtn = document.getElementById("expand-body-btn");
|
|
2498
|
+
const supportsBody = hasMeaningfulRequestBodySchema(reqSchema);
|
|
2499
|
+
|
|
2500
|
+
if (requestBodySection) {
|
|
2501
|
+
requestBodySection.classList.toggle("hidden", !supportsBody);
|
|
2502
|
+
}
|
|
2503
|
+
if (supportsBody && bodyInput) {
|
|
2504
|
+
const existingDraft = getBodyDraft(selected);
|
|
2505
|
+
if (typeof existingDraft === "string") {
|
|
2506
|
+
bodyInput.value = existingDraft;
|
|
2507
|
+
} else {
|
|
2508
|
+
const prefill = buildRequiredBodyPrefill(reqSchema);
|
|
2509
|
+
bodyInput.value = prefill;
|
|
2510
|
+
setBodyDraft(selected, prefill);
|
|
2511
|
+
}
|
|
2512
|
+
} else if (!supportsBody && bodyInput) {
|
|
2513
|
+
bodyInput.value = "";
|
|
2514
|
+
}
|
|
2515
|
+
if (expandBodyBtn) {
|
|
2516
|
+
expandBodyBtn.disabled = !supportsBody;
|
|
2517
|
+
}
|
|
2518
|
+
setResponseContent("", "", false);
|
|
2519
|
+
|
|
2520
|
+
document.getElementById("tag-title").textContent = selected.tag;
|
|
2521
|
+
document.getElementById("tag-description").textContent = op.description || "Interactive API documentation.";
|
|
2522
|
+
const methodNode = document.getElementById("endpoint-method");
|
|
2523
|
+
methodNode.textContent = selected.method;
|
|
2524
|
+
methodNode.className = "px-2.5 py-0.5 rounded-full text-xs font-mono font-medium " + (methodBadge[selected.method] || methodBadgeDefault);
|
|
2525
|
+
document.getElementById("endpoint-title").textContent = selected.name;
|
|
2526
|
+
document.getElementById("endpoint-path").textContent = selected.path;
|
|
2527
|
+
|
|
2528
|
+
const { all: params, query, path, headers } =
|
|
2529
|
+
getOperationParameterGroups(selected);
|
|
2530
|
+
|
|
2531
|
+
let html = "";
|
|
2532
|
+
html += renderParamSection("Path Parameters", path);
|
|
2533
|
+
html += renderParamSection("Query Parameters", query);
|
|
2534
|
+
html += renderParamSection("Header Parameters", headers);
|
|
2535
|
+
|
|
2536
|
+
html += renderRequestBodySchemaSection(reqSchema);
|
|
2537
|
+
html += renderResponseSchemasSection(op.responses);
|
|
2538
|
+
document.getElementById("params-column").innerHTML = html || '<div class="text-sm opacity-70">No parameters</div>';
|
|
2539
|
+
renderTryItParameterInputs(path, query);
|
|
2540
|
+
renderHeaderInputs();
|
|
2541
|
+
updateRequestPreview();
|
|
2542
|
+
updateBodyJsonPresentation();
|
|
2543
|
+
syncBodyEditorScroll();
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
document.getElementById("copy-curl").addEventListener("click", async () => {
|
|
2547
|
+
try { await navigator.clipboard.writeText(document.getElementById("curl-code").textContent || ""); } catch {}
|
|
2548
|
+
});
|
|
2549
|
+
document.getElementById("sidebar-search").addEventListener("input", (event) => {
|
|
2550
|
+
sidebarSearchQuery = event.currentTarget.value || "";
|
|
2551
|
+
renderSidebar();
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
document.getElementById("send-btn").addEventListener("click", async () => {
|
|
2555
|
+
if (!selected) return;
|
|
2556
|
+
const { path, query } = getOperationParameterGroups(selected);
|
|
2557
|
+
const values = getParameterValues(selected);
|
|
2558
|
+
const missingPathParams = path.filter((param) => {
|
|
2559
|
+
if (param.required === false) return false;
|
|
2560
|
+
const value = values[param.name];
|
|
2561
|
+
return value === undefined || value === null || String(value).trim() === "";
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
if (missingPathParams.length > 0) {
|
|
2565
|
+
setResponseContent(
|
|
2566
|
+
"",
|
|
2567
|
+
"Missing required path parameter(s): " +
|
|
2568
|
+
missingPathParams.map((param) => param.name).join(", "),
|
|
2569
|
+
false,
|
|
2570
|
+
);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
const requestPath = buildRequestPath(selected, path, query, values);
|
|
2575
|
+
formatBodyJsonInput();
|
|
2576
|
+
updateBodyJsonPresentation();
|
|
2577
|
+
const op = selected.operation || {};
|
|
2578
|
+
const reqSchema = op.requestBody && op.requestBody.content && op.requestBody.content["application/json"] && op.requestBody.content["application/json"].schema;
|
|
2579
|
+
const supportsBody = hasMeaningfulRequestBodySchema(reqSchema);
|
|
2580
|
+
const bodyInput = document.getElementById("body-input");
|
|
2581
|
+
const body =
|
|
2582
|
+
supportsBody && bodyInput ? bodyInput.value.trim() : "";
|
|
2583
|
+
const headers = getRequestHeadersObject();
|
|
2584
|
+
if (body && !hasHeaderName(headers, "Content-Type")) {
|
|
2585
|
+
headers["Content-Type"] = "application/json";
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
setSubmitLoading(true);
|
|
2589
|
+
try {
|
|
2590
|
+
const requestStart = performance.now();
|
|
2591
|
+
const response = await fetch(requestPath, { method: selected.method, headers, body: body || undefined });
|
|
2592
|
+
const text = await response.text();
|
|
2593
|
+
const responseTimeMs = Math.round(performance.now() - requestStart);
|
|
2594
|
+
const contentType = response.headers.get("content-type") || "unknown";
|
|
2595
|
+
const formattedResponse = formatResponseText(text);
|
|
2596
|
+
const headerText =
|
|
2597
|
+
"Status: " + response.status + " " + response.statusText + "\\n" +
|
|
2598
|
+
"Content-Type: " + contentType + "\\n" +
|
|
2599
|
+
"Response Time: " + responseTimeMs + " ms\\n\\n";
|
|
2600
|
+
setResponseContent(
|
|
2601
|
+
headerText,
|
|
2602
|
+
formattedResponse.text,
|
|
2603
|
+
formattedResponse.isJson,
|
|
2604
|
+
);
|
|
2605
|
+
} catch (error) {
|
|
2606
|
+
setResponseContent("", "Request failed: " + String(error), false);
|
|
2607
|
+
} finally {
|
|
2608
|
+
setSubmitLoading(false);
|
|
2609
|
+
}
|
|
2610
|
+
});
|
|
2611
|
+
|
|
2612
|
+
function openExpandModal(mode) {
|
|
2613
|
+
const modal = document.getElementById("expand-modal");
|
|
2614
|
+
const title = document.getElementById("expand-modal-title");
|
|
2615
|
+
const editorShell = document.getElementById("expand-editor-shell");
|
|
2616
|
+
const editor = document.getElementById("expand-editor");
|
|
2617
|
+
const viewer = document.getElementById("expand-viewer");
|
|
2618
|
+
const apply = document.getElementById("expand-apply");
|
|
2619
|
+
const bodyInput = document.getElementById("body-input");
|
|
2620
|
+
const result = document.getElementById("result");
|
|
2621
|
+
if (!modal || !title || !editorShell || !editor || !viewer || !apply) return;
|
|
2622
|
+
|
|
2623
|
+
expandModalMode = mode;
|
|
2624
|
+
modal.classList.remove("hidden");
|
|
2625
|
+
modal.classList.add("flex");
|
|
2626
|
+
|
|
2627
|
+
if (mode === "body") {
|
|
2628
|
+
title.textContent = "Request Body";
|
|
2629
|
+
editorShell.classList.remove("hidden");
|
|
2630
|
+
viewer.classList.add("hidden");
|
|
2631
|
+
apply.classList.remove("hidden");
|
|
2632
|
+
editor.value = bodyInput ? bodyInput.value : "";
|
|
2633
|
+
updateExpandEditorPresentation();
|
|
2634
|
+
syncExpandEditorScroll();
|
|
2635
|
+
} else {
|
|
2636
|
+
title.textContent = "Response";
|
|
2637
|
+
viewer.classList.remove("hidden");
|
|
2638
|
+
editorShell.classList.add("hidden");
|
|
2639
|
+
apply.classList.add("hidden");
|
|
2640
|
+
const hasResponse = Boolean(result && result.dataset && result.dataset.raw);
|
|
2641
|
+
if (!hasResponse) {
|
|
2642
|
+
viewer.textContent = "(empty response yet)";
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
const header = result.dataset.header || "";
|
|
2647
|
+
const body = result.dataset.body || "";
|
|
2648
|
+
const isJson = result.dataset.isJson === "true";
|
|
2649
|
+
if (isJson) {
|
|
2650
|
+
viewer.innerHTML = escapeHtml(header) + highlightJson(body);
|
|
2651
|
+
} else {
|
|
2652
|
+
viewer.textContent = result.dataset.raw || "(empty response yet)";
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function closeExpandModal() {
|
|
2658
|
+
const modal = document.getElementById("expand-modal");
|
|
2659
|
+
if (!modal) return;
|
|
2660
|
+
modal.classList.add("hidden");
|
|
2661
|
+
modal.classList.remove("flex");
|
|
2662
|
+
expandModalMode = null;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
document.getElementById("add-header-btn").addEventListener("click", () => {
|
|
2666
|
+
requestHeaders.push({ key: "", value: "" });
|
|
2667
|
+
renderHeaderInputs();
|
|
2668
|
+
updateRequestPreview();
|
|
2669
|
+
});
|
|
2670
|
+
document.getElementById("body-input").addEventListener("input", () => {
|
|
2671
|
+
if (selected) {
|
|
2672
|
+
setBodyDraft(selected, document.getElementById("body-input").value);
|
|
2673
|
+
}
|
|
2674
|
+
updateRequestPreview();
|
|
2675
|
+
updateBodyJsonPresentation();
|
|
2676
|
+
syncBodyEditorScroll();
|
|
2677
|
+
});
|
|
2678
|
+
document.getElementById("body-input").addEventListener("scroll", () => {
|
|
2679
|
+
syncBodyEditorScroll();
|
|
2680
|
+
});
|
|
2681
|
+
document.getElementById("body-input").addEventListener("keydown", (event) => {
|
|
2682
|
+
if (event.key !== "Tab") return;
|
|
2683
|
+
event.preventDefault();
|
|
2684
|
+
const input = event.currentTarget;
|
|
2685
|
+
const start = input.selectionStart;
|
|
2686
|
+
const end = input.selectionEnd;
|
|
2687
|
+
const value = input.value;
|
|
2688
|
+
const tab = " ";
|
|
2689
|
+
input.value = value.slice(0, start) + tab + value.slice(end);
|
|
2690
|
+
input.selectionStart = input.selectionEnd = start + tab.length;
|
|
2691
|
+
if (selected) {
|
|
2692
|
+
setBodyDraft(selected, input.value);
|
|
2693
|
+
}
|
|
2694
|
+
updateRequestPreview();
|
|
2695
|
+
updateBodyJsonPresentation();
|
|
2696
|
+
syncBodyEditorScroll();
|
|
2697
|
+
});
|
|
2698
|
+
document.getElementById("body-input").addEventListener("blur", () => {
|
|
2699
|
+
formatBodyJsonInput();
|
|
2700
|
+
if (selected) {
|
|
2701
|
+
setBodyDraft(selected, document.getElementById("body-input").value);
|
|
2702
|
+
}
|
|
2703
|
+
updateRequestPreview();
|
|
2704
|
+
updateBodyJsonPresentation();
|
|
2705
|
+
syncBodyEditorScroll();
|
|
2706
|
+
});
|
|
2707
|
+
document.getElementById("expand-editor").addEventListener("input", () => {
|
|
2708
|
+
updateExpandEditorPresentation();
|
|
2709
|
+
syncExpandEditorScroll();
|
|
2710
|
+
});
|
|
2711
|
+
document.getElementById("expand-editor").addEventListener("scroll", () => {
|
|
2712
|
+
syncExpandEditorScroll();
|
|
2713
|
+
});
|
|
2714
|
+
document.getElementById("expand-editor").addEventListener("keydown", (event) => {
|
|
2715
|
+
if (event.key !== "Tab") return;
|
|
2716
|
+
event.preventDefault();
|
|
2717
|
+
const editor = event.currentTarget;
|
|
2718
|
+
const start = editor.selectionStart;
|
|
2719
|
+
const end = editor.selectionEnd;
|
|
2720
|
+
const value = editor.value;
|
|
2721
|
+
const tab = " ";
|
|
2722
|
+
editor.value = value.slice(0, start) + tab + value.slice(end);
|
|
2723
|
+
editor.selectionStart = editor.selectionEnd = start + tab.length;
|
|
2724
|
+
updateExpandEditorPresentation();
|
|
2725
|
+
syncExpandEditorScroll();
|
|
2726
|
+
});
|
|
2727
|
+
document.getElementById("expand-editor").addEventListener("blur", () => {
|
|
2728
|
+
const editor = document.getElementById("expand-editor");
|
|
2729
|
+
const current = editor.value.trim();
|
|
2730
|
+
if (current) {
|
|
2731
|
+
try {
|
|
2732
|
+
editor.value = JSON.stringify(JSON.parse(current), null, 2);
|
|
2733
|
+
} catch {}
|
|
2734
|
+
}
|
|
2735
|
+
updateExpandEditorPresentation();
|
|
2736
|
+
syncExpandEditorScroll();
|
|
2737
|
+
});
|
|
2738
|
+
document.getElementById("expand-body-btn").addEventListener("click", () => {
|
|
2739
|
+
openExpandModal("body");
|
|
2740
|
+
});
|
|
2741
|
+
document.getElementById("expand-response-btn").addEventListener("click", () => {
|
|
2742
|
+
openExpandModal("response");
|
|
2743
|
+
});
|
|
2744
|
+
document.getElementById("expand-close").addEventListener("click", closeExpandModal);
|
|
2745
|
+
document.getElementById("expand-apply").addEventListener("click", () => {
|
|
2746
|
+
if (expandModalMode !== "body") {
|
|
2747
|
+
closeExpandModal();
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
const editor = document.getElementById("expand-editor");
|
|
2752
|
+
const bodyInput = document.getElementById("body-input");
|
|
2753
|
+
if (editor && bodyInput) {
|
|
2754
|
+
bodyInput.value = editor.value;
|
|
2755
|
+
formatBodyJsonInput();
|
|
2756
|
+
if (selected) {
|
|
2757
|
+
setBodyDraft(selected, bodyInput.value);
|
|
2758
|
+
}
|
|
2759
|
+
updateRequestPreview();
|
|
2760
|
+
updateBodyJsonPresentation();
|
|
2761
|
+
syncBodyEditorScroll();
|
|
2762
|
+
}
|
|
2763
|
+
closeExpandModal();
|
|
2764
|
+
});
|
|
2765
|
+
document.getElementById("expand-modal").addEventListener("click", (event) => {
|
|
2766
|
+
if (event.target === event.currentTarget) {
|
|
2767
|
+
closeExpandModal();
|
|
2768
|
+
}
|
|
2769
|
+
});
|
|
2770
|
+
document.getElementById("sidebar-open").addEventListener("click", () => {
|
|
2771
|
+
setMobileSidebarOpen(true);
|
|
2772
|
+
});
|
|
2773
|
+
document.getElementById("sidebar-close").addEventListener("click", () => {
|
|
2774
|
+
setMobileSidebarOpen(false);
|
|
2775
|
+
});
|
|
2776
|
+
document.getElementById("mobile-backdrop").addEventListener("click", () => {
|
|
2777
|
+
setMobileSidebarOpen(false);
|
|
2778
|
+
});
|
|
2779
|
+
window.addEventListener("resize", () => {
|
|
2780
|
+
if (window.innerWidth >= 768 && isMobileSidebarOpen) {
|
|
2781
|
+
setMobileSidebarOpen(false);
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
document.addEventListener("keydown", (event) => {
|
|
2785
|
+
if (event.key === "Escape") {
|
|
2786
|
+
if (isMobileSidebarOpen) {
|
|
2787
|
+
setMobileSidebarOpen(false);
|
|
2788
|
+
}
|
|
2789
|
+
closeExpandModal();
|
|
2790
|
+
}
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
const themeToggleBtn = document.getElementById('theme-toggle');
|
|
2794
|
+
const htmlElement = document.documentElement;
|
|
2795
|
+
themeToggleBtn.addEventListener('click', () => {
|
|
2796
|
+
htmlElement.classList.toggle('dark');
|
|
2797
|
+
if (htmlElement.classList.contains('dark')) {
|
|
2798
|
+
localStorage.setItem('theme', 'dark');
|
|
2799
|
+
} else {
|
|
2800
|
+
localStorage.setItem('theme', 'light');
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
|
2804
|
+
if (!('theme' in localStorage)) {
|
|
2805
|
+
if (e.matches) htmlElement.classList.add('dark');
|
|
2806
|
+
else htmlElement.classList.remove('dark');
|
|
2807
|
+
}
|
|
2808
|
+
});
|
|
2809
|
+
|
|
2810
|
+
setMobileSidebarOpen(false);
|
|
2811
|
+
renderSidebar();
|
|
2812
|
+
renderEndpoint();
|
|
2813
|
+
</script>
|
|
2814
|
+
</body>
|
|
2815
|
+
</html>`;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// src/openapi/generator.ts
|
|
2819
|
+
function isJSONSchemaCapable(schema) {
|
|
2820
|
+
const standard = schema?.["~standard"];
|
|
2821
|
+
const converter = standard?.jsonSchema;
|
|
2822
|
+
return !!standard && typeof standard === "object" && standard.version === 1 && !!converter && typeof converter.input === "function" && typeof converter.output === "function";
|
|
2823
|
+
}
|
|
2824
|
+
function normalizeRoutePathForOpenAPI(path) {
|
|
2825
|
+
let wildcardCount = 0;
|
|
2826
|
+
const pathParamNames = [];
|
|
2827
|
+
const segments = path.split("/").map((segment) => {
|
|
2828
|
+
const greedyParamMatch = /^:([A-Za-z0-9_]+)\+$/.exec(segment);
|
|
2829
|
+
if (greedyParamMatch?.[1]) {
|
|
2830
|
+
pathParamNames.push(greedyParamMatch[1]);
|
|
2831
|
+
return `{${greedyParamMatch[1]}}`;
|
|
2832
|
+
}
|
|
2833
|
+
const paramMatch = /^:([A-Za-z0-9_]+)$/.exec(segment);
|
|
2834
|
+
if (paramMatch?.[1]) {
|
|
2835
|
+
pathParamNames.push(paramMatch[1]);
|
|
2836
|
+
return `{${paramMatch[1]}}`;
|
|
2837
|
+
}
|
|
2838
|
+
if (segment === "*") {
|
|
2839
|
+
wildcardCount += 1;
|
|
2840
|
+
const wildcardParamName = wildcardCount === 1 ? "wildcard" : `wildcard${wildcardCount}`;
|
|
2841
|
+
pathParamNames.push(wildcardParamName);
|
|
2842
|
+
return `{${wildcardParamName}}`;
|
|
2843
|
+
}
|
|
2844
|
+
return segment;
|
|
2845
|
+
});
|
|
2846
|
+
return {
|
|
2847
|
+
openapiPath: segments.join("/"),
|
|
2848
|
+
pathParamNames
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
function toOpenAPIPath(path) {
|
|
2852
|
+
return normalizeRoutePathForOpenAPI(path).openapiPath;
|
|
2853
|
+
}
|
|
2854
|
+
function createOperationId(method, path) {
|
|
2855
|
+
const normalized = `${method.toLowerCase()}_${path}`.replace(/[:{}]/g, "").replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
|
|
2856
|
+
return normalized || `${method.toLowerCase()}_operation`;
|
|
2857
|
+
}
|
|
2858
|
+
function inferTagFromPath(path) {
|
|
2859
|
+
const segments = path.split("/").filter(Boolean);
|
|
2860
|
+
for (const segment of segments) {
|
|
2861
|
+
if (!segment.startsWith(":") && segment !== "*") {
|
|
2862
|
+
return segment.toLowerCase();
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
return "default";
|
|
2866
|
+
}
|
|
2867
|
+
function extractPathParamNames(path) {
|
|
2868
|
+
return normalizeRoutePathForOpenAPI(path).pathParamNames;
|
|
2869
|
+
}
|
|
2870
|
+
function addMissingPathParameters(operation, routePath) {
|
|
2871
|
+
const existingPathNames = new Set((operation.parameters || []).filter((p) => p.in === "path").map((p) => String(p.name)));
|
|
2872
|
+
for (const pathName of extractPathParamNames(routePath)) {
|
|
2873
|
+
if (existingPathNames.has(pathName))
|
|
2874
|
+
continue;
|
|
2875
|
+
(operation.parameters ||= []).push({
|
|
2876
|
+
name: pathName,
|
|
2877
|
+
in: "path",
|
|
2878
|
+
required: true,
|
|
2879
|
+
schema: { type: "string" }
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
function isNoBodyResponseStatus(status) {
|
|
2884
|
+
const numericStatus = Number(status);
|
|
2885
|
+
if (!Number.isInteger(numericStatus))
|
|
2886
|
+
return false;
|
|
2887
|
+
return numericStatus >= 100 && numericStatus < 200 || numericStatus === 204 || numericStatus === 205 || numericStatus === 304;
|
|
2888
|
+
}
|
|
2889
|
+
function getResponseDescription(status) {
|
|
2890
|
+
if (status === "204")
|
|
2891
|
+
return "No Content";
|
|
2892
|
+
if (status === "205")
|
|
2893
|
+
return "Reset Content";
|
|
2894
|
+
if (status === "304")
|
|
2895
|
+
return "Not Modified";
|
|
2896
|
+
const numericStatus = Number(status);
|
|
2897
|
+
if (Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus < 200) {
|
|
2898
|
+
return "Informational";
|
|
2899
|
+
}
|
|
2900
|
+
return "OK";
|
|
2901
|
+
}
|
|
2902
|
+
function convertInputSchema(routePath, inputSchema, target, warnings) {
|
|
2903
|
+
if (!isJSONSchemaCapable(inputSchema)) {
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
try {
|
|
2907
|
+
return inputSchema["~standard"].jsonSchema.input({ target });
|
|
2908
|
+
} catch (error) {
|
|
2909
|
+
warnings.push(`[OpenAPI] Failed input schema conversion for ${routePath}: ${error instanceof Error ? error.message : String(error)}. Falling back to a permissive JSON Schema.`);
|
|
2910
|
+
const fallback = buildFallbackJSONSchema(inputSchema);
|
|
2911
|
+
return isEmptyObjectSchema(fallback) ? null : fallback;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
function convertOutputSchema(routePath, statusCode, outputSchema, target, warnings) {
|
|
2915
|
+
if (!isJSONSchemaCapable(outputSchema)) {
|
|
2916
|
+
return null;
|
|
2917
|
+
}
|
|
2918
|
+
try {
|
|
2919
|
+
return outputSchema["~standard"].jsonSchema.output({ target });
|
|
2920
|
+
} catch (error) {
|
|
2921
|
+
warnings.push(`[OpenAPI] Failed output schema conversion for ${routePath} (${statusCode}): ${error instanceof Error ? error.message : String(error)}. Falling back to a permissive JSON Schema.`);
|
|
2922
|
+
return buildFallbackJSONSchema(outputSchema);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
function isRecord(value) {
|
|
2926
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2927
|
+
}
|
|
2928
|
+
function isEmptyObjectSchema(value) {
|
|
2929
|
+
return isRecord(value) && Object.keys(value).length === 0;
|
|
2930
|
+
}
|
|
2931
|
+
function getValidatorSchemaDef(schema) {
|
|
2932
|
+
if (!schema || typeof schema !== "object")
|
|
2933
|
+
return null;
|
|
2934
|
+
const value = schema;
|
|
2935
|
+
if (isRecord(value._def))
|
|
2936
|
+
return value._def;
|
|
2937
|
+
if (isRecord(value._zod) && isRecord(value._zod.def)) {
|
|
2938
|
+
return value._zod.def;
|
|
2939
|
+
}
|
|
2940
|
+
return null;
|
|
2941
|
+
}
|
|
2942
|
+
function getSchemaKind(def) {
|
|
2943
|
+
if (!def)
|
|
2944
|
+
return null;
|
|
2945
|
+
const typeName = def.typeName;
|
|
2946
|
+
if (typeof typeName === "string")
|
|
2947
|
+
return typeName;
|
|
2948
|
+
const type = def.type;
|
|
2949
|
+
if (typeof type === "string")
|
|
2950
|
+
return type;
|
|
2951
|
+
return null;
|
|
2952
|
+
}
|
|
2953
|
+
function pickSchemaChild(def) {
|
|
2954
|
+
const candidates = ["innerType", "schema", "type", "out", "in", "left", "right"];
|
|
2955
|
+
for (const key of candidates) {
|
|
2956
|
+
if (key in def)
|
|
2957
|
+
return def[key];
|
|
2958
|
+
}
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
function pickSchemaObjectCandidate(def, keys) {
|
|
2962
|
+
for (const key of keys) {
|
|
2963
|
+
const value = def[key];
|
|
2964
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
2965
|
+
return value;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
function isOptionalWrapperKind(kind) {
|
|
2971
|
+
if (!kind)
|
|
2972
|
+
return false;
|
|
2973
|
+
const lower = kind.toLowerCase();
|
|
2974
|
+
return lower.includes("optional") || lower.includes("default") || lower.includes("catch");
|
|
2975
|
+
}
|
|
2976
|
+
function unwrapOptionalForRequired(schema) {
|
|
2977
|
+
let current = schema;
|
|
2978
|
+
let optional = false;
|
|
2979
|
+
let guard = 0;
|
|
2980
|
+
while (guard < 8) {
|
|
2981
|
+
guard += 1;
|
|
2982
|
+
const def = getValidatorSchemaDef(current);
|
|
2983
|
+
const kind = getSchemaKind(def);
|
|
2984
|
+
if (!def || !isOptionalWrapperKind(kind))
|
|
2985
|
+
break;
|
|
2986
|
+
optional = true;
|
|
2987
|
+
const inner = pickSchemaChild(def);
|
|
2988
|
+
if (!inner)
|
|
2989
|
+
break;
|
|
2990
|
+
current = inner;
|
|
2991
|
+
}
|
|
2992
|
+
return { schema: current, optional };
|
|
2993
|
+
}
|
|
2994
|
+
function getObjectShape(def) {
|
|
2995
|
+
const rawShape = def.shape;
|
|
2996
|
+
if (typeof rawShape === "function") {
|
|
2997
|
+
try {
|
|
2998
|
+
const resolved = rawShape();
|
|
2999
|
+
return isRecord(resolved) ? resolved : {};
|
|
3000
|
+
} catch {
|
|
3001
|
+
return {};
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
return isRecord(rawShape) ? rawShape : {};
|
|
3005
|
+
}
|
|
3006
|
+
function mapPrimitiveKind(kind) {
|
|
3007
|
+
const lower = kind.toLowerCase();
|
|
3008
|
+
if (lower.includes("string"))
|
|
3009
|
+
return { type: "string" };
|
|
3010
|
+
if (lower.includes("number"))
|
|
3011
|
+
return { type: "number" };
|
|
3012
|
+
if (lower.includes("boolean"))
|
|
3013
|
+
return { type: "boolean" };
|
|
3014
|
+
if (lower.includes("bigint"))
|
|
3015
|
+
return { type: "string" };
|
|
3016
|
+
if (lower.includes("null"))
|
|
3017
|
+
return { type: "null" };
|
|
3018
|
+
if (lower.includes("any") || lower.includes("unknown") || lower.includes("never"))
|
|
3019
|
+
return {};
|
|
3020
|
+
if (lower.includes("date"))
|
|
3021
|
+
return { type: "string", format: "date-time" };
|
|
3022
|
+
if (lower.includes("custom"))
|
|
3023
|
+
return { type: "object", additionalProperties: true };
|
|
3024
|
+
return null;
|
|
3025
|
+
}
|
|
3026
|
+
function buildIntrospectedFallbackJSONSchema(schema, seen = new WeakSet) {
|
|
3027
|
+
if (!schema || typeof schema !== "object")
|
|
3028
|
+
return {};
|
|
3029
|
+
if (seen.has(schema))
|
|
3030
|
+
return {};
|
|
3031
|
+
seen.add(schema);
|
|
3032
|
+
const def = getValidatorSchemaDef(schema);
|
|
3033
|
+
const kind = getSchemaKind(def);
|
|
3034
|
+
if (!def || !kind)
|
|
3035
|
+
return {};
|
|
3036
|
+
const primitive = mapPrimitiveKind(kind);
|
|
3037
|
+
if (primitive)
|
|
3038
|
+
return primitive;
|
|
3039
|
+
const lower = kind.toLowerCase();
|
|
3040
|
+
if (lower.includes("object")) {
|
|
3041
|
+
const shape = getObjectShape(def);
|
|
3042
|
+
const properties = {};
|
|
3043
|
+
const required = [];
|
|
3044
|
+
for (const [key, child2] of Object.entries(shape)) {
|
|
3045
|
+
const unwrapped = unwrapOptionalForRequired(child2);
|
|
3046
|
+
properties[key] = buildIntrospectedFallbackJSONSchema(unwrapped.schema, seen);
|
|
3047
|
+
if (!unwrapped.optional)
|
|
3048
|
+
required.push(key);
|
|
3049
|
+
}
|
|
3050
|
+
const out = {
|
|
3051
|
+
type: "object",
|
|
3052
|
+
properties,
|
|
3053
|
+
additionalProperties: true
|
|
3054
|
+
};
|
|
3055
|
+
if (required.length > 0) {
|
|
3056
|
+
out.required = required;
|
|
3057
|
+
}
|
|
3058
|
+
return out;
|
|
3059
|
+
}
|
|
3060
|
+
if (lower.includes("array")) {
|
|
3061
|
+
const itemSchema = pickSchemaObjectCandidate(def, ["element", "items", "innerType", "type"]) ?? {};
|
|
3062
|
+
return {
|
|
3063
|
+
type: "array",
|
|
3064
|
+
items: buildIntrospectedFallbackJSONSchema(itemSchema, seen)
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
if (lower.includes("record")) {
|
|
3068
|
+
const valueType = def.valueType ?? def.valueSchema;
|
|
3069
|
+
return {
|
|
3070
|
+
type: "object",
|
|
3071
|
+
additionalProperties: valueType ? buildIntrospectedFallbackJSONSchema(valueType, seen) : true
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
if (lower.includes("tuple")) {
|
|
3075
|
+
const items = Array.isArray(def.items) ? def.items : [];
|
|
3076
|
+
const prefixItems = items.map((item) => buildIntrospectedFallbackJSONSchema(item, seen));
|
|
3077
|
+
return {
|
|
3078
|
+
type: "array",
|
|
3079
|
+
prefixItems,
|
|
3080
|
+
minItems: prefixItems.length,
|
|
3081
|
+
maxItems: prefixItems.length
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
if (lower.includes("union")) {
|
|
3085
|
+
const options = def.options ?? def.schemas ?? [];
|
|
3086
|
+
if (!Array.isArray(options) || options.length === 0)
|
|
3087
|
+
return {};
|
|
3088
|
+
return {
|
|
3089
|
+
anyOf: options.map((option) => buildIntrospectedFallbackJSONSchema(option, seen))
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
if (lower.includes("intersection")) {
|
|
3093
|
+
const left = def.left;
|
|
3094
|
+
const right = def.right;
|
|
3095
|
+
if (!left || !right)
|
|
3096
|
+
return {};
|
|
3097
|
+
return {
|
|
3098
|
+
allOf: [buildIntrospectedFallbackJSONSchema(left, seen), buildIntrospectedFallbackJSONSchema(right, seen)]
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
if (lower.includes("enum")) {
|
|
3102
|
+
const values = def.values;
|
|
3103
|
+
if (Array.isArray(values))
|
|
3104
|
+
return { enum: values };
|
|
3105
|
+
if (values && typeof values === "object")
|
|
3106
|
+
return { enum: Object.values(values) };
|
|
3107
|
+
return {};
|
|
3108
|
+
}
|
|
3109
|
+
if (lower.includes("literal")) {
|
|
3110
|
+
const value = def.value;
|
|
3111
|
+
if (value === undefined)
|
|
3112
|
+
return {};
|
|
3113
|
+
const valueType = value === null ? "null" : typeof value;
|
|
3114
|
+
if (valueType === "string" || valueType === "number" || valueType === "boolean" || valueType === "null") {
|
|
3115
|
+
return { type: valueType, const: value };
|
|
3116
|
+
}
|
|
3117
|
+
return { const: value };
|
|
3118
|
+
}
|
|
3119
|
+
if (lower.includes("nullable")) {
|
|
3120
|
+
const inner = pickSchemaChild(def);
|
|
3121
|
+
if (!inner)
|
|
3122
|
+
return {};
|
|
3123
|
+
return {
|
|
3124
|
+
anyOf: [buildIntrospectedFallbackJSONSchema(inner, seen), { type: "null" }]
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
if (lower.includes("lazy")) {
|
|
3128
|
+
const getter = def.getter;
|
|
3129
|
+
if (typeof getter !== "function")
|
|
3130
|
+
return {};
|
|
3131
|
+
try {
|
|
3132
|
+
return buildIntrospectedFallbackJSONSchema(getter(), seen);
|
|
3133
|
+
} catch {
|
|
3134
|
+
return {};
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
const child = pickSchemaChild(def);
|
|
3138
|
+
if (child)
|
|
3139
|
+
return buildIntrospectedFallbackJSONSchema(child, seen);
|
|
3140
|
+
return {};
|
|
3141
|
+
}
|
|
3142
|
+
function buildFallbackJSONSchema(schema) {
|
|
3143
|
+
const def = getValidatorSchemaDef(schema);
|
|
3144
|
+
if (!def)
|
|
3145
|
+
return {};
|
|
3146
|
+
return buildIntrospectedFallbackJSONSchema(schema);
|
|
3147
|
+
}
|
|
3148
|
+
function addStructuredInputToOperation(operation, inputJSONSchema) {
|
|
3149
|
+
if (!isRecord(inputJSONSchema))
|
|
3150
|
+
return;
|
|
3151
|
+
if (inputJSONSchema.type !== "object" || !isRecord(inputJSONSchema.properties)) {
|
|
3152
|
+
operation.requestBody = {
|
|
3153
|
+
required: true,
|
|
3154
|
+
content: {
|
|
3155
|
+
"application/json": {
|
|
3156
|
+
schema: inputJSONSchema
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
};
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
const rootRequired = new Set(Array.isArray(inputJSONSchema.required) ? inputJSONSchema.required : []);
|
|
3163
|
+
const properties = inputJSONSchema.properties;
|
|
3164
|
+
const parameters = Array.isArray(operation.parameters) ? operation.parameters : [];
|
|
3165
|
+
const parameterSections = [
|
|
3166
|
+
{ key: "params", in: "path" },
|
|
3167
|
+
{ key: "query", in: "query" },
|
|
3168
|
+
{ key: "headers", in: "header" },
|
|
3169
|
+
{ key: "cookies", in: "cookie" }
|
|
3170
|
+
];
|
|
3171
|
+
for (const section of parameterSections) {
|
|
3172
|
+
const sectionSchema = properties[section.key];
|
|
3173
|
+
if (!isRecord(sectionSchema))
|
|
3174
|
+
continue;
|
|
3175
|
+
if (sectionSchema.type !== "object" || !isRecord(sectionSchema.properties))
|
|
3176
|
+
continue;
|
|
3177
|
+
const sectionRequired = new Set(Array.isArray(sectionSchema.required) ? sectionSchema.required : []);
|
|
3178
|
+
for (const [name, schema] of Object.entries(sectionSchema.properties)) {
|
|
3179
|
+
parameters.push({
|
|
3180
|
+
name,
|
|
3181
|
+
in: section.in,
|
|
3182
|
+
required: section.in === "path" ? true : sectionRequired.has(name),
|
|
3183
|
+
schema: isRecord(schema) ? schema : {}
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
if (parameters.length > 0) {
|
|
3188
|
+
const deduped = new Map;
|
|
3189
|
+
for (const parameter of parameters) {
|
|
3190
|
+
deduped.set(`${parameter.in}:${parameter.name}`, parameter);
|
|
3191
|
+
}
|
|
3192
|
+
operation.parameters = [...deduped.values()];
|
|
3193
|
+
}
|
|
3194
|
+
const bodySchema = properties.body;
|
|
3195
|
+
if (bodySchema) {
|
|
3196
|
+
operation.requestBody = {
|
|
3197
|
+
required: rootRequired.has("body"),
|
|
3198
|
+
content: {
|
|
3199
|
+
"application/json": {
|
|
3200
|
+
schema: isRecord(bodySchema) ? bodySchema : {}
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
function addOutputSchemasToOperation(operation, routePath, routeSchema, target, warnings) {
|
|
3207
|
+
const output = routeSchema.output;
|
|
3208
|
+
if (!output) {
|
|
3209
|
+
operation.responses = {
|
|
3210
|
+
200: { description: "OK" }
|
|
3211
|
+
};
|
|
3212
|
+
return;
|
|
856
3213
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
3214
|
+
const responses = {};
|
|
3215
|
+
if (typeof output === "object" && output !== null && "~standard" in output) {
|
|
3216
|
+
const outputSchema = convertOutputSchema(routePath, "200", output, target, warnings);
|
|
3217
|
+
if (outputSchema) {
|
|
3218
|
+
responses["200"] = {
|
|
3219
|
+
description: "OK",
|
|
3220
|
+
content: {
|
|
3221
|
+
"application/json": {
|
|
3222
|
+
schema: outputSchema
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
};
|
|
3226
|
+
} else {
|
|
3227
|
+
responses["200"] = { description: "OK" };
|
|
3228
|
+
}
|
|
3229
|
+
} else {
|
|
3230
|
+
for (const [statusCode, schema] of Object.entries(output)) {
|
|
3231
|
+
const status = String(statusCode);
|
|
3232
|
+
const outputSchema = convertOutputSchema(routePath, status, schema, target, warnings);
|
|
3233
|
+
const description = getResponseDescription(status);
|
|
3234
|
+
if (outputSchema && !isNoBodyResponseStatus(status)) {
|
|
3235
|
+
responses[status] = {
|
|
3236
|
+
description,
|
|
3237
|
+
content: {
|
|
3238
|
+
"application/json": {
|
|
3239
|
+
schema: outputSchema
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
};
|
|
3243
|
+
} else {
|
|
3244
|
+
responses[status] = {
|
|
3245
|
+
description
|
|
3246
|
+
};
|
|
3247
|
+
}
|
|
860
3248
|
}
|
|
861
|
-
this.sortRoutes();
|
|
862
3249
|
}
|
|
863
|
-
|
|
864
|
-
|
|
3250
|
+
if (Object.keys(responses).length === 0) {
|
|
3251
|
+
responses["200"] = { description: "OK" };
|
|
865
3252
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
3253
|
+
operation.responses = responses;
|
|
3254
|
+
}
|
|
3255
|
+
function generateOpenAPIDocument(routes, options) {
|
|
3256
|
+
const warnings = [];
|
|
3257
|
+
const paths = {};
|
|
3258
|
+
for (const route of routes) {
|
|
3259
|
+
if (route.options.expose === false)
|
|
3260
|
+
continue;
|
|
3261
|
+
if (!route.method || !route.path)
|
|
3262
|
+
continue;
|
|
3263
|
+
const method = route.method.toLowerCase();
|
|
3264
|
+
if (method === "options")
|
|
3265
|
+
continue;
|
|
3266
|
+
const openapiPath = toOpenAPIPath(route.path);
|
|
3267
|
+
const operation = {
|
|
3268
|
+
operationId: createOperationId(method, openapiPath),
|
|
3269
|
+
tags: [route.options.schema?.tag || inferTagFromPath(route.path)]
|
|
3270
|
+
};
|
|
3271
|
+
const inputJSONSchema = convertInputSchema(route.path, route.options.schema?.input, options.target, warnings);
|
|
3272
|
+
if (inputJSONSchema) {
|
|
3273
|
+
addStructuredInputToOperation(operation, inputJSONSchema);
|
|
872
3274
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
3275
|
+
addMissingPathParameters(operation, route.path);
|
|
3276
|
+
addOutputSchemasToOperation(operation, route.path, route.options.schema || {}, options.target, warnings);
|
|
3277
|
+
paths[openapiPath] ||= {};
|
|
3278
|
+
paths[openapiPath][method] = operation;
|
|
3279
|
+
}
|
|
3280
|
+
const openapiVersion = options.target === "openapi-3.0" ? "3.0.3" : "3.1.0";
|
|
3281
|
+
const document = {
|
|
3282
|
+
openapi: openapiVersion,
|
|
3283
|
+
info: {
|
|
3284
|
+
title: options.info?.title || "Vector API",
|
|
3285
|
+
version: options.info?.version || "1.0.0",
|
|
3286
|
+
...options.info?.description ? { description: options.info.description } : {}
|
|
3287
|
+
},
|
|
3288
|
+
paths
|
|
3289
|
+
};
|
|
3290
|
+
return {
|
|
3291
|
+
document,
|
|
3292
|
+
warnings
|
|
3293
|
+
};
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
// src/core/server.ts
|
|
3297
|
+
var OPENAPI_TAILWIND_ASSET_PATH = "/_vector/openapi/tailwindcdn.js";
|
|
3298
|
+
var OPENAPI_LOGO_DARK_ASSET_PATH = "/_vector/openapi/logo_dark.svg";
|
|
3299
|
+
var OPENAPI_LOGO_WHITE_ASSET_PATH = "/_vector/openapi/logo_white.svg";
|
|
3300
|
+
var OPENAPI_APPLE_TOUCH_ICON_ASSET_PATH = "/_vector/openapi/favicon/apple-touch-icon.png";
|
|
3301
|
+
var OPENAPI_FAVICON_32_ASSET_PATH = "/_vector/openapi/favicon/favicon-32x32.png";
|
|
3302
|
+
var OPENAPI_FAVICON_16_ASSET_PATH = "/_vector/openapi/favicon/favicon-16x16.png";
|
|
3303
|
+
var OPENAPI_FAVICON_ICO_ASSET_PATH = "/_vector/openapi/favicon/favicon.ico";
|
|
3304
|
+
var OPENAPI_WEBMANIFEST_ASSET_PATH = "/_vector/openapi/favicon/site.webmanifest";
|
|
3305
|
+
var OPENAPI_ANDROID_192_ASSET_PATH = "/_vector/openapi/favicon/android-chrome-192x192.png";
|
|
3306
|
+
var OPENAPI_ANDROID_512_ASSET_PATH = "/_vector/openapi/favicon/android-chrome-512x512.png";
|
|
3307
|
+
var OPENAPI_TAILWIND_ASSET_RELATIVE_CANDIDATES = [
|
|
3308
|
+
"../openapi/assets/tailwindcdn.js",
|
|
3309
|
+
"../src/openapi/assets/tailwindcdn.js",
|
|
3310
|
+
"../../src/openapi/assets/tailwindcdn.js"
|
|
3311
|
+
];
|
|
3312
|
+
var OPENAPI_LOGO_DARK_ASSET_RELATIVE_CANDIDATES = [
|
|
3313
|
+
"../openapi/assets/logo_dark.svg",
|
|
3314
|
+
"../src/openapi/assets/logo_dark.svg",
|
|
3315
|
+
"../../src/openapi/assets/logo_dark.svg"
|
|
3316
|
+
];
|
|
3317
|
+
var OPENAPI_LOGO_WHITE_ASSET_RELATIVE_CANDIDATES = [
|
|
3318
|
+
"../openapi/assets/logo_white.svg",
|
|
3319
|
+
"../src/openapi/assets/logo_white.svg",
|
|
3320
|
+
"../../src/openapi/assets/logo_white.svg"
|
|
3321
|
+
];
|
|
3322
|
+
var OPENAPI_TAILWIND_ASSET_CWD_CANDIDATES = [
|
|
3323
|
+
"src/openapi/assets/tailwindcdn.js",
|
|
3324
|
+
"openapi/assets/tailwindcdn.js",
|
|
3325
|
+
"dist/openapi/assets/tailwindcdn.js"
|
|
3326
|
+
];
|
|
3327
|
+
var OPENAPI_LOGO_DARK_ASSET_CWD_CANDIDATES = [
|
|
3328
|
+
"src/openapi/assets/logo_dark.svg",
|
|
3329
|
+
"openapi/assets/logo_dark.svg",
|
|
3330
|
+
"dist/openapi/assets/logo_dark.svg"
|
|
3331
|
+
];
|
|
3332
|
+
var OPENAPI_LOGO_WHITE_ASSET_CWD_CANDIDATES = [
|
|
3333
|
+
"src/openapi/assets/logo_white.svg",
|
|
3334
|
+
"openapi/assets/logo_white.svg",
|
|
3335
|
+
"dist/openapi/assets/logo_white.svg"
|
|
3336
|
+
];
|
|
3337
|
+
var OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES = [
|
|
3338
|
+
"../openapi/assets/favicon",
|
|
3339
|
+
"../src/openapi/assets/favicon",
|
|
3340
|
+
"../../src/openapi/assets/favicon"
|
|
3341
|
+
];
|
|
3342
|
+
var OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES = [
|
|
3343
|
+
"src/openapi/assets/favicon",
|
|
3344
|
+
"openapi/assets/favicon",
|
|
3345
|
+
"dist/openapi/assets/favicon"
|
|
3346
|
+
];
|
|
3347
|
+
var OPENAPI_TAILWIND_ASSET_INLINE_FALLBACK = "/* OpenAPI docs runtime asset missing: tailwind disabled */";
|
|
3348
|
+
function buildOpenAPIAssetCandidatePaths(bases, filename) {
|
|
3349
|
+
return bases.map((base) => `${base}/${filename}`);
|
|
3350
|
+
}
|
|
3351
|
+
function resolveOpenAPIAssetFile(relativeCandidates, cwdCandidates) {
|
|
3352
|
+
for (const relativePath of relativeCandidates) {
|
|
3353
|
+
try {
|
|
3354
|
+
const fileUrl = new URL(relativePath, import.meta.url);
|
|
3355
|
+
if (existsSync3(fileUrl)) {
|
|
3356
|
+
return Bun.file(fileUrl);
|
|
890
3357
|
}
|
|
3358
|
+
} catch {}
|
|
3359
|
+
}
|
|
3360
|
+
const cwd = process.cwd();
|
|
3361
|
+
for (const relativePath of cwdCandidates) {
|
|
3362
|
+
const absolutePath = join2(cwd, relativePath);
|
|
3363
|
+
if (existsSync3(absolutePath)) {
|
|
3364
|
+
return Bun.file(absolutePath);
|
|
891
3365
|
}
|
|
892
|
-
return APIError.notFound("Route not found");
|
|
893
3366
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
3367
|
+
return null;
|
|
3368
|
+
}
|
|
3369
|
+
var OPENAPI_TAILWIND_ASSET_FILE = resolveOpenAPIAssetFile(OPENAPI_TAILWIND_ASSET_RELATIVE_CANDIDATES, OPENAPI_TAILWIND_ASSET_CWD_CANDIDATES);
|
|
3370
|
+
var OPENAPI_LOGO_DARK_ASSET_FILE = resolveOpenAPIAssetFile(OPENAPI_LOGO_DARK_ASSET_RELATIVE_CANDIDATES, OPENAPI_LOGO_DARK_ASSET_CWD_CANDIDATES);
|
|
3371
|
+
var OPENAPI_LOGO_WHITE_ASSET_FILE = resolveOpenAPIAssetFile(OPENAPI_LOGO_WHITE_ASSET_RELATIVE_CANDIDATES, OPENAPI_LOGO_WHITE_ASSET_CWD_CANDIDATES);
|
|
3372
|
+
var OPENAPI_APPLE_TOUCH_ICON_ASSET_FILE = resolveOpenAPIAssetFile(buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, "apple-touch-icon.png"), buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, "apple-touch-icon.png"));
|
|
3373
|
+
var OPENAPI_FAVICON_32_ASSET_FILE = resolveOpenAPIAssetFile(buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, "favicon-32x32.png"), buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, "favicon-32x32.png"));
|
|
3374
|
+
var OPENAPI_FAVICON_16_ASSET_FILE = resolveOpenAPIAssetFile(buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, "favicon-16x16.png"), buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, "favicon-16x16.png"));
|
|
3375
|
+
var OPENAPI_FAVICON_ICO_ASSET_FILE = resolveOpenAPIAssetFile(buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, "favicon.ico"), buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, "favicon.ico"));
|
|
3376
|
+
var OPENAPI_WEBMANIFEST_ASSET_FILE = resolveOpenAPIAssetFile(buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, "site.webmanifest"), buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, "site.webmanifest"));
|
|
3377
|
+
var OPENAPI_ANDROID_192_ASSET_FILE = resolveOpenAPIAssetFile(buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, "android-chrome-192x192.png"), buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, "android-chrome-192x192.png"));
|
|
3378
|
+
var OPENAPI_ANDROID_512_ASSET_FILE = resolveOpenAPIAssetFile(buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_RELATIVE_BASE_CANDIDATES, "android-chrome-512x512.png"), buildOpenAPIAssetCandidatePaths(OPENAPI_FAVICON_ASSET_CWD_BASE_CANDIDATES, "android-chrome-512x512.png"));
|
|
3379
|
+
var OPENAPI_FAVICON_ASSETS = [
|
|
3380
|
+
{
|
|
3381
|
+
path: OPENAPI_APPLE_TOUCH_ICON_ASSET_PATH,
|
|
3382
|
+
file: OPENAPI_APPLE_TOUCH_ICON_ASSET_FILE,
|
|
3383
|
+
contentType: "image/png",
|
|
3384
|
+
filename: "apple-touch-icon.png"
|
|
3385
|
+
},
|
|
3386
|
+
{
|
|
3387
|
+
path: OPENAPI_FAVICON_32_ASSET_PATH,
|
|
3388
|
+
file: OPENAPI_FAVICON_32_ASSET_FILE,
|
|
3389
|
+
contentType: "image/png",
|
|
3390
|
+
filename: "favicon-32x32.png"
|
|
3391
|
+
},
|
|
3392
|
+
{
|
|
3393
|
+
path: OPENAPI_FAVICON_16_ASSET_PATH,
|
|
3394
|
+
file: OPENAPI_FAVICON_16_ASSET_FILE,
|
|
3395
|
+
contentType: "image/png",
|
|
3396
|
+
filename: "favicon-16x16.png"
|
|
3397
|
+
},
|
|
3398
|
+
{
|
|
3399
|
+
path: OPENAPI_FAVICON_ICO_ASSET_PATH,
|
|
3400
|
+
file: OPENAPI_FAVICON_ICO_ASSET_FILE,
|
|
3401
|
+
contentType: "image/x-icon",
|
|
3402
|
+
filename: "favicon.ico"
|
|
3403
|
+
},
|
|
3404
|
+
{
|
|
3405
|
+
path: OPENAPI_WEBMANIFEST_ASSET_PATH,
|
|
3406
|
+
file: OPENAPI_WEBMANIFEST_ASSET_FILE,
|
|
3407
|
+
contentType: "application/manifest+json; charset=utf-8",
|
|
3408
|
+
filename: "site.webmanifest"
|
|
3409
|
+
},
|
|
3410
|
+
{
|
|
3411
|
+
path: OPENAPI_ANDROID_192_ASSET_PATH,
|
|
3412
|
+
file: OPENAPI_ANDROID_192_ASSET_FILE,
|
|
3413
|
+
contentType: "image/png",
|
|
3414
|
+
filename: "android-chrome-192x192.png"
|
|
3415
|
+
},
|
|
3416
|
+
{
|
|
3417
|
+
path: OPENAPI_ANDROID_512_ASSET_PATH,
|
|
3418
|
+
file: OPENAPI_ANDROID_512_ASSET_FILE,
|
|
3419
|
+
contentType: "image/png",
|
|
3420
|
+
filename: "android-chrome-512x512.png"
|
|
3421
|
+
}
|
|
3422
|
+
];
|
|
3423
|
+
var DOCS_HTML_CACHE_CONTROL = "public, max-age=0, must-revalidate";
|
|
3424
|
+
var DOCS_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
3425
|
+
var DOCS_ASSET_ERROR_CACHE_CONTROL = "no-store";
|
|
3426
|
+
function escapeRegex(value) {
|
|
3427
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3428
|
+
}
|
|
3429
|
+
function wildcardPatternToRegex(pattern) {
|
|
3430
|
+
let regexSource = "^";
|
|
3431
|
+
for (const char of pattern) {
|
|
3432
|
+
if (char === "*") {
|
|
3433
|
+
regexSource += ".*";
|
|
3434
|
+
continue;
|
|
3435
|
+
}
|
|
3436
|
+
regexSource += escapeRegex(char);
|
|
3437
|
+
}
|
|
3438
|
+
regexSource += "$";
|
|
3439
|
+
return new RegExp(regexSource);
|
|
3440
|
+
}
|
|
3441
|
+
function matchesExposePath(path, exposePathPattern) {
|
|
3442
|
+
if (!exposePathPattern.includes("*")) {
|
|
3443
|
+
return path === exposePathPattern;
|
|
897
3444
|
}
|
|
3445
|
+
return wildcardPatternToRegex(exposePathPattern).test(path);
|
|
898
3446
|
}
|
|
899
3447
|
|
|
900
|
-
// src/core/server.ts
|
|
901
3448
|
class VectorServer {
|
|
902
3449
|
server = null;
|
|
903
3450
|
router;
|
|
904
3451
|
config;
|
|
905
|
-
|
|
906
|
-
|
|
3452
|
+
openapiConfig;
|
|
3453
|
+
openapiDocCache = null;
|
|
3454
|
+
openapiDocsHtmlCache = null;
|
|
3455
|
+
openapiWarningsLogged = false;
|
|
3456
|
+
openapiTailwindMissingLogged = false;
|
|
3457
|
+
openapiLogoDarkMissingLogged = false;
|
|
3458
|
+
openapiLogoWhiteMissingLogged = false;
|
|
3459
|
+
corsHandler = null;
|
|
3460
|
+
corsHeadersEntries = null;
|
|
907
3461
|
constructor(router, config) {
|
|
908
3462
|
this.router = router;
|
|
909
3463
|
this.config = config;
|
|
3464
|
+
this.openapiConfig = this.normalizeOpenAPIConfig(config.openapi, config.development);
|
|
910
3465
|
if (config.cors) {
|
|
911
3466
|
const opts = this.normalizeCorsOptions(config.cors);
|
|
912
|
-
const { preflight
|
|
913
|
-
this.corsHandler = { preflight
|
|
914
|
-
|
|
915
|
-
|
|
3467
|
+
const { preflight, corsify } = cors(opts);
|
|
3468
|
+
this.corsHandler = { preflight, corsify };
|
|
3469
|
+
const canUseStaticCorsHeaders = typeof opts.origin === "string" && (opts.origin !== "*" || !opts.credentials);
|
|
3470
|
+
if (canUseStaticCorsHeaders) {
|
|
3471
|
+
const corsHeaders = {
|
|
916
3472
|
"access-control-allow-origin": opts.origin,
|
|
917
3473
|
"access-control-allow-methods": opts.allowMethods,
|
|
918
3474
|
"access-control-allow-headers": opts.allowHeaders,
|
|
@@ -920,10 +3476,252 @@ class VectorServer {
|
|
|
920
3476
|
"access-control-max-age": String(opts.maxAge)
|
|
921
3477
|
};
|
|
922
3478
|
if (opts.credentials) {
|
|
923
|
-
|
|
3479
|
+
corsHeaders["access-control-allow-credentials"] = "true";
|
|
3480
|
+
}
|
|
3481
|
+
this.corsHeadersEntries = Object.entries(corsHeaders);
|
|
3482
|
+
}
|
|
3483
|
+
this.router.setCorsHeaders(this.corsHeadersEntries);
|
|
3484
|
+
this.router.setCorsHandler(this.corsHeadersEntries ? null : this.corsHandler.corsify);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
normalizeOpenAPIConfig(openapi, development) {
|
|
3488
|
+
const isDev = development !== false && true;
|
|
3489
|
+
const defaultEnabled = isDev;
|
|
3490
|
+
if (openapi === false) {
|
|
3491
|
+
return {
|
|
3492
|
+
enabled: false,
|
|
3493
|
+
path: "/openapi.json",
|
|
3494
|
+
target: "openapi-3.0",
|
|
3495
|
+
docs: { enabled: false, path: "/docs" }
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
if (openapi === true) {
|
|
3499
|
+
return {
|
|
3500
|
+
enabled: true,
|
|
3501
|
+
path: "/openapi.json",
|
|
3502
|
+
target: "openapi-3.0",
|
|
3503
|
+
docs: { enabled: false, path: "/docs" }
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
const openapiObject = openapi || {};
|
|
3507
|
+
const docsValue = openapiObject.docs;
|
|
3508
|
+
const docs = typeof docsValue === "boolean" ? { enabled: docsValue, path: "/docs", exposePaths: undefined } : {
|
|
3509
|
+
enabled: docsValue?.enabled === true,
|
|
3510
|
+
path: docsValue?.path || "/docs",
|
|
3511
|
+
exposePaths: Array.isArray(docsValue?.exposePaths) ? docsValue.exposePaths.map((path) => typeof path === "string" ? path.trim() : "").filter((path) => path.length > 0) : undefined
|
|
3512
|
+
};
|
|
3513
|
+
return {
|
|
3514
|
+
enabled: openapiObject.enabled ?? defaultEnabled,
|
|
3515
|
+
path: openapiObject.path || "/openapi.json",
|
|
3516
|
+
target: openapiObject.target || "openapi-3.0",
|
|
3517
|
+
docs,
|
|
3518
|
+
info: openapiObject.info
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
isDocsReservedPath(path) {
|
|
3522
|
+
return path === this.openapiConfig.path || this.openapiConfig.docs.enabled && path === this.openapiConfig.docs.path;
|
|
3523
|
+
}
|
|
3524
|
+
getOpenAPIDocument() {
|
|
3525
|
+
if (this.openapiDocCache) {
|
|
3526
|
+
return this.openapiDocCache;
|
|
3527
|
+
}
|
|
3528
|
+
const routes = this.router.getRouteDefinitions().filter((route) => !this.isDocsReservedPath(route.path));
|
|
3529
|
+
const result = generateOpenAPIDocument(routes, {
|
|
3530
|
+
target: this.openapiConfig.target,
|
|
3531
|
+
info: this.openapiConfig.info
|
|
3532
|
+
});
|
|
3533
|
+
if (!this.openapiWarningsLogged && result.warnings.length > 0) {
|
|
3534
|
+
for (const warning of result.warnings) {
|
|
3535
|
+
console.warn(warning);
|
|
3536
|
+
}
|
|
3537
|
+
this.openapiWarningsLogged = true;
|
|
3538
|
+
}
|
|
3539
|
+
this.openapiDocCache = result.document;
|
|
3540
|
+
return this.openapiDocCache;
|
|
3541
|
+
}
|
|
3542
|
+
getOpenAPIDocumentForDocs() {
|
|
3543
|
+
const exposePaths = this.openapiConfig.docs.exposePaths;
|
|
3544
|
+
const document = this.getOpenAPIDocument();
|
|
3545
|
+
if (!Array.isArray(exposePaths) || exposePaths.length === 0) {
|
|
3546
|
+
return document;
|
|
3547
|
+
}
|
|
3548
|
+
const existingPaths = document.paths && typeof document.paths === "object" && !Array.isArray(document.paths) ? document.paths : {};
|
|
3549
|
+
const filteredPaths = {};
|
|
3550
|
+
for (const [path, value] of Object.entries(existingPaths)) {
|
|
3551
|
+
if (exposePaths.some((pattern) => matchesExposePath(path, pattern))) {
|
|
3552
|
+
filteredPaths[path] = value;
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
return {
|
|
3556
|
+
...document,
|
|
3557
|
+
paths: filteredPaths
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
getOpenAPIDocsHtmlCacheEntry() {
|
|
3561
|
+
if (this.openapiDocsHtmlCache) {
|
|
3562
|
+
return this.openapiDocsHtmlCache;
|
|
3563
|
+
}
|
|
3564
|
+
const html = renderOpenAPIDocsHtml(this.getOpenAPIDocumentForDocs(), this.openapiConfig.path, OPENAPI_TAILWIND_ASSET_PATH, OPENAPI_LOGO_DARK_ASSET_PATH, OPENAPI_LOGO_WHITE_ASSET_PATH, OPENAPI_APPLE_TOUCH_ICON_ASSET_PATH, OPENAPI_FAVICON_32_ASSET_PATH, OPENAPI_FAVICON_16_ASSET_PATH, OPENAPI_WEBMANIFEST_ASSET_PATH);
|
|
3565
|
+
const gzip = Bun.gzipSync(html);
|
|
3566
|
+
const etag = `"${Bun.hash(html).toString(16)}"`;
|
|
3567
|
+
this.openapiDocsHtmlCache = { html, gzip, etag };
|
|
3568
|
+
return this.openapiDocsHtmlCache;
|
|
3569
|
+
}
|
|
3570
|
+
requestAcceptsGzip(request) {
|
|
3571
|
+
const acceptEncoding = request.headers.get("accept-encoding");
|
|
3572
|
+
return Boolean(acceptEncoding && /\bgzip\b/i.test(acceptEncoding));
|
|
3573
|
+
}
|
|
3574
|
+
validateReservedOpenAPIPaths() {
|
|
3575
|
+
if (!this.openapiConfig.enabled) {
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
const reserved = new Set([this.openapiConfig.path]);
|
|
3579
|
+
if (this.openapiConfig.docs.enabled) {
|
|
3580
|
+
reserved.add(this.openapiConfig.docs.path);
|
|
3581
|
+
reserved.add(OPENAPI_TAILWIND_ASSET_PATH);
|
|
3582
|
+
reserved.add(OPENAPI_LOGO_DARK_ASSET_PATH);
|
|
3583
|
+
reserved.add(OPENAPI_LOGO_WHITE_ASSET_PATH);
|
|
3584
|
+
for (const asset of OPENAPI_FAVICON_ASSETS) {
|
|
3585
|
+
reserved.add(asset.path);
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
const methodConflicts = this.router.getRouteDefinitions().filter((route) => reserved.has(route.path)).map((route) => `${route.method} ${route.path}`);
|
|
3589
|
+
const staticConflicts = Object.entries(this.router.getRouteTable()).filter(([path, value]) => reserved.has(path) && value instanceof Response).map(([path]) => `STATIC ${path}`);
|
|
3590
|
+
const conflicts = [...methodConflicts, ...staticConflicts];
|
|
3591
|
+
if (conflicts.length > 0) {
|
|
3592
|
+
throw new Error(`OpenAPI reserved path conflict: ${conflicts.join(", ")}. Change your route path(s) or reconfigure openapi.path/docs.path.`);
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
tryHandleOpenAPIRequest(request) {
|
|
3596
|
+
if (!this.openapiConfig.enabled || request.method !== "GET") {
|
|
3597
|
+
return null;
|
|
3598
|
+
}
|
|
3599
|
+
const pathname = new URL(request.url).pathname;
|
|
3600
|
+
if (pathname === this.openapiConfig.path) {
|
|
3601
|
+
return Response.json(this.getOpenAPIDocument());
|
|
3602
|
+
}
|
|
3603
|
+
if (this.openapiConfig.docs.enabled && pathname === this.openapiConfig.docs.path) {
|
|
3604
|
+
const { html, gzip, etag } = this.getOpenAPIDocsHtmlCacheEntry();
|
|
3605
|
+
if (request.headers.get("if-none-match") === etag) {
|
|
3606
|
+
return new Response(null, {
|
|
3607
|
+
status: 304,
|
|
3608
|
+
headers: {
|
|
3609
|
+
etag,
|
|
3610
|
+
"cache-control": DOCS_HTML_CACHE_CONTROL,
|
|
3611
|
+
vary: "accept-encoding"
|
|
3612
|
+
}
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
if (this.requestAcceptsGzip(request)) {
|
|
3616
|
+
return new Response(gzip, {
|
|
3617
|
+
status: 200,
|
|
3618
|
+
headers: {
|
|
3619
|
+
"content-type": "text/html; charset=utf-8",
|
|
3620
|
+
"content-encoding": "gzip",
|
|
3621
|
+
etag,
|
|
3622
|
+
"cache-control": DOCS_HTML_CACHE_CONTROL,
|
|
3623
|
+
vary: "accept-encoding"
|
|
3624
|
+
}
|
|
3625
|
+
});
|
|
3626
|
+
}
|
|
3627
|
+
return new Response(html, {
|
|
3628
|
+
status: 200,
|
|
3629
|
+
headers: {
|
|
3630
|
+
"content-type": "text/html; charset=utf-8",
|
|
3631
|
+
etag,
|
|
3632
|
+
"cache-control": DOCS_HTML_CACHE_CONTROL,
|
|
3633
|
+
vary: "accept-encoding"
|
|
3634
|
+
}
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
if (this.openapiConfig.docs.enabled && pathname === OPENAPI_TAILWIND_ASSET_PATH) {
|
|
3638
|
+
if (!OPENAPI_TAILWIND_ASSET_FILE) {
|
|
3639
|
+
if (!this.openapiTailwindMissingLogged) {
|
|
3640
|
+
this.openapiTailwindMissingLogged = true;
|
|
3641
|
+
console.warn('[OpenAPI] Missing docs runtime asset "tailwindcdn.js". Serving inline fallback script instead.');
|
|
3642
|
+
}
|
|
3643
|
+
return new Response(OPENAPI_TAILWIND_ASSET_INLINE_FALLBACK, {
|
|
3644
|
+
status: 200,
|
|
3645
|
+
headers: {
|
|
3646
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
3647
|
+
"cache-control": DOCS_ASSET_CACHE_CONTROL
|
|
3648
|
+
}
|
|
3649
|
+
});
|
|
3650
|
+
}
|
|
3651
|
+
return new Response(OPENAPI_TAILWIND_ASSET_FILE, {
|
|
3652
|
+
status: 200,
|
|
3653
|
+
headers: {
|
|
3654
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
3655
|
+
"cache-control": DOCS_ASSET_CACHE_CONTROL
|
|
3656
|
+
}
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
if (this.openapiConfig.docs.enabled && pathname === OPENAPI_LOGO_DARK_ASSET_PATH) {
|
|
3660
|
+
if (!OPENAPI_LOGO_DARK_ASSET_FILE) {
|
|
3661
|
+
if (!this.openapiLogoDarkMissingLogged) {
|
|
3662
|
+
this.openapiLogoDarkMissingLogged = true;
|
|
3663
|
+
console.warn('[OpenAPI] Missing docs runtime asset "logo_dark.svg".');
|
|
3664
|
+
}
|
|
3665
|
+
return new Response("OpenAPI docs runtime asset missing: logo_dark.svg", {
|
|
3666
|
+
status: 404,
|
|
3667
|
+
headers: {
|
|
3668
|
+
"content-type": "text/plain; charset=utf-8",
|
|
3669
|
+
"cache-control": DOCS_ASSET_ERROR_CACHE_CONTROL
|
|
3670
|
+
}
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
return new Response(OPENAPI_LOGO_DARK_ASSET_FILE, {
|
|
3674
|
+
status: 200,
|
|
3675
|
+
headers: {
|
|
3676
|
+
"content-type": "image/svg+xml; charset=utf-8",
|
|
3677
|
+
"cache-control": DOCS_ASSET_CACHE_CONTROL
|
|
3678
|
+
}
|
|
3679
|
+
});
|
|
3680
|
+
}
|
|
3681
|
+
if (this.openapiConfig.docs.enabled && pathname === OPENAPI_LOGO_WHITE_ASSET_PATH) {
|
|
3682
|
+
if (!OPENAPI_LOGO_WHITE_ASSET_FILE) {
|
|
3683
|
+
if (!this.openapiLogoWhiteMissingLogged) {
|
|
3684
|
+
this.openapiLogoWhiteMissingLogged = true;
|
|
3685
|
+
console.warn('[OpenAPI] Missing docs runtime asset "logo_white.svg".');
|
|
3686
|
+
}
|
|
3687
|
+
return new Response("OpenAPI docs runtime asset missing: logo_white.svg", {
|
|
3688
|
+
status: 404,
|
|
3689
|
+
headers: {
|
|
3690
|
+
"content-type": "text/plain; charset=utf-8",
|
|
3691
|
+
"cache-control": DOCS_ASSET_ERROR_CACHE_CONTROL
|
|
3692
|
+
}
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
return new Response(OPENAPI_LOGO_WHITE_ASSET_FILE, {
|
|
3696
|
+
status: 200,
|
|
3697
|
+
headers: {
|
|
3698
|
+
"content-type": "image/svg+xml; charset=utf-8",
|
|
3699
|
+
"cache-control": DOCS_ASSET_CACHE_CONTROL
|
|
3700
|
+
}
|
|
3701
|
+
});
|
|
3702
|
+
}
|
|
3703
|
+
if (this.openapiConfig.docs.enabled) {
|
|
3704
|
+
const faviconAsset = OPENAPI_FAVICON_ASSETS.find((asset) => asset.path === pathname);
|
|
3705
|
+
if (faviconAsset) {
|
|
3706
|
+
if (!faviconAsset.file) {
|
|
3707
|
+
return new Response(`OpenAPI docs runtime asset missing: ${faviconAsset.filename}`, {
|
|
3708
|
+
status: 404,
|
|
3709
|
+
headers: {
|
|
3710
|
+
"content-type": "text/plain; charset=utf-8",
|
|
3711
|
+
"cache-control": DOCS_ASSET_ERROR_CACHE_CONTROL
|
|
3712
|
+
}
|
|
3713
|
+
});
|
|
924
3714
|
}
|
|
3715
|
+
return new Response(faviconAsset.file, {
|
|
3716
|
+
status: 200,
|
|
3717
|
+
headers: {
|
|
3718
|
+
"content-type": faviconAsset.contentType,
|
|
3719
|
+
"cache-control": DOCS_ASSET_CACHE_CONTROL
|
|
3720
|
+
}
|
|
3721
|
+
});
|
|
925
3722
|
}
|
|
926
3723
|
}
|
|
3724
|
+
return null;
|
|
927
3725
|
}
|
|
928
3726
|
normalizeCorsOptions(options) {
|
|
929
3727
|
return {
|
|
@@ -935,26 +3733,35 @@ class VectorServer {
|
|
|
935
3733
|
maxAge: options.maxAge || 86400
|
|
936
3734
|
};
|
|
937
3735
|
}
|
|
3736
|
+
applyCors(response, request) {
|
|
3737
|
+
if (this.corsHeadersEntries) {
|
|
3738
|
+
for (const [k, v] of this.corsHeadersEntries) {
|
|
3739
|
+
response.headers.set(k, v);
|
|
3740
|
+
}
|
|
3741
|
+
return response;
|
|
3742
|
+
}
|
|
3743
|
+
if (this.corsHandler && request) {
|
|
3744
|
+
return this.corsHandler.corsify(response, request);
|
|
3745
|
+
}
|
|
3746
|
+
return response;
|
|
3747
|
+
}
|
|
938
3748
|
async start() {
|
|
939
|
-
const port = this.config.port
|
|
3749
|
+
const port = this.config.port ?? 3000;
|
|
940
3750
|
const hostname = this.config.hostname || "localhost";
|
|
941
|
-
|
|
3751
|
+
this.validateReservedOpenAPIPaths();
|
|
3752
|
+
const fallbackFetch = async (request) => {
|
|
942
3753
|
try {
|
|
943
3754
|
if (this.corsHandler && request.method === "OPTIONS") {
|
|
944
3755
|
return this.corsHandler.preflight(request);
|
|
945
3756
|
}
|
|
946
|
-
|
|
947
|
-
if (
|
|
948
|
-
|
|
949
|
-
response.headers.set(k, v);
|
|
950
|
-
}
|
|
951
|
-
} else if (this.corsHandler) {
|
|
952
|
-
response = this.corsHandler.corsify(response, request);
|
|
3757
|
+
const openapiResponse = this.tryHandleOpenAPIRequest(request);
|
|
3758
|
+
if (openapiResponse) {
|
|
3759
|
+
return this.applyCors(openapiResponse, request);
|
|
953
3760
|
}
|
|
954
|
-
return
|
|
3761
|
+
return this.applyCors(STATIC_RESPONSES.NOT_FOUND.clone(), request);
|
|
955
3762
|
} catch (error) {
|
|
956
3763
|
console.error("Server error:", error);
|
|
957
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
3764
|
+
return this.applyCors(new Response("Internal Server Error", { status: 500 }), request);
|
|
958
3765
|
}
|
|
959
3766
|
};
|
|
960
3767
|
try {
|
|
@@ -962,11 +3769,12 @@ class VectorServer {
|
|
|
962
3769
|
port,
|
|
963
3770
|
hostname,
|
|
964
3771
|
reusePort: this.config.reusePort !== false,
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
3772
|
+
routes: this.router.getRouteTable(),
|
|
3773
|
+
fetch: fallbackFetch,
|
|
3774
|
+
idleTimeout: this.config.idleTimeout ?? 60,
|
|
3775
|
+
error: (error, request) => {
|
|
968
3776
|
console.error("[ERROR] Server error:", error);
|
|
969
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
3777
|
+
return this.applyCors(new Response("Internal Server Error", { status: 500 }), request);
|
|
970
3778
|
}
|
|
971
3779
|
});
|
|
972
3780
|
if (!this.server || !this.server.port) {
|
|
@@ -991,6 +3799,9 @@ class VectorServer {
|
|
|
991
3799
|
if (this.server) {
|
|
992
3800
|
this.server.stop();
|
|
993
3801
|
this.server = null;
|
|
3802
|
+
this.openapiDocCache = null;
|
|
3803
|
+
this.openapiDocsHtmlCache = null;
|
|
3804
|
+
this.openapiWarningsLogged = false;
|
|
994
3805
|
console.log("Server stopped");
|
|
995
3806
|
}
|
|
996
3807
|
}
|
|
@@ -998,7 +3809,7 @@ class VectorServer {
|
|
|
998
3809
|
return this.server;
|
|
999
3810
|
}
|
|
1000
3811
|
getPort() {
|
|
1001
|
-
return this.server?.port
|
|
3812
|
+
return this.server?.port ?? this.config.port ?? 3000;
|
|
1002
3813
|
}
|
|
1003
3814
|
getHostname() {
|
|
1004
3815
|
return this.server?.hostname || this.config.hostname || "localhost";
|
|
@@ -1023,6 +3834,7 @@ class Vector {
|
|
|
1023
3834
|
routeGenerator = null;
|
|
1024
3835
|
_protectedHandler = null;
|
|
1025
3836
|
_cacheHandler = null;
|
|
3837
|
+
shutdownPromise = null;
|
|
1026
3838
|
constructor() {
|
|
1027
3839
|
this.middlewareManager = new MiddlewareManager;
|
|
1028
3840
|
this.authManager = new AuthManager;
|
|
@@ -1037,23 +3849,34 @@ class Vector {
|
|
|
1037
3849
|
}
|
|
1038
3850
|
setProtectedHandler(handler) {
|
|
1039
3851
|
this._protectedHandler = handler;
|
|
1040
|
-
|
|
3852
|
+
if (handler) {
|
|
3853
|
+
this.authManager.setProtectedHandler(handler);
|
|
3854
|
+
return;
|
|
3855
|
+
}
|
|
3856
|
+
this.authManager.clearProtectedHandler();
|
|
1041
3857
|
}
|
|
1042
3858
|
getProtectedHandler() {
|
|
1043
3859
|
return this._protectedHandler;
|
|
1044
3860
|
}
|
|
1045
3861
|
setCacheHandler(handler) {
|
|
1046
3862
|
this._cacheHandler = handler;
|
|
1047
|
-
|
|
3863
|
+
if (handler) {
|
|
3864
|
+
this.cacheManager.setCacheHandler(handler);
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
this.cacheManager.clearCacheHandler();
|
|
1048
3868
|
}
|
|
1049
3869
|
getCacheHandler() {
|
|
1050
3870
|
return this._cacheHandler;
|
|
1051
3871
|
}
|
|
1052
3872
|
addRoute(options, handler) {
|
|
1053
|
-
|
|
3873
|
+
this.router.route(options, handler);
|
|
1054
3874
|
}
|
|
1055
3875
|
async startServer(config) {
|
|
1056
3876
|
this.config = { ...this.config, ...config };
|
|
3877
|
+
const routeDefaults = { ...this.config.defaults?.route };
|
|
3878
|
+
this.router.setRouteBooleanDefaults(routeDefaults);
|
|
3879
|
+
this.router.setDevelopmentMode(this.config.development);
|
|
1057
3880
|
this.middlewareManager.clear();
|
|
1058
3881
|
if (this.config.autoDiscover !== false) {
|
|
1059
3882
|
this.router.clearRoutes();
|
|
@@ -1064,6 +3887,9 @@ class Vector {
|
|
|
1064
3887
|
if (config?.finally) {
|
|
1065
3888
|
this.middlewareManager.addFinally(...config.finally);
|
|
1066
3889
|
}
|
|
3890
|
+
if (typeof this.config.startup === "function") {
|
|
3891
|
+
await this.config.startup();
|
|
3892
|
+
}
|
|
1067
3893
|
if (this.config.autoDiscover !== false) {
|
|
1068
3894
|
await this.discoverRoutes();
|
|
1069
3895
|
}
|
|
@@ -1091,9 +3917,8 @@ class Vector {
|
|
|
1091
3917
|
const exported = route.name === "default" ? module.default : module[route.name];
|
|
1092
3918
|
if (exported) {
|
|
1093
3919
|
if (this.isRouteDefinition(exported)) {
|
|
1094
|
-
|
|
1095
|
-
this.
|
|
1096
|
-
this.logRouteLoaded(routeDef.options);
|
|
3920
|
+
this.router.route(exported.options, exported.handler);
|
|
3921
|
+
this.logRouteLoaded(exported.options);
|
|
1097
3922
|
} else if (this.isRouteEntry(exported)) {
|
|
1098
3923
|
this.router.addRoute(exported);
|
|
1099
3924
|
this.logRouteLoaded(exported);
|
|
@@ -1106,7 +3931,6 @@ class Vector {
|
|
|
1106
3931
|
console.error(`Failed to load route ${route.name} from ${route.path}:`, error);
|
|
1107
3932
|
}
|
|
1108
3933
|
}
|
|
1109
|
-
this.router.sortRoutes();
|
|
1110
3934
|
}
|
|
1111
3935
|
} catch (error) {
|
|
1112
3936
|
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
|
|
@@ -1117,14 +3941,14 @@ class Vector {
|
|
|
1117
3941
|
async loadRoute(routeModule) {
|
|
1118
3942
|
if (typeof routeModule === "function") {
|
|
1119
3943
|
const routeEntry = routeModule();
|
|
1120
|
-
if (
|
|
3944
|
+
if (this.isRouteEntry(routeEntry)) {
|
|
1121
3945
|
this.router.addRoute(routeEntry);
|
|
1122
3946
|
}
|
|
1123
3947
|
} else if (routeModule && typeof routeModule === "object") {
|
|
1124
3948
|
for (const [, value] of Object.entries(routeModule)) {
|
|
1125
3949
|
if (typeof value === "function") {
|
|
1126
3950
|
const routeEntry = value();
|
|
1127
|
-
if (
|
|
3951
|
+
if (this.isRouteEntry(routeEntry)) {
|
|
1128
3952
|
this.router.addRoute(routeEntry);
|
|
1129
3953
|
}
|
|
1130
3954
|
}
|
|
@@ -1132,10 +3956,14 @@ class Vector {
|
|
|
1132
3956
|
}
|
|
1133
3957
|
}
|
|
1134
3958
|
isRouteEntry(value) {
|
|
1135
|
-
|
|
3959
|
+
if (!Array.isArray(value) || value.length < 3) {
|
|
3960
|
+
return false;
|
|
3961
|
+
}
|
|
3962
|
+
const [method, matcher, handlers, path] = value;
|
|
3963
|
+
return typeof method === "string" && matcher instanceof RegExp && Array.isArray(handlers) && handlers.length > 0 && handlers.every((handler) => typeof handler === "function") && (path === undefined || typeof path === "string");
|
|
1136
3964
|
}
|
|
1137
3965
|
isRouteDefinition(value) {
|
|
1138
|
-
return value && typeof value === "object" && "entry" in value && "options" in value && "handler" in value;
|
|
3966
|
+
return value !== null && typeof value === "object" && "entry" in value && "options" in value && "handler" in value && typeof value.handler === "function";
|
|
1139
3967
|
}
|
|
1140
3968
|
logRouteLoaded(_) {}
|
|
1141
3969
|
stop() {
|
|
@@ -1144,6 +3972,22 @@ class Vector {
|
|
|
1144
3972
|
this.server = null;
|
|
1145
3973
|
}
|
|
1146
3974
|
}
|
|
3975
|
+
async shutdown() {
|
|
3976
|
+
if (this.shutdownPromise) {
|
|
3977
|
+
return this.shutdownPromise;
|
|
3978
|
+
}
|
|
3979
|
+
this.shutdownPromise = (async () => {
|
|
3980
|
+
this.stop();
|
|
3981
|
+
if (typeof this.config.shutdown === "function") {
|
|
3982
|
+
await this.config.shutdown();
|
|
3983
|
+
}
|
|
3984
|
+
})();
|
|
3985
|
+
try {
|
|
3986
|
+
await this.shutdownPromise;
|
|
3987
|
+
} finally {
|
|
3988
|
+
this.shutdownPromise = null;
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
1147
3991
|
getServer() {
|
|
1148
3992
|
return this.server;
|
|
1149
3993
|
}
|
|
@@ -1162,80 +4006,40 @@ class Vector {
|
|
|
1162
4006
|
}
|
|
1163
4007
|
var getVectorInstance = Vector.getInstance;
|
|
1164
4008
|
|
|
1165
|
-
// src/
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
config =
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
const path = configPath || "vector.config.ts";
|
|
1174
|
-
this.configPath = isAbsolute(path) ? path : resolve2(process.cwd(), path);
|
|
1175
|
-
}
|
|
1176
|
-
async load() {
|
|
1177
|
-
if (existsSync2(this.configPath)) {
|
|
1178
|
-
try {
|
|
1179
|
-
const userConfigPath = toFileUrl(this.configPath);
|
|
1180
|
-
const userConfig = await import(userConfigPath);
|
|
1181
|
-
this.config = userConfig.default || userConfig;
|
|
1182
|
-
this.configSource = "user";
|
|
1183
|
-
} catch (error) {
|
|
1184
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1185
|
-
console.error(`[Vector] Failed to load config from ${this.configPath}: ${msg}`);
|
|
1186
|
-
console.error("[Vector] Server is using default configuration. Fix your config file and restart.");
|
|
1187
|
-
this.config = {};
|
|
1188
|
-
}
|
|
1189
|
-
} else {
|
|
1190
|
-
this.config = {};
|
|
1191
|
-
}
|
|
1192
|
-
return await this.buildLegacyConfig();
|
|
1193
|
-
}
|
|
1194
|
-
getConfigSource() {
|
|
1195
|
-
return this.configSource;
|
|
1196
|
-
}
|
|
1197
|
-
async buildLegacyConfig() {
|
|
1198
|
-
const config = {};
|
|
1199
|
-
if (this.config) {
|
|
1200
|
-
config.port = this.config.port;
|
|
1201
|
-
config.hostname = this.config.hostname;
|
|
1202
|
-
config.reusePort = this.config.reusePort;
|
|
1203
|
-
config.development = this.config.development;
|
|
1204
|
-
config.routesDir = this.config.routesDir || "./routes";
|
|
1205
|
-
config.idleTimeout = this.config.idleTimeout;
|
|
1206
|
-
}
|
|
1207
|
-
config.autoDiscover = true;
|
|
1208
|
-
if (this.config?.cors) {
|
|
1209
|
-
if (typeof this.config.cors === "boolean") {
|
|
1210
|
-
config.cors = this.config.cors ? {
|
|
1211
|
-
origin: "*",
|
|
1212
|
-
credentials: true,
|
|
1213
|
-
allowHeaders: "Content-Type, Authorization",
|
|
1214
|
-
allowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
1215
|
-
exposeHeaders: "Authorization",
|
|
1216
|
-
maxAge: 86400
|
|
1217
|
-
} : undefined;
|
|
1218
|
-
} else {
|
|
1219
|
-
config.cors = this.config.cors;
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
if (this.config?.before) {
|
|
1223
|
-
config.before = this.config.before;
|
|
1224
|
-
}
|
|
1225
|
-
if (this.config?.after) {
|
|
1226
|
-
config.finally = this.config.after;
|
|
1227
|
-
}
|
|
1228
|
-
return config;
|
|
1229
|
-
}
|
|
1230
|
-
async loadAuthHandler() {
|
|
1231
|
-
return this.config?.auth || null;
|
|
4009
|
+
// src/start-vector.ts
|
|
4010
|
+
async function startVector(options = {}) {
|
|
4011
|
+
const configLoader = new ConfigLoader(options.configPath);
|
|
4012
|
+
const loadedConfig = await configLoader.load();
|
|
4013
|
+
const configSource = configLoader.getConfigSource();
|
|
4014
|
+
let config = { ...loadedConfig };
|
|
4015
|
+
if (options.mutateConfig) {
|
|
4016
|
+
config = await options.mutateConfig(config, { configSource });
|
|
1232
4017
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
4018
|
+
if (options.config) {
|
|
4019
|
+
config = { ...config, ...options.config };
|
|
1235
4020
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
4021
|
+
if (options.autoDiscover !== undefined) {
|
|
4022
|
+
config.autoDiscover = options.autoDiscover;
|
|
1238
4023
|
}
|
|
4024
|
+
const vector = getVectorInstance();
|
|
4025
|
+
const resolvedProtectedHandler = options.protectedHandler !== undefined ? options.protectedHandler : await configLoader.loadAuthHandler();
|
|
4026
|
+
const resolvedCacheHandler = options.cacheHandler !== undefined ? options.cacheHandler : await configLoader.loadCacheHandler();
|
|
4027
|
+
vector.setProtectedHandler(resolvedProtectedHandler ?? null);
|
|
4028
|
+
vector.setCacheHandler(resolvedCacheHandler ?? null);
|
|
4029
|
+
const server = await vector.startServer(config);
|
|
4030
|
+
const effectiveConfig = {
|
|
4031
|
+
...config,
|
|
4032
|
+
port: server.port ?? config.port ?? DEFAULT_CONFIG.PORT,
|
|
4033
|
+
hostname: server.hostname || config.hostname || DEFAULT_CONFIG.HOSTNAME,
|
|
4034
|
+
reusePort: config.reusePort !== false,
|
|
4035
|
+
idleTimeout: config.idleTimeout ?? 60
|
|
4036
|
+
};
|
|
4037
|
+
return {
|
|
4038
|
+
server,
|
|
4039
|
+
config: effectiveConfig,
|
|
4040
|
+
stop: () => vector.stop(),
|
|
4041
|
+
shutdown: () => vector.shutdown()
|
|
4042
|
+
};
|
|
1239
4043
|
}
|
|
1240
4044
|
|
|
1241
4045
|
// src/cli/index.ts
|
|
@@ -1276,10 +4080,14 @@ var { values, positionals } = parseArgs({
|
|
|
1276
4080
|
allowPositionals: true
|
|
1277
4081
|
});
|
|
1278
4082
|
var command = positionals[0] || "dev";
|
|
4083
|
+
var hasRoutesOption = args.some((arg) => arg === "--routes" || arg === "-r" || arg.startsWith("--routes="));
|
|
4084
|
+
var hasHostOption = args.some((arg) => arg === "--host" || arg === "-h" || arg.startsWith("--host="));
|
|
4085
|
+
var hasPortOption = args.some((arg) => arg === "--port" || arg === "-p" || arg.startsWith("--port="));
|
|
1279
4086
|
async function runDev() {
|
|
1280
4087
|
const isDev = command === "dev";
|
|
1281
4088
|
let server = null;
|
|
1282
|
-
let
|
|
4089
|
+
let app = null;
|
|
4090
|
+
let removeShutdownHandlers = null;
|
|
1283
4091
|
async function startServer() {
|
|
1284
4092
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1285
4093
|
setTimeout(() => {
|
|
@@ -1287,33 +4095,31 @@ async function runDev() {
|
|
|
1287
4095
|
}, 1e4);
|
|
1288
4096
|
});
|
|
1289
4097
|
const serverStartPromise = (async () => {
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
}
|
|
1316
|
-
server = await vector.startServer(config);
|
|
4098
|
+
const explicitConfigPath = values.config;
|
|
4099
|
+
app = await startVector({
|
|
4100
|
+
configPath: explicitConfigPath,
|
|
4101
|
+
mutateConfig: (loadedConfig) => {
|
|
4102
|
+
const config2 = { ...loadedConfig };
|
|
4103
|
+
config2.port = resolvePort(config2.port, hasPortOption, values.port);
|
|
4104
|
+
config2.hostname = resolveHost(config2.hostname, hasHostOption, values.host);
|
|
4105
|
+
config2.routesDir = resolveRoutesDir(config2.routesDir, hasRoutesOption, values.routes);
|
|
4106
|
+
config2.development = config2.development ?? isDev;
|
|
4107
|
+
config2.autoDiscover = true;
|
|
4108
|
+
if (config2.cors === undefined && values.cors) {
|
|
4109
|
+
config2.cors = {
|
|
4110
|
+
origin: "*",
|
|
4111
|
+
credentials: true,
|
|
4112
|
+
allowHeaders: "Content-Type, Authorization",
|
|
4113
|
+
allowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
4114
|
+
exposeHeaders: "Authorization",
|
|
4115
|
+
maxAge: 86400
|
|
4116
|
+
};
|
|
4117
|
+
}
|
|
4118
|
+
return config2;
|
|
4119
|
+
}
|
|
4120
|
+
});
|
|
4121
|
+
server = app.server;
|
|
4122
|
+
const config = app.config;
|
|
1317
4123
|
if (!server || !server.port) {
|
|
1318
4124
|
throw new Error("Server started but is not responding correctly");
|
|
1319
4125
|
}
|
|
@@ -1322,13 +4128,18 @@ async function runDev() {
|
|
|
1322
4128
|
console.log(`
|
|
1323
4129
|
Listening on ${cyan}http://${config.hostname}:${config.port}${reset}
|
|
1324
4130
|
`);
|
|
1325
|
-
return { server,
|
|
4131
|
+
return { server, app, config };
|
|
1326
4132
|
})();
|
|
1327
4133
|
return await Promise.race([serverStartPromise, timeoutPromise]);
|
|
1328
4134
|
}
|
|
1329
4135
|
try {
|
|
1330
4136
|
const result = await startServer();
|
|
1331
4137
|
server = result.server;
|
|
4138
|
+
if (!removeShutdownHandlers) {
|
|
4139
|
+
removeShutdownHandlers = installGracefulShutdownHandlers({
|
|
4140
|
+
getTarget: () => app
|
|
4141
|
+
});
|
|
4142
|
+
}
|
|
1332
4143
|
if (isDev && values.watch) {
|
|
1333
4144
|
try {
|
|
1334
4145
|
let reloadTimeout = null;
|
|
@@ -1352,14 +4163,14 @@ Listening on ${cyan}http://${config.hostname}:${config.port}${reset}
|
|
|
1352
4163
|
isReloading = true;
|
|
1353
4164
|
lastReloadTime = Date.now();
|
|
1354
4165
|
changedFiles.clear();
|
|
1355
|
-
if (
|
|
1356
|
-
|
|
4166
|
+
if (app) {
|
|
4167
|
+
app.stop();
|
|
1357
4168
|
}
|
|
1358
4169
|
await new Promise((resolve3) => setTimeout(resolve3, 100));
|
|
1359
4170
|
try {
|
|
1360
4171
|
const result2 = await startServer();
|
|
1361
4172
|
server = result2.server;
|
|
1362
|
-
|
|
4173
|
+
app = result2.app;
|
|
1363
4174
|
} catch (error) {
|
|
1364
4175
|
console.error(`
|
|
1365
4176
|
[Reload Error]`, error.message || error);
|
|
@@ -1387,62 +4198,15 @@ ${red}Error: ${error.message || error}${reset}
|
|
|
1387
4198
|
process.exit(1);
|
|
1388
4199
|
}
|
|
1389
4200
|
}
|
|
1390
|
-
async function runBuild() {
|
|
1391
|
-
try {
|
|
1392
|
-
const { RouteScanner: RouteScanner2 } = await Promise.resolve().then(() => (init_route_scanner(), exports_route_scanner));
|
|
1393
|
-
const { RouteGenerator: RouteGenerator2 } = await Promise.resolve().then(() => (init_route_generator(), exports_route_generator));
|
|
1394
|
-
const scanner = new RouteScanner2(values.routes);
|
|
1395
|
-
const generator = new RouteGenerator2;
|
|
1396
|
-
const routes = await scanner.scan();
|
|
1397
|
-
await generator.generate(routes);
|
|
1398
|
-
if (typeof Bun !== "undefined") {
|
|
1399
|
-
const buildProcess = Bun.spawn([
|
|
1400
|
-
"bun",
|
|
1401
|
-
"build",
|
|
1402
|
-
"src/cli/index.ts",
|
|
1403
|
-
"--target",
|
|
1404
|
-
"bun",
|
|
1405
|
-
"--outfile",
|
|
1406
|
-
"dist/server.js",
|
|
1407
|
-
"--minify"
|
|
1408
|
-
]);
|
|
1409
|
-
const exitCode = await buildProcess.exited;
|
|
1410
|
-
if (exitCode !== 0) {
|
|
1411
|
-
throw new Error(`Build failed with exit code ${exitCode}`);
|
|
1412
|
-
}
|
|
1413
|
-
} else {
|
|
1414
|
-
const { spawnSync } = await import("child_process");
|
|
1415
|
-
const result = spawnSync("bun", ["build", "src/cli/index.ts", "--target", "bun", "--outfile", "dist/server.js", "--minify"], {
|
|
1416
|
-
stdio: "inherit",
|
|
1417
|
-
shell: true
|
|
1418
|
-
});
|
|
1419
|
-
if (result.status !== 0) {
|
|
1420
|
-
throw new Error(`Build failed with exit code ${result.status}`);
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
console.log(`
|
|
1424
|
-
Build complete: dist/server.js
|
|
1425
|
-
`);
|
|
1426
|
-
} catch (error) {
|
|
1427
|
-
const red = "\x1B[31m";
|
|
1428
|
-
const reset = "\x1B[0m";
|
|
1429
|
-
console.error(`
|
|
1430
|
-
${red}Error: ${error.message || error}${reset}
|
|
1431
|
-
`);
|
|
1432
|
-
process.exit(1);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
4201
|
switch (command) {
|
|
1436
4202
|
case "dev":
|
|
1437
4203
|
await runDev();
|
|
1438
4204
|
break;
|
|
1439
|
-
case "
|
|
1440
|
-
await runBuild();
|
|
1441
|
-
break;
|
|
1442
|
-
case "start":
|
|
4205
|
+
case "start": {
|
|
1443
4206
|
process.env.NODE_ENV = "production";
|
|
1444
4207
|
await runDev();
|
|
1445
4208
|
break;
|
|
4209
|
+
}
|
|
1446
4210
|
default:
|
|
1447
4211
|
console.error(`Unknown command: ${command}`);
|
|
1448
4212
|
console.log(`
|
|
@@ -1450,15 +4214,14 @@ Usage: vector [command] [options]
|
|
|
1450
4214
|
|
|
1451
4215
|
Commands:
|
|
1452
4216
|
dev Start development server (default)
|
|
1453
|
-
build Build for production
|
|
1454
4217
|
start Start production server
|
|
1455
4218
|
|
|
1456
4219
|
Options:
|
|
1457
4220
|
-p, --port <port> Port to listen on (default: 3000)
|
|
1458
4221
|
-h, --host <host> Hostname to bind to (default: localhost)
|
|
1459
|
-
-r, --routes <dir> Routes directory (
|
|
4222
|
+
-r, --routes <dir> Routes directory (dev/start)
|
|
1460
4223
|
-w, --watch Watch for file changes (default: true)
|
|
1461
|
-
-c, --config <path> Path to config file (
|
|
4224
|
+
-c, --config <path> Path to config file (dev/start)
|
|
1462
4225
|
--cors Enable CORS (default: true)
|
|
1463
4226
|
`);
|
|
1464
4227
|
process.exit(1);
|