shokupan 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/dist/analysis/openapi-analyzer.d.ts +142 -0
- package/dist/cli.cjs +62 -2
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +62 -2
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +59 -5
- package/dist/decorators.d.ts +5 -1
- package/dist/index.cjs +1799 -737
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1799 -737
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +2 -0
- package/dist/openapi-analyzer-BN0wFCML.cjs +772 -0
- package/dist/openapi-analyzer-BN0wFCML.cjs.map +1 -0
- package/dist/openapi-analyzer-BTExMLX4.js +772 -0
- package/dist/openapi-analyzer-BTExMLX4.js.map +1 -0
- package/dist/plugins/debugview/plugin.d.ts +34 -0
- package/dist/plugins/openapi-validator.d.ts +2 -0
- package/dist/plugins/openapi.d.ts +10 -0
- package/dist/plugins/proxy.d.ts +9 -0
- package/dist/plugins/scalar.d.ts +3 -1
- package/dist/plugins/serve-static.d.ts +3 -0
- package/dist/plugins/server-adapter.d.ts +13 -0
- package/dist/response.d.ts +4 -0
- package/dist/router.d.ts +43 -15
- package/dist/server-adapter-BD6oKEto.cjs +81 -0
- package/dist/server-adapter-BD6oKEto.cjs.map +1 -0
- package/dist/server-adapter-CnQFr4P7.js +64 -0
- package/dist/server-adapter-CnQFr4P7.js.map +1 -0
- package/dist/shokupan.d.ts +16 -7
- package/dist/symbol.d.ts +2 -0
- package/dist/types.d.ts +120 -1
- package/package.json +9 -3
package/dist/index.cjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
-
const api = require("@opentelemetry/api");
|
|
4
|
-
const exporterTraceOtlpProto = require("@opentelemetry/exporter-trace-otlp-proto");
|
|
5
|
-
const resources = require("@opentelemetry/resources");
|
|
6
|
-
const sdkTraceBase = require("@opentelemetry/sdk-trace-base");
|
|
7
|
-
const sdkTraceNode = require("@opentelemetry/sdk-trace-node");
|
|
8
|
-
const semanticConventions = require("@opentelemetry/semantic-conventions");
|
|
9
3
|
const eta$2 = require("eta");
|
|
10
4
|
const promises = require("fs/promises");
|
|
11
5
|
const path = require("path");
|
|
12
6
|
const node_async_hooks = require("node:async_hooks");
|
|
7
|
+
const api = require("@opentelemetry/api");
|
|
13
8
|
const arctic = require("arctic");
|
|
14
9
|
const jose = require("jose");
|
|
10
|
+
const Ajv = require("ajv");
|
|
11
|
+
const addFormats = require("ajv-formats");
|
|
12
|
+
const classTransformer = require("class-transformer");
|
|
13
|
+
const classValidator = require("class-validator");
|
|
14
|
+
const openapiAnalyzer = require("./openapi-analyzer-BN0wFCML.cjs");
|
|
15
15
|
const crypto = require("crypto");
|
|
16
16
|
const events = require("events");
|
|
17
17
|
function _interopNamespaceDefault(e) {
|
|
@@ -32,12 +32,13 @@ function _interopNamespaceDefault(e) {
|
|
|
32
32
|
}
|
|
33
33
|
const jose__namespace = /* @__PURE__ */ _interopNamespaceDefault(jose);
|
|
34
34
|
class ShokupanResponse {
|
|
35
|
-
_headers =
|
|
35
|
+
_headers = null;
|
|
36
36
|
_status = 200;
|
|
37
37
|
/**
|
|
38
38
|
* Get the current headers
|
|
39
39
|
*/
|
|
40
40
|
get headers() {
|
|
41
|
+
if (!this._headers) this._headers = new Headers();
|
|
41
42
|
return this._headers;
|
|
42
43
|
}
|
|
43
44
|
/**
|
|
@@ -58,6 +59,7 @@ class ShokupanResponse {
|
|
|
58
59
|
* @param value Header value
|
|
59
60
|
*/
|
|
60
61
|
set(key, value) {
|
|
62
|
+
if (!this._headers) this._headers = new Headers();
|
|
61
63
|
this._headers.set(key, value);
|
|
62
64
|
return this;
|
|
63
65
|
}
|
|
@@ -67,6 +69,7 @@ class ShokupanResponse {
|
|
|
67
69
|
* @param value Header value
|
|
68
70
|
*/
|
|
69
71
|
append(key, value) {
|
|
72
|
+
if (!this._headers) this._headers = new Headers();
|
|
70
73
|
this._headers.append(key, value);
|
|
71
74
|
return this;
|
|
72
75
|
}
|
|
@@ -75,27 +78,58 @@ class ShokupanResponse {
|
|
|
75
78
|
* @param key Header name
|
|
76
79
|
*/
|
|
77
80
|
get(key) {
|
|
78
|
-
return this._headers
|
|
81
|
+
return this._headers?.get(key) || null;
|
|
79
82
|
}
|
|
80
83
|
/**
|
|
81
84
|
* Check if a header exists
|
|
82
85
|
* @param key Header name
|
|
83
86
|
*/
|
|
84
87
|
has(key) {
|
|
85
|
-
return this._headers
|
|
88
|
+
return this._headers?.has(key) || false;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Internal: check if headers have been initialized/modified
|
|
92
|
+
*/
|
|
93
|
+
get hasPopulatedHeaders() {
|
|
94
|
+
return this._headers !== null;
|
|
86
95
|
}
|
|
87
96
|
}
|
|
88
97
|
class ShokupanContext {
|
|
89
|
-
constructor(request, state) {
|
|
98
|
+
constructor(request, server, state, app, enableMiddlewareTracking = false) {
|
|
90
99
|
this.request = request;
|
|
91
|
-
this.
|
|
100
|
+
this.server = server;
|
|
101
|
+
this.app = app;
|
|
92
102
|
this.state = state || {};
|
|
103
|
+
if (enableMiddlewareTracking) {
|
|
104
|
+
const self = this;
|
|
105
|
+
this.state = new Proxy(this.state, {
|
|
106
|
+
set(target, p, newValue, receiver) {
|
|
107
|
+
const result = Reflect.set(target, p, newValue, receiver);
|
|
108
|
+
const currentHandler = self.handlerStack[self.handlerStack.length - 1];
|
|
109
|
+
if (currentHandler) {
|
|
110
|
+
if (!currentHandler.stateChanges) currentHandler.stateChanges = {};
|
|
111
|
+
currentHandler.stateChanges[p] = newValue;
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
93
117
|
this.response = new ShokupanResponse();
|
|
94
118
|
}
|
|
95
|
-
|
|
119
|
+
_url;
|
|
96
120
|
params = {};
|
|
121
|
+
// Router assigns this, but default to empty object
|
|
97
122
|
state;
|
|
123
|
+
handlerStack = [];
|
|
98
124
|
response;
|
|
125
|
+
_finalResponse;
|
|
126
|
+
get url() {
|
|
127
|
+
if (!this._url) {
|
|
128
|
+
const urlString = this.request.url || "http://localhost/";
|
|
129
|
+
this._url = new URL(urlString);
|
|
130
|
+
}
|
|
131
|
+
return this._url;
|
|
132
|
+
}
|
|
99
133
|
/**
|
|
100
134
|
* Base request
|
|
101
135
|
*/
|
|
@@ -112,7 +146,26 @@ class ShokupanContext {
|
|
|
112
146
|
* Request path
|
|
113
147
|
*/
|
|
114
148
|
get path() {
|
|
115
|
-
return this.
|
|
149
|
+
if (this._url) return this._url.pathname;
|
|
150
|
+
const url = this.request.url;
|
|
151
|
+
let queryIndex = url.indexOf("?");
|
|
152
|
+
const end = queryIndex === -1 ? url.length : queryIndex;
|
|
153
|
+
let start = 0;
|
|
154
|
+
const protocolIndex = url.indexOf("://");
|
|
155
|
+
if (protocolIndex !== -1) {
|
|
156
|
+
const hostStart = protocolIndex + 3;
|
|
157
|
+
const pathStart = url.indexOf("/", hostStart);
|
|
158
|
+
if (pathStart !== -1 && pathStart < end) {
|
|
159
|
+
start = pathStart;
|
|
160
|
+
} else {
|
|
161
|
+
return "/";
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
if (url.charCodeAt(0) === 47) {
|
|
165
|
+
start = 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return url.substring(start, end);
|
|
116
169
|
}
|
|
117
170
|
/**
|
|
118
171
|
* Request query params
|
|
@@ -120,12 +173,55 @@ class ShokupanContext {
|
|
|
120
173
|
get query() {
|
|
121
174
|
return Object.fromEntries(this.url.searchParams);
|
|
122
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Client IP address
|
|
178
|
+
*/
|
|
179
|
+
get ip() {
|
|
180
|
+
return this.server?.requestIP(this.request);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Request hostname (e.g. "localhost")
|
|
184
|
+
*/
|
|
185
|
+
get hostname() {
|
|
186
|
+
return this.url.hostname;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Request host (e.g. "localhost:3000")
|
|
190
|
+
*/
|
|
191
|
+
get host() {
|
|
192
|
+
return this.url.host;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Request protocol (e.g. "http:", "https:")
|
|
196
|
+
*/
|
|
197
|
+
get protocol() {
|
|
198
|
+
return this.url.protocol;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether request is secure (https)
|
|
202
|
+
*/
|
|
203
|
+
get secure() {
|
|
204
|
+
return this.url.protocol === "https:";
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Request origin (e.g. "http://localhost:3000")
|
|
208
|
+
*/
|
|
209
|
+
get origin() {
|
|
210
|
+
return this.url.origin;
|
|
211
|
+
}
|
|
123
212
|
/**
|
|
124
213
|
* Request headers
|
|
125
214
|
*/
|
|
126
215
|
get headers() {
|
|
127
216
|
return this.request.headers;
|
|
128
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Get a request header
|
|
220
|
+
* @param name Header name
|
|
221
|
+
*/
|
|
222
|
+
get(name) {
|
|
223
|
+
return this.request.headers.get(name);
|
|
224
|
+
}
|
|
129
225
|
/**
|
|
130
226
|
* Base response object
|
|
131
227
|
*/
|
|
@@ -134,6 +230,8 @@ class ShokupanContext {
|
|
|
134
230
|
}
|
|
135
231
|
/**
|
|
136
232
|
* Helper to set a header on the response
|
|
233
|
+
* @param key Header key
|
|
234
|
+
* @param value Header value
|
|
137
235
|
*/
|
|
138
236
|
set(key, value) {
|
|
139
237
|
this.response.set(key, value);
|
|
@@ -164,9 +262,25 @@ class ShokupanContext {
|
|
|
164
262
|
return this;
|
|
165
263
|
}
|
|
166
264
|
mergeHeaders(headers) {
|
|
167
|
-
|
|
265
|
+
let h;
|
|
266
|
+
if (this.response.hasPopulatedHeaders) {
|
|
267
|
+
h = new Headers(this.response.headers);
|
|
268
|
+
} else {
|
|
269
|
+
h = new Headers();
|
|
270
|
+
}
|
|
168
271
|
if (headers) {
|
|
169
|
-
|
|
272
|
+
if (headers instanceof Headers) {
|
|
273
|
+
headers.forEach((v, k) => h.set(k, v));
|
|
274
|
+
} else if (Array.isArray(headers)) {
|
|
275
|
+
headers.forEach(([k, v]) => h.set(k, v));
|
|
276
|
+
} else {
|
|
277
|
+
const keys = Object.keys(headers);
|
|
278
|
+
for (let i = 0; i < keys.length; i++) {
|
|
279
|
+
const key = keys[i];
|
|
280
|
+
const val = headers[key];
|
|
281
|
+
h.set(key, val);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
170
284
|
}
|
|
171
285
|
return h;
|
|
172
286
|
}
|
|
@@ -179,7 +293,8 @@ class ShokupanContext {
|
|
|
179
293
|
send(body, options) {
|
|
180
294
|
const headers = this.mergeHeaders(options?.headers);
|
|
181
295
|
const status = options?.status ?? this.response.status;
|
|
182
|
-
|
|
296
|
+
this._finalResponse = new Response(body, { status, headers });
|
|
297
|
+
return this._finalResponse;
|
|
183
298
|
}
|
|
184
299
|
/**
|
|
185
300
|
* Read request body
|
|
@@ -198,19 +313,36 @@ class ShokupanContext {
|
|
|
198
313
|
* Respond with a JSON object
|
|
199
314
|
*/
|
|
200
315
|
json(data, status, headers) {
|
|
316
|
+
const finalStatus = status ?? this.response.status;
|
|
317
|
+
const jsonString = JSON.stringify(data);
|
|
318
|
+
if (!headers && !this.response.hasPopulatedHeaders) {
|
|
319
|
+
this._finalResponse = new Response(jsonString, {
|
|
320
|
+
status: finalStatus,
|
|
321
|
+
headers: { "content-type": "application/json" }
|
|
322
|
+
});
|
|
323
|
+
return this._finalResponse;
|
|
324
|
+
}
|
|
201
325
|
const finalHeaders = this.mergeHeaders(headers);
|
|
202
326
|
finalHeaders.set("content-type", "application/json");
|
|
203
|
-
|
|
204
|
-
return
|
|
327
|
+
this._finalResponse = new Response(jsonString, { status: finalStatus, headers: finalHeaders });
|
|
328
|
+
return this._finalResponse;
|
|
205
329
|
}
|
|
206
330
|
/**
|
|
207
331
|
* Respond with a text string
|
|
208
332
|
*/
|
|
209
333
|
text(data, status, headers) {
|
|
334
|
+
const finalStatus = status ?? this.response.status;
|
|
335
|
+
if (!headers && !this.response.hasPopulatedHeaders) {
|
|
336
|
+
this._finalResponse = new Response(data, {
|
|
337
|
+
status: finalStatus,
|
|
338
|
+
headers: { "content-type": "text/plain" }
|
|
339
|
+
});
|
|
340
|
+
return this._finalResponse;
|
|
341
|
+
}
|
|
210
342
|
const finalHeaders = this.mergeHeaders(headers);
|
|
211
343
|
finalHeaders.set("content-type", "text/plain");
|
|
212
|
-
|
|
213
|
-
return
|
|
344
|
+
this._finalResponse = new Response(data, { status: finalStatus, headers: finalHeaders });
|
|
345
|
+
return this._finalResponse;
|
|
214
346
|
}
|
|
215
347
|
/**
|
|
216
348
|
* Respond with HTML content
|
|
@@ -219,7 +351,8 @@ class ShokupanContext {
|
|
|
219
351
|
const finalHeaders = this.mergeHeaders(headers);
|
|
220
352
|
finalHeaders.set("content-type", "text/html");
|
|
221
353
|
const finalStatus = status ?? this.response.status;
|
|
222
|
-
|
|
354
|
+
this._finalResponse = new Response(html, { status: finalStatus, headers: finalHeaders });
|
|
355
|
+
return this._finalResponse;
|
|
223
356
|
}
|
|
224
357
|
/**
|
|
225
358
|
* Respond with a redirect
|
|
@@ -227,7 +360,8 @@ class ShokupanContext {
|
|
|
227
360
|
redirect(url, status = 302) {
|
|
228
361
|
const headers = this.mergeHeaders();
|
|
229
362
|
headers.set("Location", url);
|
|
230
|
-
|
|
363
|
+
this._finalResponse = new Response(null, { status, headers });
|
|
364
|
+
return this._finalResponse;
|
|
231
365
|
}
|
|
232
366
|
/**
|
|
233
367
|
* Respond with a status code
|
|
@@ -235,7 +369,8 @@ class ShokupanContext {
|
|
|
235
369
|
*/
|
|
236
370
|
status(status) {
|
|
237
371
|
const headers = this.mergeHeaders();
|
|
238
|
-
|
|
372
|
+
this._finalResponse = new Response(null, { status, headers });
|
|
373
|
+
return this._finalResponse;
|
|
239
374
|
}
|
|
240
375
|
/**
|
|
241
376
|
* Respond with a file
|
|
@@ -243,7 +378,25 @@ class ShokupanContext {
|
|
|
243
378
|
file(path2, fileOptions, responseOptions) {
|
|
244
379
|
const headers = this.mergeHeaders(responseOptions?.headers);
|
|
245
380
|
const status = responseOptions?.status ?? this.response.status;
|
|
246
|
-
|
|
381
|
+
this._finalResponse = new Response(Bun.file(path2, fileOptions), { status, headers });
|
|
382
|
+
return this._finalResponse;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* JSX Rendering Function
|
|
386
|
+
*/
|
|
387
|
+
renderer;
|
|
388
|
+
/**
|
|
389
|
+
* Render a JSX element
|
|
390
|
+
* @param element JSX Element
|
|
391
|
+
* @param status HTTP Status
|
|
392
|
+
* @param headers HTTP Headers
|
|
393
|
+
*/
|
|
394
|
+
async jsx(element, args, status, headers) {
|
|
395
|
+
if (!this.renderer) {
|
|
396
|
+
throw new Error("No JSX renderer configured");
|
|
397
|
+
}
|
|
398
|
+
const html = await this.renderer(element, args);
|
|
399
|
+
return this.html(html, status, headers);
|
|
247
400
|
}
|
|
248
401
|
}
|
|
249
402
|
const $isApplication = /* @__PURE__ */ Symbol.for("Shokupan.app");
|
|
@@ -259,6 +412,8 @@ const $childRouters = /* @__PURE__ */ Symbol.for("Shokupan.child-routers");
|
|
|
259
412
|
const $childControllers = /* @__PURE__ */ Symbol.for("Shokupan.child-controllers");
|
|
260
413
|
const $mountPath = /* @__PURE__ */ Symbol.for("Shokupan.mount-path");
|
|
261
414
|
const $dispatch = /* @__PURE__ */ Symbol.for("Shokupan.dispatch");
|
|
415
|
+
const $routes = /* @__PURE__ */ Symbol.for("Shokupan.routes");
|
|
416
|
+
const $routeSpec = /* @__PURE__ */ Symbol.for("Shokupan.routeSpec");
|
|
262
417
|
const HTTPMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "ALL"];
|
|
263
418
|
var RouteParamType = /* @__PURE__ */ ((RouteParamType2) => {
|
|
264
419
|
RouteParamType2["BODY"] = "BODY";
|
|
@@ -311,6 +466,14 @@ const Query = createParamDecorator(RouteParamType.QUERY);
|
|
|
311
466
|
const Headers$1 = createParamDecorator(RouteParamType.HEADER);
|
|
312
467
|
const Req = createParamDecorator(RouteParamType.REQUEST);
|
|
313
468
|
const Ctx = createParamDecorator(RouteParamType.CONTEXT);
|
|
469
|
+
function Spec(spec) {
|
|
470
|
+
return (target, propertyKey, descriptor) => {
|
|
471
|
+
if (!target[$routeSpec]) {
|
|
472
|
+
target[$routeSpec] = /* @__PURE__ */ new Map();
|
|
473
|
+
}
|
|
474
|
+
target[$routeSpec].set(propertyKey, spec);
|
|
475
|
+
};
|
|
476
|
+
}
|
|
314
477
|
function createMethodDecorator(method) {
|
|
315
478
|
return (path2 = "/") => {
|
|
316
479
|
return (target, propertyKey, descriptor) => {
|
|
@@ -365,83 +528,29 @@ function Inject(token) {
|
|
|
365
528
|
});
|
|
366
529
|
};
|
|
367
530
|
}
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
provider.register();
|
|
382
|
-
const tracer = api.trace.getTracer("shokupan.middleware");
|
|
383
|
-
function traceMiddleware(fn, name) {
|
|
384
|
-
const middlewareName = fn.name || "anonymous middleware";
|
|
385
|
-
return async (ctx, next) => {
|
|
386
|
-
return tracer.startActiveSpan(`middleware - ${middlewareName}`, {
|
|
387
|
-
kind: api.SpanKind.INTERNAL,
|
|
388
|
-
attributes: {
|
|
389
|
-
"code.function": middlewareName,
|
|
390
|
-
"component": "shokupan.middleware"
|
|
391
|
-
}
|
|
392
|
-
}, async (span) => {
|
|
393
|
-
try {
|
|
394
|
-
const result = await fn(ctx, next);
|
|
395
|
-
return result;
|
|
396
|
-
} catch (err) {
|
|
397
|
-
span.recordException(err);
|
|
398
|
-
span.setStatus({ code: api.SpanStatusCode.ERROR, message: err.message });
|
|
399
|
-
throw err;
|
|
400
|
-
} finally {
|
|
401
|
-
span.end();
|
|
402
|
-
}
|
|
403
|
-
});
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
function traceHandler(fn, name) {
|
|
407
|
-
return async function(...args) {
|
|
408
|
-
return tracer.startActiveSpan(`route handler - ${name}`, {
|
|
409
|
-
kind: api.SpanKind.INTERNAL,
|
|
410
|
-
attributes: {
|
|
411
|
-
"http.route": name,
|
|
412
|
-
"component": "shokupan.route"
|
|
531
|
+
const compose = (middleware) => {
|
|
532
|
+
if (!middleware.length) {
|
|
533
|
+
return (context, next) => {
|
|
534
|
+
return next ? next() : Promise.resolve();
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
return function dispatch(context, next) {
|
|
538
|
+
let index = -1;
|
|
539
|
+
function runner(i) {
|
|
540
|
+
if (i <= index) return Promise.reject(new Error("next() called multiple times"));
|
|
541
|
+
index = i;
|
|
542
|
+
if (i >= middleware.length) {
|
|
543
|
+
return next ? next() : Promise.resolve();
|
|
413
544
|
}
|
|
414
|
-
|
|
545
|
+
const fn = middleware[i];
|
|
415
546
|
try {
|
|
416
|
-
|
|
417
|
-
return result;
|
|
547
|
+
return Promise.resolve(fn(context, () => runner(i + 1)));
|
|
418
548
|
} catch (err) {
|
|
419
|
-
|
|
420
|
-
span.setStatus({ code: api.SpanStatusCode.ERROR, message: err.message });
|
|
421
|
-
throw err;
|
|
422
|
-
} finally {
|
|
423
|
-
span.end();
|
|
549
|
+
return Promise.reject(err);
|
|
424
550
|
}
|
|
425
|
-
});
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
const compose = (middleware) => {
|
|
429
|
-
function fn(context, next) {
|
|
430
|
-
let runner = next || (async () => {
|
|
431
|
-
});
|
|
432
|
-
for (let i = middleware.length - 1; i >= 0; i--) {
|
|
433
|
-
const fn2 = traceMiddleware(middleware[i]);
|
|
434
|
-
const nextStep = runner;
|
|
435
|
-
let called = false;
|
|
436
|
-
runner = async () => {
|
|
437
|
-
if (called) throw new Error("next() called multiple times");
|
|
438
|
-
called = true;
|
|
439
|
-
return fn2(context, nextStep);
|
|
440
|
-
};
|
|
441
551
|
}
|
|
442
|
-
return runner();
|
|
443
|
-
}
|
|
444
|
-
return fn;
|
|
552
|
+
return runner(0);
|
|
553
|
+
};
|
|
445
554
|
};
|
|
446
555
|
class ShokupanRequestBase {
|
|
447
556
|
method;
|
|
@@ -468,7 +577,6 @@ class ShokupanRequestBase {
|
|
|
468
577
|
}
|
|
469
578
|
}
|
|
470
579
|
const ShokupanRequest = ShokupanRequestBase;
|
|
471
|
-
const asyncContext = new node_async_hooks.AsyncLocalStorage();
|
|
472
580
|
function isObject(item) {
|
|
473
581
|
return item && typeof item === "object" && !Array.isArray(item);
|
|
474
582
|
}
|
|
@@ -482,7 +590,17 @@ function deepMerge(target, ...sources) {
|
|
|
482
590
|
deepMerge(target[key], source[key]);
|
|
483
591
|
} else if (Array.isArray(source[key])) {
|
|
484
592
|
if (!target[key]) Object.assign(target, { [key]: [] });
|
|
485
|
-
|
|
593
|
+
if (key === "tags") {
|
|
594
|
+
target[key] = source[key];
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const mergedArray = target[key].concat(source[key]);
|
|
598
|
+
const isPrimitive = (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean";
|
|
599
|
+
if (mergedArray.every(isPrimitive)) {
|
|
600
|
+
target[key] = Array.from(new Set(mergedArray));
|
|
601
|
+
} else {
|
|
602
|
+
target[key] = mergedArray;
|
|
603
|
+
}
|
|
486
604
|
} else {
|
|
487
605
|
Object.assign(target, { [key]: source[key] });
|
|
488
606
|
}
|
|
@@ -490,98 +608,709 @@ function deepMerge(target, ...sources) {
|
|
|
490
608
|
}
|
|
491
609
|
return deepMerge(target, ...sources);
|
|
492
610
|
}
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
611
|
+
function analyzeHandler(handler) {
|
|
612
|
+
const handlerSource = handler.toString();
|
|
613
|
+
const inferredSpec = {};
|
|
614
|
+
if (handlerSource.includes("ctx.body") || handlerSource.includes("await ctx.req.json()")) {
|
|
615
|
+
inferredSpec.requestBody = {
|
|
616
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
617
|
+
};
|
|
499
618
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
[$childRouters] = [];
|
|
508
|
-
[$childControllers] = [];
|
|
509
|
-
get rootConfig() {
|
|
510
|
-
return this[$appRoot]?.applicationConfig;
|
|
619
|
+
const queryParams = /* @__PURE__ */ new Map();
|
|
620
|
+
const queryIntMatch = handlerSource.match(/parseInt\(ctx\.query\.(\w+)\)/g);
|
|
621
|
+
if (queryIntMatch) {
|
|
622
|
+
queryIntMatch.forEach((match) => {
|
|
623
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
624
|
+
if (paramName) queryParams.set(paramName, { type: "integer", format: "int32" });
|
|
625
|
+
});
|
|
511
626
|
}
|
|
512
|
-
|
|
513
|
-
|
|
627
|
+
const queryFloatMatch = handlerSource.match(/parseFloat\(ctx\.query\.(\w+)\)/g);
|
|
628
|
+
if (queryFloatMatch) {
|
|
629
|
+
queryFloatMatch.forEach((match) => {
|
|
630
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
631
|
+
if (paramName) queryParams.set(paramName, { type: "number", format: "float" });
|
|
632
|
+
});
|
|
514
633
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
634
|
+
const queryNumberMatch = handlerSource.match(/Number\(ctx\.query\.(\w+)\)/g);
|
|
635
|
+
if (queryNumberMatch) {
|
|
636
|
+
queryNumberMatch.forEach((match) => {
|
|
637
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
638
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
639
|
+
queryParams.set(paramName, { type: "number" });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
519
642
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
* - get(ctx) -> GET /prefix/
|
|
527
|
-
* - getUsers(ctx) -> GET /prefix/users
|
|
528
|
-
* - postCreate(ctx) -> POST /prefix/create
|
|
529
|
-
*/
|
|
530
|
-
mount(prefix, controller) {
|
|
531
|
-
if (this.isRouterInstance(controller)) {
|
|
532
|
-
if (controller[$isMounted]) {
|
|
533
|
-
throw new Error("Router is already mounted");
|
|
643
|
+
const queryBoolMatch = handlerSource.match(/(?:Boolean\(ctx\.query\.(\w+)\)|!+ctx\.query\.(\w+))/g);
|
|
644
|
+
if (queryBoolMatch) {
|
|
645
|
+
queryBoolMatch.forEach((match) => {
|
|
646
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
647
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
648
|
+
queryParams.set(paramName, { type: "boolean" });
|
|
534
649
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
const queryMatch = handlerSource.match(/ctx\.query\.(\w+)/g);
|
|
653
|
+
if (queryMatch) {
|
|
654
|
+
queryMatch.forEach((match) => {
|
|
655
|
+
const paramName = match.split(".")[2];
|
|
656
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
657
|
+
queryParams.set(paramName, { type: "string" });
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
if (queryParams.size > 0) {
|
|
662
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
663
|
+
queryParams.forEach((schema, paramName) => {
|
|
664
|
+
inferredSpec.parameters.push({
|
|
665
|
+
name: paramName,
|
|
666
|
+
in: "query",
|
|
667
|
+
schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
const pathParams = /* @__PURE__ */ new Map();
|
|
672
|
+
const paramIntMatch = handlerSource.match(/parseInt\(ctx\.params\.(\w+)\)/g);
|
|
673
|
+
if (paramIntMatch) {
|
|
674
|
+
paramIntMatch.forEach((match) => {
|
|
675
|
+
const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
|
|
676
|
+
if (paramName) pathParams.set(paramName, { type: "integer", format: "int32" });
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
const paramFloatMatch = handlerSource.match(/parseFloat\(ctx\.params\.(\w+)\)/g);
|
|
680
|
+
if (paramFloatMatch) {
|
|
681
|
+
paramFloatMatch.forEach((match) => {
|
|
682
|
+
const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
|
|
683
|
+
if (paramName) pathParams.set(paramName, { type: "number", format: "float" });
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (pathParams.size > 0) {
|
|
687
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
688
|
+
pathParams.forEach((schema, paramName) => {
|
|
689
|
+
inferredSpec.parameters.push({
|
|
690
|
+
name: paramName,
|
|
691
|
+
in: "path",
|
|
692
|
+
required: true,
|
|
693
|
+
schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
const headerMatch = handlerSource.match(/ctx\.get\(['"](\w+)['"]\)/g);
|
|
698
|
+
if (headerMatch) {
|
|
699
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
700
|
+
headerMatch.forEach((match) => {
|
|
701
|
+
const headerName = match.match(/['"](\w+)['"]/)?.[1];
|
|
702
|
+
if (headerName) {
|
|
703
|
+
inferredSpec.parameters.push({
|
|
704
|
+
name: headerName,
|
|
705
|
+
in: "header",
|
|
706
|
+
schema: { type: "string" }
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
const responses = {};
|
|
712
|
+
if (handlerSource.includes("ctx.json(")) {
|
|
713
|
+
responses["200"] = {
|
|
714
|
+
description: "Successful response",
|
|
715
|
+
content: {
|
|
716
|
+
"application/json": { schema: { type: "object" } }
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
if (handlerSource.includes("ctx.html(")) {
|
|
721
|
+
responses["200"] = {
|
|
722
|
+
description: "Successful response",
|
|
723
|
+
content: {
|
|
724
|
+
"text/html": { schema: { type: "string" } }
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (handlerSource.includes("ctx.text(")) {
|
|
729
|
+
responses["200"] = {
|
|
730
|
+
description: "Successful response",
|
|
731
|
+
content: {
|
|
732
|
+
"text/plain": { schema: { type: "string" } }
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (handlerSource.includes("ctx.file(")) {
|
|
737
|
+
responses["200"] = {
|
|
738
|
+
description: "File download",
|
|
739
|
+
content: {
|
|
740
|
+
"application/octet-stream": { schema: { type: "string", format: "binary" } }
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (handlerSource.includes("ctx.redirect(")) {
|
|
745
|
+
responses["302"] = {
|
|
746
|
+
description: "Redirect"
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
if (!responses["200"] && /return\s+\{/.test(handlerSource)) {
|
|
750
|
+
responses["200"] = {
|
|
751
|
+
description: "Successful response",
|
|
752
|
+
content: {
|
|
753
|
+
"application/json": { schema: { type: "object" } }
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
const errorStatusMatch = handlerSource.match(/ctx\.(?:json|text|html)\([^)]+,\s*(\d{3,})\)/g);
|
|
758
|
+
if (errorStatusMatch) {
|
|
759
|
+
errorStatusMatch.forEach((match) => {
|
|
760
|
+
const statusCode = match.match(/,\s*(\d{3,})\)/)?.[1];
|
|
761
|
+
if (statusCode && statusCode !== "200") {
|
|
762
|
+
responses[statusCode] = {
|
|
763
|
+
description: `Error response (${statusCode})`
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (Object.keys(responses).length > 0) {
|
|
769
|
+
inferredSpec.responses = responses;
|
|
770
|
+
}
|
|
771
|
+
return { inferredSpec };
|
|
772
|
+
}
|
|
773
|
+
async function generateOpenApi(rootRouter, options = {}) {
|
|
774
|
+
const paths = {};
|
|
775
|
+
const tagGroups = /* @__PURE__ */ new Map();
|
|
776
|
+
const defaultTagGroup = options.defaultTagGroup || "General";
|
|
777
|
+
const defaultTagName = options.defaultTag || "Application";
|
|
778
|
+
let astRoutes = [];
|
|
779
|
+
try {
|
|
780
|
+
const { OpenAPIAnalyzer } = await Promise.resolve().then(() => require("./openapi-analyzer-BN0wFCML.cjs"));
|
|
781
|
+
const analyzer = new OpenAPIAnalyzer(process.cwd());
|
|
782
|
+
const { applications } = await analyzer.analyze();
|
|
783
|
+
const appMap = /* @__PURE__ */ new Map();
|
|
784
|
+
applications.forEach((app) => {
|
|
785
|
+
appMap.set(app.name, app);
|
|
786
|
+
if (app.name !== app.className) {
|
|
787
|
+
appMap.set(app.className, app);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
const getExpandedRoutes = (app, prefix = "", seen = /* @__PURE__ */ new Set()) => {
|
|
791
|
+
if (seen.has(app.name)) return [];
|
|
792
|
+
const newSeen = new Set(seen);
|
|
793
|
+
newSeen.add(app.name);
|
|
794
|
+
const expanded = [];
|
|
795
|
+
for (const route of app.routes) {
|
|
796
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
797
|
+
const cleanPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
798
|
+
let joined = cleanPrefix + cleanPath;
|
|
799
|
+
if (joined.length > 1 && joined.endsWith("/")) {
|
|
800
|
+
joined = joined.slice(0, -1);
|
|
801
|
+
}
|
|
802
|
+
expanded.push({
|
|
803
|
+
...route,
|
|
804
|
+
path: joined || "/"
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
if (app.mounted) {
|
|
808
|
+
for (const mount of app.mounted) {
|
|
809
|
+
const targetApp = appMap.get(mount.target);
|
|
810
|
+
if (targetApp) {
|
|
811
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
812
|
+
const mountPrefix = mount.prefix.startsWith("/") ? mount.prefix : "/" + mount.prefix;
|
|
813
|
+
expanded.push(...getExpandedRoutes(targetApp, cleanPrefix + mountPrefix, newSeen));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return expanded;
|
|
818
|
+
};
|
|
819
|
+
applications.forEach((app) => {
|
|
820
|
+
astRoutes.push(...getExpandedRoutes(app));
|
|
821
|
+
});
|
|
822
|
+
const dedupedRoutes = /* @__PURE__ */ new Map();
|
|
823
|
+
for (const route of astRoutes) {
|
|
824
|
+
const key = `${route.method.toUpperCase()}:${route.path}`;
|
|
825
|
+
let score = 0;
|
|
826
|
+
if (route.responseSchema) score += 10;
|
|
827
|
+
if (route.handlerSource) score += 5;
|
|
828
|
+
if (!dedupedRoutes.has(key) || score > dedupedRoutes.get(key).score) {
|
|
829
|
+
dedupedRoutes.set(key, { route, score });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
astRoutes = Array.from(dedupedRoutes.values()).map((v) => v.route);
|
|
833
|
+
} catch (e) {
|
|
834
|
+
console.warn("OpenAPI AST analysis failed or skipped:", e);
|
|
835
|
+
}
|
|
836
|
+
const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
|
|
837
|
+
let group = currentGroup;
|
|
838
|
+
let tag = defaultTag;
|
|
839
|
+
if (router.config?.group) group = router.config.group;
|
|
840
|
+
if (router.config?.name) {
|
|
841
|
+
tag = router.config.name;
|
|
546
842
|
} else {
|
|
547
|
-
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const p2 = controllerPath.startsWith("/") ? controllerPath : "/" + controllerPath;
|
|
554
|
-
prefix = p1 + p2;
|
|
555
|
-
if (!prefix) prefix = "/";
|
|
843
|
+
const mountPath = router[$mountPath];
|
|
844
|
+
if (mountPath && mountPath !== "/") {
|
|
845
|
+
const segments = mountPath.split("/").filter(Boolean);
|
|
846
|
+
if (segments.length > 0) {
|
|
847
|
+
const lastSegment = segments[segments.length - 1];
|
|
848
|
+
tag = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
556
849
|
}
|
|
557
850
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
851
|
+
}
|
|
852
|
+
if (!tagGroups.has(group)) tagGroups.set(group, /* @__PURE__ */ new Set());
|
|
853
|
+
const routes = router[$routes] || [];
|
|
854
|
+
for (const route of routes) {
|
|
855
|
+
const routeGroup = route.group || group;
|
|
856
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
857
|
+
const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
858
|
+
let fullPath = cleanPrefix + cleanSubPath || "/";
|
|
859
|
+
fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
860
|
+
if (fullPath.length > 1 && fullPath.endsWith("/")) {
|
|
861
|
+
fullPath = fullPath.slice(0, -1);
|
|
567
862
|
}
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
863
|
+
if (!paths[fullPath]) paths[fullPath] = {};
|
|
864
|
+
const operation = {
|
|
865
|
+
responses: { "200": { description: "Successful response" } },
|
|
866
|
+
tags: [tag]
|
|
867
|
+
};
|
|
868
|
+
if (route.guards) {
|
|
869
|
+
for (const guard of route.guards) {
|
|
870
|
+
if (guard.spec) {
|
|
871
|
+
if (guard.spec.security) {
|
|
872
|
+
const existing = operation.security || [];
|
|
873
|
+
for (const req of guard.spec.security) {
|
|
874
|
+
const reqStr = JSON.stringify(req);
|
|
875
|
+
if (!existing.some((e) => JSON.stringify(e) === reqStr)) {
|
|
876
|
+
existing.push(req);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
operation.security = existing;
|
|
880
|
+
}
|
|
881
|
+
if (guard.spec.responses) {
|
|
882
|
+
operation.responses = { ...operation.responses, ...guard.spec.responses };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
let astMatch = astRoutes.find(
|
|
888
|
+
(r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
|
|
889
|
+
);
|
|
890
|
+
if (!astMatch) {
|
|
891
|
+
let runtimeSource = route.handler.toString();
|
|
892
|
+
if (route.handler.originalHandler) {
|
|
893
|
+
runtimeSource = route.handler.originalHandler.toString();
|
|
894
|
+
}
|
|
895
|
+
const runtimeHandlerSrc = runtimeSource.replace(/\s+/g, " ");
|
|
896
|
+
const sameMethodRoutes = astRoutes.filter((r) => r.method.toUpperCase() === route.method.toUpperCase());
|
|
897
|
+
astMatch = sameMethodRoutes.find((r) => {
|
|
898
|
+
const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
|
|
899
|
+
if (!astHandlerSrc || astHandlerSrc.length < 20) return false;
|
|
900
|
+
const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
|
|
901
|
+
return match;
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const potentialMatches = astRoutes.filter(
|
|
905
|
+
(r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
|
|
906
|
+
);
|
|
907
|
+
if (potentialMatches.length > 1) {
|
|
908
|
+
const runtimeHandlerSrc = route.handler.toString().replace(/\s+/g, " ");
|
|
909
|
+
const preciseMatch = potentialMatches.find((r) => {
|
|
910
|
+
const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
|
|
911
|
+
const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
|
|
912
|
+
return match;
|
|
913
|
+
});
|
|
914
|
+
if (preciseMatch) {
|
|
915
|
+
astMatch = preciseMatch;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (astMatch) {
|
|
919
|
+
if (astMatch.summary) operation.summary = astMatch.summary;
|
|
920
|
+
if (astMatch.description) operation.description = astMatch.description;
|
|
921
|
+
if (astMatch.tags) operation.tags = astMatch.tags;
|
|
922
|
+
if (astMatch.operationId) operation.operationId = astMatch.operationId;
|
|
923
|
+
if (astMatch.requestTypes?.body) {
|
|
924
|
+
operation.requestBody = {
|
|
925
|
+
content: {
|
|
926
|
+
"application/json": { schema: astMatch.requestTypes.body }
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
if (astMatch.responseSchema) {
|
|
931
|
+
operation.responses["200"] = {
|
|
932
|
+
description: "Successful response",
|
|
933
|
+
content: {
|
|
934
|
+
"application/json": { schema: astMatch.responseSchema }
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
} else if (astMatch.responseType) {
|
|
938
|
+
const contentType = astMatch.responseType === "string" ? "text/plain" : "application/json";
|
|
939
|
+
operation.responses["200"] = {
|
|
940
|
+
description: "Successful response",
|
|
941
|
+
content: {
|
|
942
|
+
[contentType]: { schema: { type: astMatch.responseType } }
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
const params = [];
|
|
947
|
+
if (astMatch.requestTypes?.query) {
|
|
948
|
+
for (const [name, _type] of Object.entries(astMatch.requestTypes.query)) {
|
|
949
|
+
params.push({ name, in: "query", schema: { type: "string" } });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (params.length > 0) {
|
|
953
|
+
operation.parameters = params;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (route.keys.length > 0) {
|
|
957
|
+
const pathParams = route.keys.map((key) => ({
|
|
958
|
+
name: key,
|
|
959
|
+
in: "path",
|
|
960
|
+
required: true,
|
|
961
|
+
schema: { type: "string" }
|
|
962
|
+
}));
|
|
963
|
+
const existingParams = operation.parameters || [];
|
|
964
|
+
const mergedParams = [...existingParams];
|
|
965
|
+
pathParams.forEach((p) => {
|
|
966
|
+
const idx = mergedParams.findIndex((ep) => ep.in === "path" && ep.name === p.name);
|
|
967
|
+
if (idx >= 0) {
|
|
968
|
+
mergedParams[idx] = deepMerge(mergedParams[idx], p);
|
|
969
|
+
} else {
|
|
970
|
+
mergedParams.push(p);
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
operation.parameters = mergedParams;
|
|
974
|
+
}
|
|
975
|
+
const { inferredSpec } = analyzeHandler(route.handler);
|
|
976
|
+
if (inferredSpec) {
|
|
977
|
+
if (inferredSpec.parameters) {
|
|
978
|
+
const existingParams = operation.parameters || [];
|
|
979
|
+
const mergedParams = [...existingParams];
|
|
980
|
+
for (const p of inferredSpec.parameters) {
|
|
981
|
+
const idx = mergedParams.findIndex((ep) => ep.name === p.name && ep.in === p.in);
|
|
982
|
+
if (idx >= 0) {
|
|
983
|
+
mergedParams[idx] = deepMerge(mergedParams[idx], p);
|
|
984
|
+
} else {
|
|
985
|
+
mergedParams.push(p);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
operation.parameters = mergedParams;
|
|
989
|
+
delete inferredSpec.parameters;
|
|
990
|
+
}
|
|
991
|
+
deepMerge(operation, inferredSpec);
|
|
992
|
+
}
|
|
993
|
+
if (route.handlerSpec) {
|
|
994
|
+
const spec = route.handlerSpec;
|
|
995
|
+
if (spec.summary) operation.summary = spec.summary;
|
|
996
|
+
if (spec.description) operation.description = spec.description;
|
|
997
|
+
if (spec.operationId) operation.operationId = spec.operationId;
|
|
998
|
+
if (spec.tags) operation.tags = spec.tags;
|
|
999
|
+
if (spec.security) operation.security = spec.security;
|
|
1000
|
+
if (spec.responses) {
|
|
1001
|
+
operation.responses = { ...operation.responses, ...spec.responses };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (!operation.tags || operation.tags.length === 0) operation.tags = [tag];
|
|
1005
|
+
if (operation.tags) {
|
|
1006
|
+
operation.tags = Array.from(new Set(operation.tags));
|
|
1007
|
+
for (const t of operation.tags) {
|
|
1008
|
+
if (!tagGroups.has(routeGroup)) tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
|
|
1009
|
+
tagGroups.get(routeGroup)?.add(t);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const methodLower = route.method.toLowerCase();
|
|
1013
|
+
if (methodLower === "all") {
|
|
1014
|
+
["get", "post", "put", "delete", "patch"].forEach((m) => {
|
|
1015
|
+
if (!paths[fullPath][m]) paths[fullPath][m] = { ...operation };
|
|
1016
|
+
});
|
|
1017
|
+
} else {
|
|
1018
|
+
paths[fullPath][methodLower] = operation;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
for (const controller of router[$childControllers]) {
|
|
1022
|
+
const controllerName = controller.constructor.name || "UnknownController";
|
|
1023
|
+
tagGroups.get(group)?.add(controllerName);
|
|
1024
|
+
}
|
|
1025
|
+
for (const child of router[$childRouters]) {
|
|
1026
|
+
const mountPath = child[$mountPath];
|
|
1027
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1028
|
+
const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
1029
|
+
const nextPrefix = cleanPrefix + cleanMount || "/";
|
|
1030
|
+
collect(child, nextPrefix, group, tag);
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
collect(rootRouter);
|
|
1034
|
+
const xTagGroups = [];
|
|
1035
|
+
for (const [name, tags] of tagGroups) {
|
|
1036
|
+
xTagGroups.push({ name, tags: Array.from(tags).sort() });
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
openapi: "3.1.0",
|
|
1040
|
+
info: { title: "Shokupan API", version: "1.0.0", ...options.info },
|
|
1041
|
+
paths,
|
|
1042
|
+
components: options.components,
|
|
1043
|
+
servers: options.servers,
|
|
1044
|
+
tags: options.tags,
|
|
1045
|
+
externalDocs: options.externalDocs,
|
|
1046
|
+
"x-tagGroups": xTagGroups
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
const eta$1 = new eta$2.Eta();
|
|
1050
|
+
function serveStatic(ctx, config, prefix) {
|
|
1051
|
+
const rootPath = path.resolve(config.root || ".");
|
|
1052
|
+
const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
|
|
1053
|
+
return async () => {
|
|
1054
|
+
let relative = ctx.path.slice(normalizedPrefix.length);
|
|
1055
|
+
if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
|
|
1056
|
+
if (relative.length === 0) relative = "/";
|
|
1057
|
+
relative = decodeURIComponent(relative);
|
|
1058
|
+
const requestPath = path.join(rootPath, relative);
|
|
1059
|
+
if (!requestPath.startsWith(rootPath)) {
|
|
1060
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1061
|
+
}
|
|
1062
|
+
if (requestPath.includes("\0")) {
|
|
1063
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1064
|
+
}
|
|
1065
|
+
if (config.hooks?.onRequest) {
|
|
1066
|
+
const res = await config.hooks.onRequest(ctx);
|
|
1067
|
+
if (res) return res;
|
|
1068
|
+
}
|
|
1069
|
+
if (config.exclude) {
|
|
1070
|
+
for (const pattern of config.exclude) {
|
|
1071
|
+
if (pattern instanceof RegExp) {
|
|
1072
|
+
if (pattern.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
|
|
1073
|
+
} else if (typeof pattern === "string") {
|
|
1074
|
+
if (relative.includes(pattern)) return ctx.json({ error: "Forbidden" }, 403);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (path.basename(requestPath).startsWith(".")) {
|
|
1079
|
+
const behavior = config.dotfiles || "ignore";
|
|
1080
|
+
if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
|
|
1081
|
+
if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
|
|
1082
|
+
}
|
|
1083
|
+
let finalPath = requestPath;
|
|
1084
|
+
let stats;
|
|
1085
|
+
try {
|
|
1086
|
+
stats = await promises.stat(requestPath);
|
|
1087
|
+
} catch (e) {
|
|
1088
|
+
if (config.extensions) {
|
|
1089
|
+
for (const ext of config.extensions) {
|
|
1090
|
+
const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
|
|
1091
|
+
try {
|
|
1092
|
+
const s = await promises.stat(p);
|
|
1093
|
+
if (s.isFile()) {
|
|
1094
|
+
finalPath = p;
|
|
1095
|
+
stats = s;
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
} catch {
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (!stats) return ctx.json({ error: "Not Found" }, 404);
|
|
1103
|
+
}
|
|
1104
|
+
if (stats.isDirectory()) {
|
|
1105
|
+
if (!ctx.path.endsWith("/")) {
|
|
1106
|
+
const query = ctx.url.search;
|
|
1107
|
+
return ctx.redirect(ctx.path + "/" + query, 302);
|
|
1108
|
+
}
|
|
1109
|
+
let indexes = [];
|
|
1110
|
+
if (config.index === void 0) {
|
|
1111
|
+
indexes = ["index.html", "index.htm"];
|
|
1112
|
+
} else if (Array.isArray(config.index)) {
|
|
1113
|
+
indexes = config.index;
|
|
1114
|
+
} else if (config.index) {
|
|
1115
|
+
indexes = [config.index];
|
|
1116
|
+
}
|
|
1117
|
+
let foundIndex = false;
|
|
1118
|
+
for (const idx of indexes) {
|
|
1119
|
+
const idxPath = path.join(finalPath, idx);
|
|
1120
|
+
try {
|
|
1121
|
+
const idxStats = await promises.stat(idxPath);
|
|
1122
|
+
if (idxStats.isFile()) {
|
|
1123
|
+
finalPath = idxPath;
|
|
1124
|
+
foundIndex = true;
|
|
1125
|
+
break;
|
|
1126
|
+
}
|
|
1127
|
+
} catch {
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (!foundIndex) {
|
|
1131
|
+
if (config.listDirectory) {
|
|
1132
|
+
try {
|
|
1133
|
+
const files = await promises.readdir(requestPath);
|
|
1134
|
+
const listing = eta$1.renderString(`
|
|
1135
|
+
<!DOCTYPE html>
|
|
1136
|
+
<html>
|
|
1137
|
+
<head>
|
|
1138
|
+
<title>Index of <%= it.relative %></title>
|
|
1139
|
+
<style>
|
|
1140
|
+
body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
1141
|
+
ul { list-style: none; padding: 0; }
|
|
1142
|
+
li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
|
1143
|
+
a { text-decoration: none; color: #0066cc; }
|
|
1144
|
+
a:hover { text-decoration: underline; }
|
|
1145
|
+
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
1146
|
+
</style>
|
|
1147
|
+
</head>
|
|
1148
|
+
<body>
|
|
1149
|
+
<h1>Index of <%= it.relative %></h1>
|
|
1150
|
+
<ul>
|
|
1151
|
+
<% if (it.relative !== '/') { %>
|
|
1152
|
+
<li><a href="../">../</a></li>
|
|
1153
|
+
<% } %>
|
|
1154
|
+
<% it.files.forEach(function(f) { %>
|
|
1155
|
+
<li><a href="<%= f %>"><%= f %></a></li>
|
|
1156
|
+
<% }) %>
|
|
1157
|
+
</ul>
|
|
1158
|
+
</body>
|
|
1159
|
+
</html>
|
|
1160
|
+
`, { relative, files, join: path.join });
|
|
1161
|
+
return new Response(listing, { headers: { "Content-Type": "text/html" } });
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
return ctx.json({ error: "Internal Server Error" }, 500);
|
|
1164
|
+
}
|
|
1165
|
+
} else {
|
|
1166
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
const file = Bun.file(finalPath);
|
|
1171
|
+
let response = new Response(file);
|
|
1172
|
+
if (config.hooks?.onResponse) {
|
|
1173
|
+
const hooked = await config.hooks.onResponse(ctx, response);
|
|
1174
|
+
if (hooked) response = hooked;
|
|
1175
|
+
}
|
|
1176
|
+
return response;
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const asyncContext = new node_async_hooks.AsyncLocalStorage();
|
|
1180
|
+
const tracer = api.trace.getTracer("shokupan.middleware");
|
|
1181
|
+
function traceHandler(fn, name) {
|
|
1182
|
+
return async function(...args) {
|
|
1183
|
+
return tracer.startActiveSpan(`route handler - ${name}`, {
|
|
1184
|
+
kind: api.SpanKind.INTERNAL,
|
|
1185
|
+
attributes: {
|
|
1186
|
+
"http.route": name,
|
|
1187
|
+
"component": "shokupan.route"
|
|
1188
|
+
}
|
|
1189
|
+
}, async (span) => {
|
|
1190
|
+
try {
|
|
1191
|
+
const result = await fn.apply(this, args);
|
|
1192
|
+
return result;
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
span.recordException(err);
|
|
1195
|
+
span.setStatus({ code: api.SpanStatusCode.ERROR, message: err.message });
|
|
1196
|
+
throw err;
|
|
1197
|
+
} finally {
|
|
1198
|
+
span.end();
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
const RouterRegistry = /* @__PURE__ */ new Map();
|
|
1204
|
+
const ShokupanApplicationTree = {};
|
|
1205
|
+
class ShokupanRouter {
|
|
1206
|
+
constructor(config) {
|
|
1207
|
+
this.config = config;
|
|
1208
|
+
if (config?.requestTimeout) {
|
|
1209
|
+
this.requestTimeout = config.requestTimeout;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// Internal marker to identify Router vs. Application
|
|
1213
|
+
[$isApplication] = false;
|
|
1214
|
+
[$isMounted] = false;
|
|
1215
|
+
[$isRouter] = true;
|
|
1216
|
+
[$appRoot];
|
|
1217
|
+
[$mountPath] = "/";
|
|
1218
|
+
// Public via Symbol for OpenAPI generator
|
|
1219
|
+
[$parent] = null;
|
|
1220
|
+
[$childRouters] = [];
|
|
1221
|
+
[$childControllers] = [];
|
|
1222
|
+
get rootConfig() {
|
|
1223
|
+
return this[$appRoot]?.applicationConfig;
|
|
1224
|
+
}
|
|
1225
|
+
get root() {
|
|
1226
|
+
return this[$appRoot];
|
|
1227
|
+
}
|
|
1228
|
+
[$routes] = [];
|
|
1229
|
+
// Public via Symbol for OpenAPI generator
|
|
1230
|
+
currentGuards = [];
|
|
1231
|
+
isRouterInstance(target) {
|
|
1232
|
+
return typeof target === "object" && target !== null && $isRouter in target;
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Mounts a controller instance to a path prefix.
|
|
1236
|
+
*
|
|
1237
|
+
* Controller can be a convection router or an arbitrary class.
|
|
1238
|
+
*
|
|
1239
|
+
* Routes are derived from method names:
|
|
1240
|
+
* - get(ctx) -> GET /prefix/
|
|
1241
|
+
* - getUsers(ctx) -> GET /prefix/users
|
|
1242
|
+
* - postCreate(ctx) -> POST /prefix/create
|
|
1243
|
+
*/
|
|
1244
|
+
mount(prefix, controller) {
|
|
1245
|
+
const isRouter = this.isRouterInstance(controller);
|
|
1246
|
+
const isFunction = typeof controller === "function";
|
|
1247
|
+
const controllersOnly = this.config?.controllersOnly ?? this.rootConfig?.controllersOnly ?? false;
|
|
1248
|
+
if (controllersOnly && !isFunction && !isRouter) {
|
|
1249
|
+
throw new Error(`[Shokupan] strict controller check failed: ${controller.constructor.name || typeof controller} is not a class constructor.`);
|
|
1250
|
+
}
|
|
1251
|
+
if (this.isRouterInstance(controller)) {
|
|
1252
|
+
if (controller[$isMounted]) {
|
|
1253
|
+
throw new Error("Router is already mounted");
|
|
1254
|
+
}
|
|
1255
|
+
controller[$mountPath] = prefix;
|
|
1256
|
+
this[$childRouters].push(controller);
|
|
1257
|
+
controller[$parent] = this;
|
|
1258
|
+
const setRouterContext = (router) => {
|
|
1259
|
+
router[$appRoot] = this.root;
|
|
1260
|
+
router[$childRouters].forEach((child) => setRouterContext(child));
|
|
1261
|
+
};
|
|
1262
|
+
setRouterContext(controller);
|
|
1263
|
+
if (this[$appRoot]) ;
|
|
1264
|
+
controller[$appRoot] = this.root;
|
|
1265
|
+
controller[$isMounted] = true;
|
|
1266
|
+
} else {
|
|
1267
|
+
let instance = controller;
|
|
1268
|
+
if (typeof controller === "function") {
|
|
1269
|
+
instance = Container.resolve(controller);
|
|
1270
|
+
const controllerPath = controller[$controllerPath];
|
|
1271
|
+
if (controllerPath) {
|
|
1272
|
+
const p1 = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1273
|
+
const p2 = controllerPath.startsWith("/") ? controllerPath : "/" + controllerPath;
|
|
1274
|
+
prefix = p1 + p2;
|
|
1275
|
+
if (!prefix) prefix = "/";
|
|
1276
|
+
}
|
|
1277
|
+
} else {
|
|
1278
|
+
const ctor = instance.constructor;
|
|
1279
|
+
const controllerPath = ctor[$controllerPath];
|
|
1280
|
+
if (controllerPath) {
|
|
1281
|
+
const p1 = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1282
|
+
const p2 = controllerPath.startsWith("/") ? controllerPath : "/" + controllerPath;
|
|
1283
|
+
prefix = p1 + p2;
|
|
1284
|
+
if (!prefix) prefix = "/";
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
instance[$mountPath] = prefix;
|
|
1288
|
+
this[$childControllers].push(instance);
|
|
1289
|
+
const controllerMiddleware = (typeof controller === "function" ? controller[$middleware] : instance[$middleware]) || [];
|
|
1290
|
+
const proto = Object.getPrototypeOf(instance);
|
|
1291
|
+
const methods = /* @__PURE__ */ new Set();
|
|
1292
|
+
let current = proto;
|
|
1293
|
+
while (current && current !== Object.prototype) {
|
|
1294
|
+
Object.getOwnPropertyNames(current).forEach((name) => methods.add(name));
|
|
1295
|
+
current = Object.getPrototypeOf(current);
|
|
1296
|
+
}
|
|
1297
|
+
Object.getOwnPropertyNames(instance).forEach((name) => methods.add(name));
|
|
1298
|
+
const decoratedRoutes = instance[$routeMethods] || proto && proto[$routeMethods];
|
|
1299
|
+
const decoratedArgs = instance[$routeArgs] || proto && proto[$routeArgs];
|
|
1300
|
+
const methodMiddlewareMap = instance[$middleware] || proto && proto[$middleware];
|
|
1301
|
+
let routesAttached = 0;
|
|
1302
|
+
for (const name of methods) {
|
|
1303
|
+
if (name === "constructor") continue;
|
|
1304
|
+
if (["arguments", "caller", "callee"].includes(name)) continue;
|
|
1305
|
+
const originalHandler = instance[name];
|
|
1306
|
+
if (typeof originalHandler !== "function") continue;
|
|
1307
|
+
let method;
|
|
1308
|
+
let subPath = "";
|
|
1309
|
+
if (decoratedRoutes && decoratedRoutes.has(name)) {
|
|
1310
|
+
const config = decoratedRoutes.get(name);
|
|
1311
|
+
method = config.method;
|
|
1312
|
+
subPath = config.path;
|
|
1313
|
+
} else {
|
|
585
1314
|
for (const m of HTTPMethods) {
|
|
586
1315
|
if (name.toUpperCase().startsWith(m)) {
|
|
587
1316
|
method = m;
|
|
@@ -661,7 +1390,7 @@ class ShokupanRouter {
|
|
|
661
1390
|
}
|
|
662
1391
|
}
|
|
663
1392
|
}
|
|
664
|
-
const tracedOriginalHandler = traceHandler(originalHandler, normalizedPath);
|
|
1393
|
+
const tracedOriginalHandler = ctx.app?.applicationConfig.enableTracing ? traceHandler(originalHandler, normalizedPath) : originalHandler;
|
|
665
1394
|
return tracedOriginalHandler.apply(instance, args);
|
|
666
1395
|
};
|
|
667
1396
|
let finalHandler = wrappedHandler;
|
|
@@ -671,8 +1400,14 @@ class ShokupanRouter {
|
|
|
671
1400
|
return composed(ctx, () => wrappedHandler(ctx));
|
|
672
1401
|
};
|
|
673
1402
|
}
|
|
1403
|
+
finalHandler.originalHandler = originalHandler;
|
|
1404
|
+
if (finalHandler !== wrappedHandler) {
|
|
1405
|
+
wrappedHandler.originalHandler = originalHandler;
|
|
1406
|
+
}
|
|
674
1407
|
const tagName = instance.constructor.name;
|
|
675
|
-
const
|
|
1408
|
+
const decoratedSpecs = instance[$routeSpec] || proto && proto[$routeSpec];
|
|
1409
|
+
const userSpec = decoratedSpecs && decoratedSpecs.get(name);
|
|
1410
|
+
const spec = { tags: [tagName], ...userSpec };
|
|
676
1411
|
this.add({ method, path: normalizedPath, handler: finalHandler, spec });
|
|
677
1412
|
}
|
|
678
1413
|
}
|
|
@@ -687,7 +1422,7 @@ class ShokupanRouter {
|
|
|
687
1422
|
* Returns all routes attached to this router and its descendants.
|
|
688
1423
|
*/
|
|
689
1424
|
getRoutes() {
|
|
690
|
-
const routes = this
|
|
1425
|
+
const routes = this[$routes].map((r) => ({
|
|
691
1426
|
method: r.method,
|
|
692
1427
|
path: r.path,
|
|
693
1428
|
handler: r.handler
|
|
@@ -788,6 +1523,31 @@ class ShokupanRouter {
|
|
|
788
1523
|
data: result
|
|
789
1524
|
};
|
|
790
1525
|
}
|
|
1526
|
+
applyHooks(match) {
|
|
1527
|
+
if (!this.config?.hooks) return match;
|
|
1528
|
+
const hooks = this.config.hooks;
|
|
1529
|
+
if (!hooks.onRequestStart && !hooks.onRequestEnd && !hooks.onError) return match;
|
|
1530
|
+
const originalHandler = match.handler;
|
|
1531
|
+
match.handler = async (ctx) => {
|
|
1532
|
+
if (hooks.onRequestStart) await hooks.onRequestStart(ctx);
|
|
1533
|
+
try {
|
|
1534
|
+
const result = await originalHandler(ctx);
|
|
1535
|
+
if (hooks.onRequestEnd) await hooks.onRequestEnd(ctx);
|
|
1536
|
+
return result;
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
if (hooks.onError) {
|
|
1539
|
+
try {
|
|
1540
|
+
await hooks.onError(err, ctx);
|
|
1541
|
+
} catch (e) {
|
|
1542
|
+
console.error("Error in router onError hook:", e);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
throw err;
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
match.handler.originalHandler = originalHandler.originalHandler || originalHandler;
|
|
1549
|
+
return match;
|
|
1550
|
+
}
|
|
791
1551
|
/**
|
|
792
1552
|
* Find a route matching the given method and path.
|
|
793
1553
|
* @param method HTTP method
|
|
@@ -795,29 +1555,38 @@ class ShokupanRouter {
|
|
|
795
1555
|
* @returns Route handler and parameters if found, otherwise null
|
|
796
1556
|
*/
|
|
797
1557
|
find(method, path2) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1558
|
+
const findInRoutes = (routes, m) => {
|
|
1559
|
+
for (const route of routes) {
|
|
1560
|
+
if (route.method !== "ALL" && route.method !== m) continue;
|
|
1561
|
+
const match = route.regex.exec(path2);
|
|
1562
|
+
if (match) {
|
|
1563
|
+
const params = {};
|
|
1564
|
+
route.keys.forEach((key, index) => {
|
|
1565
|
+
params[key] = match[index + 1];
|
|
1566
|
+
});
|
|
1567
|
+
return this.applyHooks({ handler: route.handler, params });
|
|
1568
|
+
}
|
|
807
1569
|
}
|
|
1570
|
+
return null;
|
|
1571
|
+
};
|
|
1572
|
+
let result = findInRoutes(this[$routes], method);
|
|
1573
|
+
if (result) return result;
|
|
1574
|
+
if (method === "HEAD") {
|
|
1575
|
+
result = findInRoutes(this[$routes], "GET");
|
|
1576
|
+
if (result) return result;
|
|
808
1577
|
}
|
|
809
1578
|
for (const child of this[$childRouters]) {
|
|
810
1579
|
const prefix = child[$mountPath];
|
|
811
1580
|
if (path2 === prefix || path2.startsWith(prefix + "/")) {
|
|
812
1581
|
const subPath = path2.slice(prefix.length) || "/";
|
|
813
1582
|
const match = child.find(method, subPath);
|
|
814
|
-
if (match) return match;
|
|
1583
|
+
if (match) return this.applyHooks(match);
|
|
815
1584
|
}
|
|
816
1585
|
if (prefix.endsWith("/")) {
|
|
817
1586
|
if (path2.startsWith(prefix)) {
|
|
818
1587
|
const subPath = path2.slice(prefix.length) || "/";
|
|
819
1588
|
const match = child.find(method, subPath);
|
|
820
|
-
if (match) return match;
|
|
1589
|
+
if (match) return this.applyHooks(match);
|
|
821
1590
|
}
|
|
822
1591
|
}
|
|
823
1592
|
}
|
|
@@ -835,6 +1604,7 @@ class ShokupanRouter {
|
|
|
835
1604
|
};
|
|
836
1605
|
}
|
|
837
1606
|
// --- Functional Routing ---
|
|
1607
|
+
requestTimeout;
|
|
838
1608
|
/**
|
|
839
1609
|
* Adds a route to the router.
|
|
840
1610
|
*
|
|
@@ -842,12 +1612,25 @@ class ShokupanRouter {
|
|
|
842
1612
|
* @param path - URL path
|
|
843
1613
|
* @param spec - OpenAPI specification for the route
|
|
844
1614
|
* @param handler - Route handler function
|
|
1615
|
+
* @param requestTimeout - Timeout for this route in milliseconds
|
|
845
1616
|
*/
|
|
846
|
-
add({ method, path: path2, spec, handler, regex: customRegex, group }) {
|
|
1617
|
+
add({ method, path: path2, spec, handler, regex: customRegex, group, requestTimeout, renderer }) {
|
|
847
1618
|
const { regex, keys } = customRegex ? { regex: customRegex, keys: [] } : this.parsePath(path2);
|
|
848
1619
|
let wrappedHandler = handler;
|
|
849
1620
|
const routeGuards = [...this.currentGuards];
|
|
1621
|
+
const effectiveTimeout = requestTimeout ?? this.requestTimeout ?? this.rootConfig?.requestTimeout;
|
|
1622
|
+
if (effectiveTimeout !== void 0 && effectiveTimeout > 0) {
|
|
1623
|
+
const originalHandler = wrappedHandler;
|
|
1624
|
+
wrappedHandler = async (ctx) => {
|
|
1625
|
+
if (ctx.server) {
|
|
1626
|
+
ctx.server.timeout(ctx.req, effectiveTimeout / 1e3);
|
|
1627
|
+
}
|
|
1628
|
+
return originalHandler(ctx);
|
|
1629
|
+
};
|
|
1630
|
+
wrappedHandler.originalHandler = originalHandler.originalHandler || originalHandler;
|
|
1631
|
+
}
|
|
850
1632
|
if (routeGuards.length > 0) {
|
|
1633
|
+
const innerHandler = wrappedHandler;
|
|
851
1634
|
wrappedHandler = async (ctx) => {
|
|
852
1635
|
for (const guard of routeGuards) {
|
|
853
1636
|
let guardPassed = false;
|
|
@@ -872,10 +1655,47 @@ class ShokupanRouter {
|
|
|
872
1655
|
return ctx.json({ error: "Forbidden" }, 403);
|
|
873
1656
|
}
|
|
874
1657
|
}
|
|
875
|
-
return
|
|
1658
|
+
return innerHandler(ctx);
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
const effectiveRenderer = renderer ?? this.config?.renderer ?? this.rootConfig?.renderer;
|
|
1662
|
+
if (effectiveRenderer) {
|
|
1663
|
+
const innerHandler = wrappedHandler;
|
|
1664
|
+
wrappedHandler = async (ctx) => {
|
|
1665
|
+
ctx.renderer = effectiveRenderer;
|
|
1666
|
+
return innerHandler(ctx);
|
|
876
1667
|
};
|
|
877
1668
|
}
|
|
878
|
-
|
|
1669
|
+
let file = "unknown";
|
|
1670
|
+
let line = 0;
|
|
1671
|
+
try {
|
|
1672
|
+
const err = new Error();
|
|
1673
|
+
const stack = err.stack?.split("\n") || [];
|
|
1674
|
+
const callerLine = stack.find(
|
|
1675
|
+
(l) => l.includes(":") && !l.includes("router.ts") && !l.includes("shokupan.ts") && !l.includes("node_modules") && !l.includes("bun:main")
|
|
1676
|
+
);
|
|
1677
|
+
if (callerLine) {
|
|
1678
|
+
const match = callerLine.match(/\((.*):(\d+):(\d+)\)/) || callerLine.match(/at (.*):(\d+):(\d+)/);
|
|
1679
|
+
if (match) {
|
|
1680
|
+
file = match[1];
|
|
1681
|
+
line = parseInt(match[2], 10);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
} catch (e) {
|
|
1685
|
+
}
|
|
1686
|
+
const trackedHandler = wrappedHandler;
|
|
1687
|
+
wrappedHandler = async (ctx) => {
|
|
1688
|
+
if (ctx.app?.applicationConfig.enableMiddlewareTracking) {
|
|
1689
|
+
ctx.handlerStack.push({
|
|
1690
|
+
name: handler.name || "anonymous",
|
|
1691
|
+
file,
|
|
1692
|
+
line
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
return trackedHandler(ctx);
|
|
1696
|
+
};
|
|
1697
|
+
wrappedHandler.originalHandler = trackedHandler.originalHandler || trackedHandler;
|
|
1698
|
+
this[$routes].push({
|
|
879
1699
|
method,
|
|
880
1700
|
path: path2,
|
|
881
1701
|
regex,
|
|
@@ -883,7 +1703,9 @@ class ShokupanRouter {
|
|
|
883
1703
|
handler: wrappedHandler,
|
|
884
1704
|
handlerSpec: spec,
|
|
885
1705
|
group,
|
|
886
|
-
guards: routeGuards.length > 0 ? routeGuards : void 0
|
|
1706
|
+
guards: routeGuards.length > 0 ? routeGuards : void 0,
|
|
1707
|
+
requestTimeout: effectiveTimeout
|
|
1708
|
+
// Save for inspection? Or just relying on closure
|
|
887
1709
|
});
|
|
888
1710
|
return this;
|
|
889
1711
|
}
|
|
@@ -918,7 +1740,35 @@ class ShokupanRouter {
|
|
|
918
1740
|
guard(specOrHandler, handler) {
|
|
919
1741
|
const spec = typeof specOrHandler === "function" ? void 0 : specOrHandler;
|
|
920
1742
|
const guardHandler = typeof specOrHandler === "function" ? specOrHandler : handler;
|
|
921
|
-
|
|
1743
|
+
let file = "unknown";
|
|
1744
|
+
let line = 0;
|
|
1745
|
+
try {
|
|
1746
|
+
const err = new Error();
|
|
1747
|
+
const stack = err.stack?.split("\n") || [];
|
|
1748
|
+
const callerLine = stack.find(
|
|
1749
|
+
(l) => l.includes(":") && !l.includes("router.ts") && !l.includes("shokupan.ts") && !l.includes("node_modules") && !l.includes("bun:main")
|
|
1750
|
+
);
|
|
1751
|
+
if (callerLine) {
|
|
1752
|
+
const match = callerLine.match(/\((.*):(\d+):(\d+)\)/) || callerLine.match(/at (.*):(\d+):(\d+)/);
|
|
1753
|
+
if (match) {
|
|
1754
|
+
file = match[1];
|
|
1755
|
+
line = parseInt(match[2], 10);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
} catch (e) {
|
|
1759
|
+
}
|
|
1760
|
+
const trackedGuard = async (ctx, next) => {
|
|
1761
|
+
if (ctx.app?.applicationConfig.enableMiddlewareTracking) {
|
|
1762
|
+
ctx.handlerStack.push({
|
|
1763
|
+
name: guardHandler.name || "guard",
|
|
1764
|
+
file,
|
|
1765
|
+
line
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
return guardHandler(ctx, next);
|
|
1769
|
+
};
|
|
1770
|
+
trackedGuard.originalHandler = guardHandler.originalHandler || guardHandler;
|
|
1771
|
+
this.currentGuards.push({ handler: trackedGuard, spec });
|
|
922
1772
|
return this;
|
|
923
1773
|
}
|
|
924
1774
|
/**
|
|
@@ -928,133 +1778,12 @@ class ShokupanRouter {
|
|
|
928
1778
|
*/
|
|
929
1779
|
static(uriPath, options) {
|
|
930
1780
|
const config = typeof options === "string" ? { root: options } : options;
|
|
931
|
-
const rootPath = path.resolve(config.root || ".");
|
|
932
1781
|
const prefix = uriPath.startsWith("/") ? uriPath : "/" + uriPath;
|
|
933
1782
|
const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
relative = decodeURIComponent(relative);
|
|
939
|
-
const requestPath = path.join(rootPath, relative);
|
|
940
|
-
if (!requestPath.startsWith(rootPath)) {
|
|
941
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
942
|
-
}
|
|
943
|
-
if (requestPath.includes("\0")) {
|
|
944
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
945
|
-
}
|
|
946
|
-
if (config.hooks?.onRequest) {
|
|
947
|
-
const res = await config.hooks.onRequest(ctx);
|
|
948
|
-
if (res) return res;
|
|
949
|
-
}
|
|
950
|
-
if (config.exclude) {
|
|
951
|
-
for (const pattern2 of config.exclude) {
|
|
952
|
-
if (pattern2 instanceof RegExp) {
|
|
953
|
-
if (pattern2.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
|
|
954
|
-
} else if (typeof pattern2 === "string") {
|
|
955
|
-
if (relative.includes(pattern2)) return ctx.json({ error: "Forbidden" }, 403);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
if (path.basename(requestPath).startsWith(".")) {
|
|
960
|
-
const behavior = config.dotfiles || "ignore";
|
|
961
|
-
if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
|
|
962
|
-
if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
|
|
963
|
-
}
|
|
964
|
-
let finalPath = requestPath;
|
|
965
|
-
let stats;
|
|
966
|
-
try {
|
|
967
|
-
stats = await promises.stat(requestPath);
|
|
968
|
-
} catch (e) {
|
|
969
|
-
if (config.extensions) {
|
|
970
|
-
for (const ext of config.extensions) {
|
|
971
|
-
const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
|
|
972
|
-
try {
|
|
973
|
-
const s = await promises.stat(p);
|
|
974
|
-
if (s.isFile()) {
|
|
975
|
-
finalPath = p;
|
|
976
|
-
stats = s;
|
|
977
|
-
break;
|
|
978
|
-
}
|
|
979
|
-
} catch {
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
if (!stats) return ctx.json({ error: "Not Found" }, 404);
|
|
984
|
-
}
|
|
985
|
-
if (stats.isDirectory()) {
|
|
986
|
-
if (!ctx.path.endsWith("/")) {
|
|
987
|
-
const query = ctx.url.search;
|
|
988
|
-
return ctx.redirect(ctx.path + "/" + query, 302);
|
|
989
|
-
}
|
|
990
|
-
let indexes = [];
|
|
991
|
-
if (config.index === void 0) {
|
|
992
|
-
indexes = ["index.html", "index.htm"];
|
|
993
|
-
} else if (Array.isArray(config.index)) {
|
|
994
|
-
indexes = config.index;
|
|
995
|
-
} else if (config.index) {
|
|
996
|
-
indexes = [config.index];
|
|
997
|
-
}
|
|
998
|
-
let foundIndex = false;
|
|
999
|
-
for (const idx of indexes) {
|
|
1000
|
-
const idxPath = path.join(finalPath, idx);
|
|
1001
|
-
try {
|
|
1002
|
-
const idxStats = await promises.stat(idxPath);
|
|
1003
|
-
if (idxStats.isFile()) {
|
|
1004
|
-
finalPath = idxPath;
|
|
1005
|
-
foundIndex = true;
|
|
1006
|
-
break;
|
|
1007
|
-
}
|
|
1008
|
-
} catch {
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
if (!foundIndex) {
|
|
1012
|
-
if (config.listDirectory) {
|
|
1013
|
-
try {
|
|
1014
|
-
const files = await promises.readdir(requestPath);
|
|
1015
|
-
const listing = eta$1.renderString(`
|
|
1016
|
-
<!DOCTYPE html>
|
|
1017
|
-
<html>
|
|
1018
|
-
<head>
|
|
1019
|
-
<title>Index of <%= it.relative %></title>
|
|
1020
|
-
<style>
|
|
1021
|
-
body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
1022
|
-
ul { list-style: none; padding: 0; }
|
|
1023
|
-
li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
|
1024
|
-
a { text-decoration: none; color: #0066cc; }
|
|
1025
|
-
a:hover { text-decoration: underline; }
|
|
1026
|
-
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
1027
|
-
</style>
|
|
1028
|
-
</head>
|
|
1029
|
-
<body>
|
|
1030
|
-
<h1>Index of <%= it.relative %></h1>
|
|
1031
|
-
<ul>
|
|
1032
|
-
<% if (it.relative !== '/') { %>
|
|
1033
|
-
<li><a href="../">../</a></li>
|
|
1034
|
-
<% } %>
|
|
1035
|
-
<% it.files.forEach(function(f) { %>
|
|
1036
|
-
<li><a href="<%= f %>"><%= f %></a></li>
|
|
1037
|
-
<% }) %>
|
|
1038
|
-
</ul>
|
|
1039
|
-
</body>
|
|
1040
|
-
</html>
|
|
1041
|
-
`, { relative, files, join: path.join });
|
|
1042
|
-
return new Response(listing, { headers: { "Content-Type": "text/html" } });
|
|
1043
|
-
} catch (e) {
|
|
1044
|
-
return ctx.json({ error: "Internal Server Error" }, 500);
|
|
1045
|
-
}
|
|
1046
|
-
} else {
|
|
1047
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
const file = Bun.file(finalPath);
|
|
1052
|
-
let response = new Response(file);
|
|
1053
|
-
if (config.hooks?.onResponse) {
|
|
1054
|
-
const hooked = await config.hooks.onResponse(ctx, response);
|
|
1055
|
-
if (hooked) response = hooked;
|
|
1056
|
-
}
|
|
1057
|
-
return response;
|
|
1783
|
+
serveStatic(null, config, prefix);
|
|
1784
|
+
const routeHandler = async (ctx) => {
|
|
1785
|
+
const runner = serveStatic(ctx, config, prefix);
|
|
1786
|
+
return runner();
|
|
1058
1787
|
};
|
|
1059
1788
|
let groupName = "Static";
|
|
1060
1789
|
const segments = normalizedPrefix.split("/").filter(Boolean);
|
|
@@ -1073,8 +1802,8 @@ class ShokupanRouter {
|
|
|
1073
1802
|
const pattern = `^${normalizedPrefix}(/.*)?$`;
|
|
1074
1803
|
const regex = new RegExp(pattern);
|
|
1075
1804
|
const displayPath = normalizedPrefix === "/" ? "/*" : normalizedPrefix + "/*";
|
|
1076
|
-
this.add({ method: "GET", path: displayPath, handler, spec, regex });
|
|
1077
|
-
this.add({ method: "HEAD", path: displayPath, handler, spec, regex });
|
|
1805
|
+
this.add({ method: "GET", path: displayPath, handler: routeHandler, spec, regex });
|
|
1806
|
+
this.add({ method: "HEAD", path: displayPath, handler: routeHandler, spec, regex });
|
|
1078
1807
|
return this;
|
|
1079
1808
|
}
|
|
1080
1809
|
/**
|
|
@@ -1109,138 +1838,25 @@ class ShokupanRouter {
|
|
|
1109
1838
|
}
|
|
1110
1839
|
/**
|
|
1111
1840
|
* Generates an OpenAPI 3.1 Document by recursing through the router and its descendants.
|
|
1841
|
+
* Now includes runtime analysis of handler functions to infer request/response types.
|
|
1112
1842
|
*/
|
|
1113
1843
|
generateApiSpec(options = {}) {
|
|
1114
|
-
|
|
1115
|
-
const tagGroups = /* @__PURE__ */ new Map();
|
|
1116
|
-
const defaultTagGroup = options.defaultTagGroup || "General";
|
|
1117
|
-
const defaultTagName = options.defaultTag || "Application";
|
|
1118
|
-
const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
|
|
1119
|
-
let group = currentGroup;
|
|
1120
|
-
let tag = defaultTag;
|
|
1121
|
-
if (router.config?.group) {
|
|
1122
|
-
group = router.config.group;
|
|
1123
|
-
}
|
|
1124
|
-
if (router.config?.name) {
|
|
1125
|
-
tag = router.config.name;
|
|
1126
|
-
} else {
|
|
1127
|
-
const mountPath = router[$mountPath];
|
|
1128
|
-
if (mountPath && mountPath !== "/") {
|
|
1129
|
-
const segments = mountPath.split("/").filter(Boolean);
|
|
1130
|
-
if (segments.length > 0) {
|
|
1131
|
-
const lastSegment = segments[segments.length - 1];
|
|
1132
|
-
const humanized = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1133
|
-
tag = humanized;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
if (!tagGroups.has(group)) {
|
|
1138
|
-
tagGroups.set(group, /* @__PURE__ */ new Set());
|
|
1139
|
-
}
|
|
1140
|
-
for (const route of router.routes) {
|
|
1141
|
-
const routeGroup = route.group || group;
|
|
1142
|
-
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1143
|
-
const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
1144
|
-
let fullPath = cleanPrefix + cleanSubPath || "/";
|
|
1145
|
-
fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
1146
|
-
if (!paths[fullPath]) {
|
|
1147
|
-
paths[fullPath] = {};
|
|
1148
|
-
}
|
|
1149
|
-
const operation = {
|
|
1150
|
-
responses: {
|
|
1151
|
-
200: { description: "OK" }
|
|
1152
|
-
}
|
|
1153
|
-
};
|
|
1154
|
-
if (route.keys.length > 0) {
|
|
1155
|
-
operation.parameters = route.keys.map((key) => ({
|
|
1156
|
-
name: key,
|
|
1157
|
-
in: "path",
|
|
1158
|
-
required: true,
|
|
1159
|
-
schema: { type: "string" }
|
|
1160
|
-
}));
|
|
1161
|
-
}
|
|
1162
|
-
if (route.guards) {
|
|
1163
|
-
for (const guard of route.guards) {
|
|
1164
|
-
if (guard.spec) {
|
|
1165
|
-
deepMerge(operation, guard.spec);
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
if (route.handlerSpec) {
|
|
1170
|
-
deepMerge(operation, route.handlerSpec);
|
|
1171
|
-
}
|
|
1172
|
-
if (!operation.tags || operation.tags.length === 0) {
|
|
1173
|
-
operation.tags = [tag];
|
|
1174
|
-
}
|
|
1175
|
-
if (operation.tags) {
|
|
1176
|
-
operation.tags = Array.from(new Set(operation.tags));
|
|
1177
|
-
for (const t of operation.tags) {
|
|
1178
|
-
if (!tagGroups.has(routeGroup)) {
|
|
1179
|
-
tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
|
|
1180
|
-
}
|
|
1181
|
-
tagGroups.get(routeGroup)?.add(t);
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
const methodLower = route.method.toLowerCase();
|
|
1185
|
-
if (methodLower === "all") {
|
|
1186
|
-
["get", "post", "put", "delete", "patch"].forEach((m) => {
|
|
1187
|
-
if (!paths[fullPath][m]) {
|
|
1188
|
-
paths[fullPath][m] = { ...operation };
|
|
1189
|
-
}
|
|
1190
|
-
});
|
|
1191
|
-
} else {
|
|
1192
|
-
paths[fullPath][methodLower] = operation;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
for (const controller of router[$childControllers]) {
|
|
1196
|
-
const mountPath = controller[$mountPath] || "";
|
|
1197
|
-
prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1198
|
-
mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
1199
|
-
const controllerName = controller.constructor.name || "UnknownController";
|
|
1200
|
-
tagGroups.get(group)?.add(controllerName);
|
|
1201
|
-
}
|
|
1202
|
-
for (const child of router[$childRouters]) {
|
|
1203
|
-
const mountPath = child[$mountPath];
|
|
1204
|
-
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1205
|
-
const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
1206
|
-
const nextPrefix = cleanPrefix + cleanMount || "/";
|
|
1207
|
-
collect(child, nextPrefix, group, tag);
|
|
1208
|
-
}
|
|
1209
|
-
};
|
|
1210
|
-
collect(this);
|
|
1211
|
-
const xTagGroups = [];
|
|
1212
|
-
for (const [name, tags] of tagGroups) {
|
|
1213
|
-
xTagGroups.push({
|
|
1214
|
-
name,
|
|
1215
|
-
tags: Array.from(tags).sort()
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
return {
|
|
1219
|
-
openapi: "3.1.0",
|
|
1220
|
-
info: {
|
|
1221
|
-
title: "Shokupan API",
|
|
1222
|
-
version: "1.0.0",
|
|
1223
|
-
...options.info
|
|
1224
|
-
},
|
|
1225
|
-
paths,
|
|
1226
|
-
components: options.components,
|
|
1227
|
-
servers: options.servers,
|
|
1228
|
-
tags: options.tags,
|
|
1229
|
-
externalDocs: options.externalDocs,
|
|
1230
|
-
"x-tagGroups": xTagGroups
|
|
1231
|
-
};
|
|
1844
|
+
return generateOpenApi(this, options);
|
|
1232
1845
|
}
|
|
1233
1846
|
}
|
|
1234
1847
|
const defaults = {
|
|
1235
1848
|
port: 3e3,
|
|
1236
1849
|
hostname: "localhost",
|
|
1237
1850
|
development: process.env.NODE_ENV !== "production",
|
|
1238
|
-
enableAsyncLocalStorage: false
|
|
1851
|
+
enableAsyncLocalStorage: false,
|
|
1852
|
+
reusePort: false
|
|
1239
1853
|
};
|
|
1240
1854
|
api.trace.getTracer("shokupan.application");
|
|
1241
1855
|
class Shokupan extends ShokupanRouter {
|
|
1242
1856
|
applicationConfig = {};
|
|
1857
|
+
openApiSpec;
|
|
1243
1858
|
middleware = [];
|
|
1859
|
+
composedMiddleware;
|
|
1244
1860
|
get logger() {
|
|
1245
1861
|
return this.applicationConfig.logger;
|
|
1246
1862
|
}
|
|
@@ -1251,10 +1867,48 @@ class Shokupan extends ShokupanRouter {
|
|
|
1251
1867
|
Object.assign(this.applicationConfig, defaults, applicationConfig);
|
|
1252
1868
|
}
|
|
1253
1869
|
/**
|
|
1254
|
-
* Adds middleware to the application.
|
|
1870
|
+
* Adds middleware to the application.
|
|
1871
|
+
*/
|
|
1872
|
+
use(middleware) {
|
|
1873
|
+
let trackedMiddleware = middleware;
|
|
1874
|
+
let file = "unknown";
|
|
1875
|
+
let line = 0;
|
|
1876
|
+
try {
|
|
1877
|
+
const err = new Error();
|
|
1878
|
+
const stack = err.stack?.split("\n") || [];
|
|
1879
|
+
const callerLine = stack.find(
|
|
1880
|
+
(l) => l.includes(":") && !l.includes("shokupan.ts") && !l.includes("router.ts") && // In case called from router?
|
|
1881
|
+
!l.includes("node_modules") && !l.includes("bun:main")
|
|
1882
|
+
);
|
|
1883
|
+
if (callerLine) {
|
|
1884
|
+
const match = callerLine.match(/\((.*):(\d+):(\d+)\)/) || callerLine.match(/at (.*):(\d+):(\d+)/);
|
|
1885
|
+
if (match) {
|
|
1886
|
+
file = match[1];
|
|
1887
|
+
line = parseInt(match[2], 10);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
} catch (e) {
|
|
1891
|
+
}
|
|
1892
|
+
trackedMiddleware = async (ctx, next) => {
|
|
1893
|
+
const c = ctx;
|
|
1894
|
+
if (c.handlerStack && c.app?.applicationConfig.enableMiddlewareTracking) {
|
|
1895
|
+
c.handlerStack.push({
|
|
1896
|
+
name: middleware.name || "middleware",
|
|
1897
|
+
file,
|
|
1898
|
+
line
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
return middleware(ctx, next);
|
|
1902
|
+
};
|
|
1903
|
+
this.middleware.push(trackedMiddleware);
|
|
1904
|
+
return this;
|
|
1905
|
+
}
|
|
1906
|
+
startupHooks = [];
|
|
1907
|
+
/**
|
|
1908
|
+
* Registers a callback to be executed before the server starts listening.
|
|
1255
1909
|
*/
|
|
1256
|
-
|
|
1257
|
-
this.
|
|
1910
|
+
onStart(callback) {
|
|
1911
|
+
this.startupHooks.push(callback);
|
|
1258
1912
|
return this;
|
|
1259
1913
|
}
|
|
1260
1914
|
/**
|
|
@@ -1263,18 +1917,46 @@ class Shokupan extends ShokupanRouter {
|
|
|
1263
1917
|
* @param port - The port to listen on. If not specified, the port from the configuration is used. If that is not specified, port 3000 is used.
|
|
1264
1918
|
* @returns The server instance.
|
|
1265
1919
|
*/
|
|
1266
|
-
listen(port) {
|
|
1920
|
+
async listen(port) {
|
|
1267
1921
|
const finalPort = port ?? this.applicationConfig.port ?? 3e3;
|
|
1268
1922
|
if (finalPort < 0 || finalPort > 65535) {
|
|
1269
1923
|
throw new Error("Invalid port number");
|
|
1270
1924
|
}
|
|
1925
|
+
for (const hook of this.startupHooks) {
|
|
1926
|
+
await hook();
|
|
1927
|
+
}
|
|
1928
|
+
if (this.applicationConfig.enableOpenApiGen) {
|
|
1929
|
+
this.openApiSpec = await generateOpenApi(this);
|
|
1930
|
+
}
|
|
1271
1931
|
if (port === 0 && process.platform === "linux") ;
|
|
1272
|
-
const
|
|
1932
|
+
const serveOptions = {
|
|
1273
1933
|
port: finalPort,
|
|
1274
1934
|
hostname: this.applicationConfig.hostname,
|
|
1275
1935
|
development: this.applicationConfig.development,
|
|
1276
|
-
fetch: this.fetch.bind(this)
|
|
1277
|
-
|
|
1936
|
+
fetch: this.fetch.bind(this),
|
|
1937
|
+
reusePort: this.applicationConfig.reusePort,
|
|
1938
|
+
idleTimeout: this.applicationConfig.readTimeout ? this.applicationConfig.readTimeout / 1e3 : void 0,
|
|
1939
|
+
websocket: {
|
|
1940
|
+
open(ws) {
|
|
1941
|
+
ws.data?.handler?.open?.(ws);
|
|
1942
|
+
},
|
|
1943
|
+
message(ws, message) {
|
|
1944
|
+
ws.data?.handler?.message?.(ws, message);
|
|
1945
|
+
},
|
|
1946
|
+
drain(ws) {
|
|
1947
|
+
ws.data?.handler?.drain?.(ws);
|
|
1948
|
+
},
|
|
1949
|
+
close(ws, code, reason) {
|
|
1950
|
+
ws.data?.handler?.close?.(ws, code, reason);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
let factory = this.applicationConfig.serverFactory;
|
|
1955
|
+
if (!factory && typeof Bun === "undefined") {
|
|
1956
|
+
const { createHttpServer } = await Promise.resolve().then(() => require("./server-adapter-BD6oKEto.cjs"));
|
|
1957
|
+
factory = createHttpServer();
|
|
1958
|
+
}
|
|
1959
|
+
const server = factory ? await factory(serveOptions) : Bun.serve(serveOptions);
|
|
1278
1960
|
console.log(`Shokupan server listening on http://${server.hostname}:${server.port}`);
|
|
1279
1961
|
return server;
|
|
1280
1962
|
}
|
|
@@ -1325,65 +2007,122 @@ class Shokupan extends ShokupanRouter {
|
|
|
1325
2007
|
* This logic contains the middleware chain and router dispatch.
|
|
1326
2008
|
*
|
|
1327
2009
|
* @param req - The request to handle.
|
|
2010
|
+
* @param server - The server instance.
|
|
1328
2011
|
* @returns The response to send.
|
|
1329
2012
|
*/
|
|
1330
|
-
async fetch(req) {
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
2013
|
+
async fetch(req, server) {
|
|
2014
|
+
if (this.applicationConfig.enableTracing) {
|
|
2015
|
+
const tracer2 = api.trace.getTracer("shokupan.application");
|
|
2016
|
+
const store = asyncContext.getStore();
|
|
2017
|
+
const attrs = {
|
|
2018
|
+
attributes: {
|
|
2019
|
+
"http.url": req.url,
|
|
2020
|
+
"http.method": req.method
|
|
2021
|
+
}
|
|
2022
|
+
};
|
|
2023
|
+
const parent = store?.get("span");
|
|
2024
|
+
const ctx = parent ? api.trace.setSpan(api.context.active(), parent) : void 0;
|
|
2025
|
+
return tracer2.startActiveSpan(`${req.method} ${new URL(req.url).pathname}`, attrs, ctx, (span) => {
|
|
2026
|
+
const ctxMap = /* @__PURE__ */ new Map();
|
|
2027
|
+
ctxMap.set("span", span);
|
|
2028
|
+
ctxMap.set("request", req);
|
|
2029
|
+
return asyncContext.run(ctxMap, () => this.handleRequest(req, server).finally(() => span.end()));
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
if (this.applicationConfig.enableAsyncLocalStorage) {
|
|
1342
2033
|
const ctxMap = /* @__PURE__ */ new Map();
|
|
1343
|
-
ctxMap.set("span", span);
|
|
1344
2034
|
ctxMap.set("request", req);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
2035
|
+
return asyncContext.run(ctxMap, () => this.handleRequest(req, server));
|
|
2036
|
+
}
|
|
2037
|
+
return this.handleRequest(req, server);
|
|
2038
|
+
}
|
|
2039
|
+
async handleRequest(req, server) {
|
|
2040
|
+
const request = req;
|
|
2041
|
+
const ctx = new ShokupanContext(request, server, void 0, this, this.applicationConfig.enableMiddlewareTracking);
|
|
2042
|
+
const handle = async () => {
|
|
2043
|
+
try {
|
|
2044
|
+
if (this.applicationConfig.hooks?.onRequestStart) {
|
|
2045
|
+
await this.applicationConfig.hooks.onRequestStart(ctx);
|
|
2046
|
+
}
|
|
2047
|
+
const fn = this.composedMiddleware ??= compose(this.middleware);
|
|
2048
|
+
const result = await fn(ctx, async () => {
|
|
2049
|
+
const match = this.find(req.method, ctx.path);
|
|
2050
|
+
if (match) {
|
|
2051
|
+
ctx.params = match.params;
|
|
2052
|
+
return match.handler(ctx);
|
|
2053
|
+
}
|
|
2054
|
+
return null;
|
|
2055
|
+
});
|
|
2056
|
+
let response;
|
|
2057
|
+
if (result instanceof Response) {
|
|
2058
|
+
response = result;
|
|
2059
|
+
} else if ((result === null || result === void 0) && ctx._finalResponse instanceof Response) {
|
|
2060
|
+
response = ctx._finalResponse;
|
|
2061
|
+
} else if ((result === null || result === void 0) && ctx.response.status === 404) {
|
|
2062
|
+
const span = asyncContext.getStore()?.get("span");
|
|
2063
|
+
if (span) span.setAttribute("http.status_code", 404);
|
|
2064
|
+
response = ctx.text("Not Found", 404);
|
|
2065
|
+
} else if (result === null || result === void 0) {
|
|
2066
|
+
if (ctx._finalResponse) response = ctx._finalResponse;
|
|
2067
|
+
else response = ctx.text("Not Found", 404);
|
|
2068
|
+
} else if (typeof result === "object") {
|
|
2069
|
+
response = ctx.json(result);
|
|
2070
|
+
} else {
|
|
2071
|
+
response = ctx.text(String(result));
|
|
2072
|
+
}
|
|
2073
|
+
if (this.applicationConfig.hooks?.onRequestEnd) {
|
|
2074
|
+
await this.applicationConfig.hooks.onRequestEnd(ctx);
|
|
2075
|
+
}
|
|
2076
|
+
if (this.applicationConfig.hooks?.onResponseStart) {
|
|
2077
|
+
await this.applicationConfig.hooks.onResponseStart(ctx, response);
|
|
2078
|
+
}
|
|
2079
|
+
return response;
|
|
2080
|
+
} catch (err) {
|
|
2081
|
+
console.error(err);
|
|
2082
|
+
const span = asyncContext.getStore()?.get("span");
|
|
2083
|
+
if (span) span.setStatus({ code: 2 });
|
|
2084
|
+
const status = err.status || err.statusCode || 500;
|
|
2085
|
+
const body = { error: err.message || "Internal Server Error" };
|
|
2086
|
+
if (err.errors) body.errors = err.errors;
|
|
2087
|
+
if (this.applicationConfig.hooks?.onError) {
|
|
1350
2088
|
try {
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
2089
|
+
await this.applicationConfig.hooks.onError(err, ctx);
|
|
2090
|
+
} catch (hookErr) {
|
|
2091
|
+
console.error("Error in onError hook:", hookErr);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
return ctx.json(body, status);
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
let executionPromise = handle();
|
|
2098
|
+
const timeoutMs = this.applicationConfig.requestTimeout;
|
|
2099
|
+
if (timeoutMs && timeoutMs > 0 && this.applicationConfig.hooks?.onRequestTimeout) {
|
|
2100
|
+
let timeoutId;
|
|
2101
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
2102
|
+
timeoutId = setTimeout(async () => {
|
|
2103
|
+
try {
|
|
2104
|
+
if (this.applicationConfig.hooks?.onRequestTimeout) {
|
|
2105
|
+
await this.applicationConfig.hooks.onRequestTimeout(ctx);
|
|
1368
2106
|
}
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
console.error(err);
|
|
1372
|
-
span.recordException(err);
|
|
1373
|
-
span.setStatus({ code: 2 });
|
|
1374
|
-
const status = err.status || err.statusCode || 500;
|
|
1375
|
-
const body = { error: err.message || "Internal Server Error" };
|
|
1376
|
-
if (err.errors) body.errors = err.errors;
|
|
1377
|
-
return ctx2.json(body, status);
|
|
2107
|
+
} catch (e) {
|
|
2108
|
+
console.error("Error in onRequestTimeout hook:", e);
|
|
1378
2109
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
};
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
2110
|
+
reject(new Error("Request Timeout"));
|
|
2111
|
+
}, timeoutMs);
|
|
2112
|
+
});
|
|
2113
|
+
executionPromise = Promise.race([executionPromise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
|
|
2114
|
+
}
|
|
2115
|
+
return executionPromise.catch((err) => {
|
|
2116
|
+
if (err.message === "Request Timeout") {
|
|
2117
|
+
return ctx.text("Request Timeout", 408);
|
|
2118
|
+
}
|
|
2119
|
+
console.error("Unexpected error in request execution:", err);
|
|
2120
|
+
return ctx.text("Internal Server Error", 500);
|
|
2121
|
+
}).then(async (res) => {
|
|
2122
|
+
if (this.applicationConfig.hooks?.onResponseEnd) {
|
|
2123
|
+
await this.applicationConfig.hooks.onResponseEnd(ctx, res);
|
|
1386
2124
|
}
|
|
2125
|
+
return res;
|
|
1387
2126
|
});
|
|
1388
2127
|
}
|
|
1389
2128
|
}
|
|
@@ -1435,8 +2174,8 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1435
2174
|
init() {
|
|
1436
2175
|
for (const [providerName, providerConfig] of Object.entries(this.authConfig.providers)) {
|
|
1437
2176
|
if (!providerConfig) continue;
|
|
1438
|
-
const
|
|
1439
|
-
if (!
|
|
2177
|
+
const provider = this.getProviderInstance(providerName, providerConfig);
|
|
2178
|
+
if (!provider) {
|
|
1440
2179
|
continue;
|
|
1441
2180
|
}
|
|
1442
2181
|
this.get(`/auth/${providerName}/login`, async (ctx) => {
|
|
@@ -1444,15 +2183,15 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1444
2183
|
const codeVerifier = providerName === "google" || providerName === "microsoft" || providerName === "auth0" || providerName === "okta" ? arctic.generateCodeVerifier() : void 0;
|
|
1445
2184
|
const scopes = providerConfig.scopes || [];
|
|
1446
2185
|
let url;
|
|
1447
|
-
if (
|
|
1448
|
-
url = await
|
|
1449
|
-
} else if (
|
|
1450
|
-
url = await
|
|
1451
|
-
} else if (
|
|
1452
|
-
url = await
|
|
1453
|
-
} else if (
|
|
2186
|
+
if (provider instanceof arctic.GitHub) {
|
|
2187
|
+
url = await provider.createAuthorizationURL(state, scopes);
|
|
2188
|
+
} else if (provider instanceof arctic.Google || provider instanceof arctic.MicrosoftEntraId || provider instanceof arctic.Auth0 || provider instanceof arctic.Okta) {
|
|
2189
|
+
url = await provider.createAuthorizationURL(state, codeVerifier, scopes);
|
|
2190
|
+
} else if (provider instanceof arctic.Apple) {
|
|
2191
|
+
url = await provider.createAuthorizationURL(state, scopes);
|
|
2192
|
+
} else if (provider instanceof arctic.OAuth2Client) {
|
|
1454
2193
|
if (!providerConfig.authUrl) return ctx.text("Config error: authUrl required for oauth2", 500);
|
|
1455
|
-
url = await
|
|
2194
|
+
url = await provider.createAuthorizationURL(providerConfig.authUrl, state, scopes);
|
|
1456
2195
|
} else {
|
|
1457
2196
|
return ctx.text("Provider config error", 500);
|
|
1458
2197
|
}
|
|
@@ -1475,19 +2214,19 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1475
2214
|
try {
|
|
1476
2215
|
let tokens;
|
|
1477
2216
|
let idToken;
|
|
1478
|
-
if (
|
|
1479
|
-
tokens = await
|
|
1480
|
-
} else if (
|
|
2217
|
+
if (provider instanceof arctic.GitHub) {
|
|
2218
|
+
tokens = await provider.validateAuthorizationCode(code);
|
|
2219
|
+
} else if (provider instanceof arctic.Google || provider instanceof arctic.MicrosoftEntraId) {
|
|
1481
2220
|
if (!storedVerifier) return ctx.text("Missing verifier", 400);
|
|
1482
|
-
tokens = await
|
|
1483
|
-
} else if (
|
|
1484
|
-
tokens = await
|
|
1485
|
-
} else if (
|
|
1486
|
-
tokens = await
|
|
2221
|
+
tokens = await provider.validateAuthorizationCode(code, storedVerifier);
|
|
2222
|
+
} else if (provider instanceof arctic.Auth0 || provider instanceof arctic.Okta) {
|
|
2223
|
+
tokens = await provider.validateAuthorizationCode(code, storedVerifier || "");
|
|
2224
|
+
} else if (provider instanceof arctic.Apple) {
|
|
2225
|
+
tokens = await provider.validateAuthorizationCode(code);
|
|
1487
2226
|
idToken = tokens.idToken;
|
|
1488
|
-
} else if (
|
|
2227
|
+
} else if (provider instanceof arctic.OAuth2Client) {
|
|
1489
2228
|
if (!providerConfig.tokenUrl) return ctx.text("Config error: tokenUrl required for oauth2", 500);
|
|
1490
|
-
tokens = await
|
|
2229
|
+
tokens = await provider.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
|
|
1491
2230
|
}
|
|
1492
2231
|
const accessToken = tokens.accessToken || tokens.access_token;
|
|
1493
2232
|
const user = await this.fetchUser(providerName, accessToken, providerConfig, idToken);
|
|
@@ -1504,9 +2243,9 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1504
2243
|
});
|
|
1505
2244
|
}
|
|
1506
2245
|
}
|
|
1507
|
-
async fetchUser(
|
|
1508
|
-
let user = { id: "unknown", provider
|
|
1509
|
-
if (
|
|
2246
|
+
async fetchUser(provider, token, config, idToken) {
|
|
2247
|
+
let user = { id: "unknown", provider };
|
|
2248
|
+
if (provider === "github") {
|
|
1510
2249
|
const res = await fetch("https://api.github.com/user", {
|
|
1511
2250
|
headers: { Authorization: `Bearer ${token}` }
|
|
1512
2251
|
});
|
|
@@ -1516,10 +2255,10 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1516
2255
|
name: data.name || data.login,
|
|
1517
2256
|
email: data.email,
|
|
1518
2257
|
picture: data.avatar_url,
|
|
1519
|
-
provider
|
|
2258
|
+
provider,
|
|
1520
2259
|
raw: data
|
|
1521
2260
|
};
|
|
1522
|
-
} else if (
|
|
2261
|
+
} else if (provider === "google") {
|
|
1523
2262
|
const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
|
|
1524
2263
|
headers: { Authorization: `Bearer ${token}` }
|
|
1525
2264
|
});
|
|
@@ -1529,10 +2268,10 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1529
2268
|
name: data.name,
|
|
1530
2269
|
email: data.email,
|
|
1531
2270
|
picture: data.picture,
|
|
1532
|
-
provider
|
|
2271
|
+
provider,
|
|
1533
2272
|
raw: data
|
|
1534
2273
|
};
|
|
1535
|
-
} else if (
|
|
2274
|
+
} else if (provider === "microsoft") {
|
|
1536
2275
|
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
|
1537
2276
|
headers: { Authorization: `Bearer ${token}` }
|
|
1538
2277
|
});
|
|
@@ -1541,12 +2280,12 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1541
2280
|
id: data.id,
|
|
1542
2281
|
name: data.displayName,
|
|
1543
2282
|
email: data.mail || data.userPrincipalName,
|
|
1544
|
-
provider
|
|
2283
|
+
provider,
|
|
1545
2284
|
raw: data
|
|
1546
2285
|
};
|
|
1547
|
-
} else if (
|
|
2286
|
+
} else if (provider === "auth0" || provider === "okta") {
|
|
1548
2287
|
const domain = config.domain.startsWith("http") ? config.domain : `https://${config.domain}`;
|
|
1549
|
-
const endpoint =
|
|
2288
|
+
const endpoint = provider === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
|
|
1550
2289
|
const res = await fetch(endpoint, {
|
|
1551
2290
|
headers: { Authorization: `Bearer ${token}` }
|
|
1552
2291
|
});
|
|
@@ -1556,20 +2295,20 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1556
2295
|
name: data.name,
|
|
1557
2296
|
email: data.email,
|
|
1558
2297
|
picture: data.picture,
|
|
1559
|
-
provider
|
|
2298
|
+
provider,
|
|
1560
2299
|
raw: data
|
|
1561
2300
|
};
|
|
1562
|
-
} else if (
|
|
2301
|
+
} else if (provider === "apple") {
|
|
1563
2302
|
if (idToken) {
|
|
1564
2303
|
const payload = jose__namespace.decodeJwt(idToken);
|
|
1565
2304
|
user = {
|
|
1566
2305
|
id: payload.sub,
|
|
1567
2306
|
email: payload["email"],
|
|
1568
|
-
provider
|
|
2307
|
+
provider,
|
|
1569
2308
|
raw: payload
|
|
1570
2309
|
};
|
|
1571
2310
|
}
|
|
1572
|
-
} else if (
|
|
2311
|
+
} else if (provider === "oauth2") {
|
|
1573
2312
|
if (config.userInfoUrl) {
|
|
1574
2313
|
const res = await fetch(config.userInfoUrl, {
|
|
1575
2314
|
headers: { Authorization: `Bearer ${token}` }
|
|
@@ -1580,7 +2319,7 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1580
2319
|
name: data.name,
|
|
1581
2320
|
email: data.email,
|
|
1582
2321
|
picture: data.picture,
|
|
1583
|
-
provider
|
|
2322
|
+
provider,
|
|
1584
2323
|
raw: data
|
|
1585
2324
|
};
|
|
1586
2325
|
}
|
|
@@ -1610,15 +2349,19 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1610
2349
|
}
|
|
1611
2350
|
}
|
|
1612
2351
|
function Compression(options = {}) {
|
|
1613
|
-
const threshold = options.threshold ??
|
|
2352
|
+
const threshold = options.threshold ?? 512;
|
|
1614
2353
|
return async (ctx, next) => {
|
|
1615
2354
|
const acceptEncoding = ctx.headers.get("accept-encoding") || "";
|
|
1616
2355
|
let method = null;
|
|
1617
2356
|
if (acceptEncoding.includes("br")) method = "br";
|
|
2357
|
+
else if (acceptEncoding.includes("zstd")) method = "zstd";
|
|
1618
2358
|
else if (acceptEncoding.includes("gzip")) method = "gzip";
|
|
1619
2359
|
else if (acceptEncoding.includes("deflate")) method = "deflate";
|
|
1620
2360
|
if (!method) return next();
|
|
1621
|
-
|
|
2361
|
+
let response = await next();
|
|
2362
|
+
if (!(response instanceof Response) && ctx._finalResponse instanceof Response) {
|
|
2363
|
+
response = ctx._finalResponse;
|
|
2364
|
+
}
|
|
1622
2365
|
if (response instanceof Response) {
|
|
1623
2366
|
if (response.headers.has("Content-Encoding")) return response;
|
|
1624
2367
|
const body = await response.arrayBuffer();
|
|
@@ -1630,97 +2373,506 @@ function Compression(options = {}) {
|
|
|
1630
2373
|
});
|
|
1631
2374
|
}
|
|
1632
2375
|
let compressed;
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
2376
|
+
switch (method) {
|
|
2377
|
+
case "br":
|
|
2378
|
+
const zlib = require("node:zlib");
|
|
2379
|
+
compressed = await new Promise((res, rej) => zlib.brotliCompress(body, {
|
|
2380
|
+
params: {
|
|
2381
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4
|
|
2382
|
+
}
|
|
2383
|
+
}, (err, data) => {
|
|
2384
|
+
if (err) return rej(err);
|
|
2385
|
+
res(data);
|
|
2386
|
+
}));
|
|
2387
|
+
break;
|
|
2388
|
+
case "gzip":
|
|
2389
|
+
compressed = Bun.gzipSync(body);
|
|
2390
|
+
break;
|
|
2391
|
+
case "zstd":
|
|
2392
|
+
compressed = await Bun.zstdCompress(body);
|
|
2393
|
+
break;
|
|
2394
|
+
default:
|
|
2395
|
+
compressed = Bun.deflateSync(body);
|
|
2396
|
+
break;
|
|
1639
2397
|
}
|
|
1640
2398
|
const headers = new Headers(response.headers);
|
|
1641
2399
|
headers.set("Content-Encoding", method);
|
|
1642
2400
|
headers.set("Content-Length", String(compressed.length));
|
|
1643
|
-
headers.delete("Content-Length");
|
|
1644
2401
|
return new Response(compressed, {
|
|
1645
2402
|
status: response.status,
|
|
1646
2403
|
statusText: response.statusText,
|
|
1647
2404
|
headers
|
|
1648
2405
|
});
|
|
1649
2406
|
}
|
|
1650
|
-
return response;
|
|
2407
|
+
return response;
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
function Cors(options = {}) {
|
|
2411
|
+
const defaults2 = {
|
|
2412
|
+
origin: "*",
|
|
2413
|
+
methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
|
|
2414
|
+
preflightContinue: false,
|
|
2415
|
+
optionsSuccessStatus: 204
|
|
2416
|
+
};
|
|
2417
|
+
const opts = { ...defaults2, ...options };
|
|
2418
|
+
return async (ctx, next) => {
|
|
2419
|
+
const headers = new Headers();
|
|
2420
|
+
const origin = ctx.headers.get("origin");
|
|
2421
|
+
const set = (k, v) => headers.set(k, v);
|
|
2422
|
+
const append = (k, v) => headers.append(k, v);
|
|
2423
|
+
if (opts.origin === "*") {
|
|
2424
|
+
set("Access-Control-Allow-Origin", "*");
|
|
2425
|
+
} else if (typeof opts.origin === "string") {
|
|
2426
|
+
set("Access-Control-Allow-Origin", opts.origin);
|
|
2427
|
+
} else if (Array.isArray(opts.origin)) {
|
|
2428
|
+
if (origin && opts.origin.includes(origin)) {
|
|
2429
|
+
set("Access-Control-Allow-Origin", origin);
|
|
2430
|
+
append("Vary", "Origin");
|
|
2431
|
+
}
|
|
2432
|
+
} else if (typeof opts.origin === "function") {
|
|
2433
|
+
const allowed = opts.origin(ctx);
|
|
2434
|
+
if (allowed === true && origin) {
|
|
2435
|
+
set("Access-Control-Allow-Origin", origin);
|
|
2436
|
+
append("Vary", "Origin");
|
|
2437
|
+
} else if (typeof allowed === "string") {
|
|
2438
|
+
set("Access-Control-Allow-Origin", allowed);
|
|
2439
|
+
append("Vary", "Origin");
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (opts.credentials) {
|
|
2443
|
+
set("Access-Control-Allow-Credentials", "true");
|
|
2444
|
+
}
|
|
2445
|
+
if (opts.exposedHeaders) {
|
|
2446
|
+
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(",") : opts.exposedHeaders;
|
|
2447
|
+
if (exposed) set("Access-Control-Expose-Headers", exposed);
|
|
2448
|
+
}
|
|
2449
|
+
if (ctx.method === "OPTIONS") {
|
|
2450
|
+
if (opts.methods) {
|
|
2451
|
+
const methods = Array.isArray(opts.methods) ? opts.methods.join(",") : opts.methods;
|
|
2452
|
+
set("Access-Control-Allow-Methods", methods);
|
|
2453
|
+
}
|
|
2454
|
+
if (opts.allowedHeaders) {
|
|
2455
|
+
const h = Array.isArray(opts.allowedHeaders) ? opts.allowedHeaders.join(",") : opts.allowedHeaders;
|
|
2456
|
+
set("Access-Control-Allow-Headers", h);
|
|
2457
|
+
} else {
|
|
2458
|
+
const reqHeaders = ctx.headers.get("access-control-request-headers");
|
|
2459
|
+
if (reqHeaders) {
|
|
2460
|
+
set("Access-Control-Allow-Headers", reqHeaders);
|
|
2461
|
+
append("Vary", "Access-Control-Request-Headers");
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
if (opts.maxAge) {
|
|
2465
|
+
set("Access-Control-Max-Age", String(opts.maxAge));
|
|
2466
|
+
}
|
|
2467
|
+
return new Response(null, {
|
|
2468
|
+
status: opts.optionsSuccessStatus || 204,
|
|
2469
|
+
headers
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
const response = await next();
|
|
2473
|
+
if (response instanceof Response) {
|
|
2474
|
+
for (const [key, value] of headers.entries()) {
|
|
2475
|
+
response.headers.set(key, value);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
return response;
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
function useExpress(expressMiddleware) {
|
|
2482
|
+
return async (ctx, next) => {
|
|
2483
|
+
return new Promise((resolve, reject) => {
|
|
2484
|
+
const reqStore = {
|
|
2485
|
+
method: ctx.method,
|
|
2486
|
+
url: ctx.url.pathname + ctx.url.search,
|
|
2487
|
+
path: ctx.url.pathname,
|
|
2488
|
+
query: ctx.query,
|
|
2489
|
+
headers: ctx.headers,
|
|
2490
|
+
get: (name) => ctx.headers.get(name)
|
|
2491
|
+
};
|
|
2492
|
+
const req = new Proxy(ctx.request, {
|
|
2493
|
+
get(target, prop) {
|
|
2494
|
+
if (prop in reqStore) return reqStore[prop];
|
|
2495
|
+
const val = target[prop];
|
|
2496
|
+
if (typeof val === "function") return val.bind(target);
|
|
2497
|
+
return val;
|
|
2498
|
+
},
|
|
2499
|
+
set(target, prop, value) {
|
|
2500
|
+
reqStore[prop] = value;
|
|
2501
|
+
ctx.state[prop] = value;
|
|
2502
|
+
return true;
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
const res = {
|
|
2506
|
+
locals: {},
|
|
2507
|
+
statusCode: 200,
|
|
2508
|
+
setHeader: (name, value) => {
|
|
2509
|
+
ctx.response.headers.set(name, value);
|
|
2510
|
+
},
|
|
2511
|
+
set: (name, value) => {
|
|
2512
|
+
ctx.response.headers.set(name, value);
|
|
2513
|
+
},
|
|
2514
|
+
end: (chunk) => {
|
|
2515
|
+
resolve(new Response(chunk, { status: res.statusCode }));
|
|
2516
|
+
},
|
|
2517
|
+
status: (code) => {
|
|
2518
|
+
res.statusCode = code;
|
|
2519
|
+
return res;
|
|
2520
|
+
},
|
|
2521
|
+
send: (body) => {
|
|
2522
|
+
let content = body;
|
|
2523
|
+
if (typeof body === "object") content = JSON.stringify(body);
|
|
2524
|
+
resolve(new Response(content, { status: res.statusCode }));
|
|
2525
|
+
},
|
|
2526
|
+
json: (body) => {
|
|
2527
|
+
resolve(Response.json(body, { status: res.statusCode }));
|
|
2528
|
+
}
|
|
2529
|
+
};
|
|
2530
|
+
try {
|
|
2531
|
+
expressMiddleware(req, res, (err) => {
|
|
2532
|
+
if (err) return reject(err);
|
|
2533
|
+
resolve(next());
|
|
2534
|
+
});
|
|
2535
|
+
} catch (err) {
|
|
2536
|
+
reject(err);
|
|
2537
|
+
}
|
|
2538
|
+
});
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
class ValidationError extends Error {
|
|
2542
|
+
constructor(errors) {
|
|
2543
|
+
super("Validation Error");
|
|
2544
|
+
this.errors = errors;
|
|
2545
|
+
}
|
|
2546
|
+
status = 400;
|
|
2547
|
+
}
|
|
2548
|
+
function isZod(schema) {
|
|
2549
|
+
return typeof schema?.safeParse === "function";
|
|
2550
|
+
}
|
|
2551
|
+
async function validateZod(schema, data) {
|
|
2552
|
+
const result = await schema.safeParseAsync(data);
|
|
2553
|
+
if (!result.success) {
|
|
2554
|
+
throw new ValidationError(result.error.errors);
|
|
2555
|
+
}
|
|
2556
|
+
return result.data;
|
|
2557
|
+
}
|
|
2558
|
+
function isTypeBox(schema) {
|
|
2559
|
+
return typeof schema?.Check === "function" && typeof schema?.Errors === "function";
|
|
2560
|
+
}
|
|
2561
|
+
function validateTypeBox(schema, data) {
|
|
2562
|
+
if (!schema.Check(data)) {
|
|
2563
|
+
throw new ValidationError([...schema.Errors(data)]);
|
|
2564
|
+
}
|
|
2565
|
+
return data;
|
|
2566
|
+
}
|
|
2567
|
+
function isAjv(schema) {
|
|
2568
|
+
return typeof schema === "function" && "errors" in schema;
|
|
2569
|
+
}
|
|
2570
|
+
function validateAjv(schema, data) {
|
|
2571
|
+
const valid = schema(data);
|
|
2572
|
+
if (!valid) {
|
|
2573
|
+
throw new ValidationError(schema.errors);
|
|
2574
|
+
}
|
|
2575
|
+
return data;
|
|
2576
|
+
}
|
|
2577
|
+
const valibot = (schema, parser) => {
|
|
2578
|
+
return {
|
|
2579
|
+
_valibot: true,
|
|
2580
|
+
schema,
|
|
2581
|
+
parser
|
|
2582
|
+
};
|
|
2583
|
+
};
|
|
2584
|
+
function isValibotWrapper(schema) {
|
|
2585
|
+
return schema?._valibot === true;
|
|
2586
|
+
}
|
|
2587
|
+
async function validateValibotWrapper(wrapper, data) {
|
|
2588
|
+
const result = await wrapper.parser(wrapper.schema, data);
|
|
2589
|
+
if (!result.success) {
|
|
2590
|
+
throw new ValidationError(result.issues);
|
|
2591
|
+
}
|
|
2592
|
+
return result.output;
|
|
2593
|
+
}
|
|
2594
|
+
function isClass(schema) {
|
|
2595
|
+
try {
|
|
2596
|
+
if (typeof schema === "function" && /^\s*class\s+/.test(schema.toString())) {
|
|
2597
|
+
return true;
|
|
2598
|
+
}
|
|
2599
|
+
return typeof schema === "function" && schema.prototype && schema.name;
|
|
2600
|
+
} catch {
|
|
2601
|
+
return false;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
async function validateClassValidator(schema, data) {
|
|
2605
|
+
const object = classTransformer.plainToInstance(schema, data);
|
|
2606
|
+
try {
|
|
2607
|
+
await classValidator.validateOrReject(object);
|
|
2608
|
+
return object;
|
|
2609
|
+
} catch (errors) {
|
|
2610
|
+
const formattedErrors = Array.isArray(errors) ? errors.map((err) => ({
|
|
2611
|
+
property: err.property,
|
|
2612
|
+
constraints: err.constraints,
|
|
2613
|
+
children: err.children
|
|
2614
|
+
})) : errors;
|
|
2615
|
+
throw new ValidationError(formattedErrors);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
const safelyGetBody = async (ctx) => {
|
|
2619
|
+
const req = ctx.req;
|
|
2620
|
+
if (req._bodyParsed) {
|
|
2621
|
+
return req._bodyValue;
|
|
2622
|
+
}
|
|
2623
|
+
try {
|
|
2624
|
+
let data;
|
|
2625
|
+
if (typeof req.json === "function") {
|
|
2626
|
+
data = await req.json();
|
|
2627
|
+
} else {
|
|
2628
|
+
data = req.body;
|
|
2629
|
+
if (typeof data === "string") {
|
|
2630
|
+
try {
|
|
2631
|
+
data = JSON.parse(data);
|
|
2632
|
+
} catch {
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
req._bodyParsed = true;
|
|
2637
|
+
req._bodyValue = data;
|
|
2638
|
+
Object.defineProperty(req, "json", {
|
|
2639
|
+
value: async () => req._bodyValue,
|
|
2640
|
+
configurable: true
|
|
2641
|
+
});
|
|
2642
|
+
return data;
|
|
2643
|
+
} catch (e) {
|
|
2644
|
+
return {};
|
|
2645
|
+
}
|
|
2646
|
+
};
|
|
2647
|
+
function validate(config) {
|
|
2648
|
+
return async (ctx, next) => {
|
|
2649
|
+
const dataToValidate = {};
|
|
2650
|
+
if (config.params) dataToValidate.params = ctx.params;
|
|
2651
|
+
let queryObj;
|
|
2652
|
+
if (config.query) {
|
|
2653
|
+
const url = new URL(ctx.req.url);
|
|
2654
|
+
queryObj = Object.fromEntries(url.searchParams.entries());
|
|
2655
|
+
dataToValidate.query = queryObj;
|
|
2656
|
+
}
|
|
2657
|
+
if (config.headers) dataToValidate.headers = Object.fromEntries(ctx.req.headers.entries());
|
|
2658
|
+
let body;
|
|
2659
|
+
if (config.body) {
|
|
2660
|
+
body = await safelyGetBody(ctx);
|
|
2661
|
+
dataToValidate.body = body;
|
|
2662
|
+
}
|
|
2663
|
+
if (ctx.app?.applicationConfig.hooks?.beforeValidate) {
|
|
2664
|
+
await ctx.app.applicationConfig.hooks.beforeValidate(ctx, dataToValidate);
|
|
2665
|
+
}
|
|
2666
|
+
if (config.params) {
|
|
2667
|
+
ctx.params = await runValidation(config.params, ctx.params);
|
|
2668
|
+
}
|
|
2669
|
+
let validQuery;
|
|
2670
|
+
if (config.query && queryObj) {
|
|
2671
|
+
validQuery = await runValidation(config.query, queryObj);
|
|
2672
|
+
}
|
|
2673
|
+
if (config.headers) {
|
|
2674
|
+
const headersObj = Object.fromEntries(ctx.req.headers.entries());
|
|
2675
|
+
await runValidation(config.headers, headersObj);
|
|
2676
|
+
}
|
|
2677
|
+
let validBody;
|
|
2678
|
+
if (config.body) {
|
|
2679
|
+
const b = body ?? await safelyGetBody(ctx);
|
|
2680
|
+
validBody = await runValidation(config.body, b);
|
|
2681
|
+
const req = ctx.req;
|
|
2682
|
+
req._bodyValue = validBody;
|
|
2683
|
+
Object.defineProperty(req, "json", {
|
|
2684
|
+
value: async () => validBody,
|
|
2685
|
+
configurable: true
|
|
2686
|
+
});
|
|
2687
|
+
ctx.body = validBody;
|
|
2688
|
+
}
|
|
2689
|
+
if (ctx.app?.applicationConfig.hooks?.afterValidate) {
|
|
2690
|
+
const validatedData = { ...dataToValidate };
|
|
2691
|
+
if (config.params) validatedData.params = ctx.params;
|
|
2692
|
+
if (config.query) validatedData.query = validQuery;
|
|
2693
|
+
if (config.body) validatedData.body = validBody;
|
|
2694
|
+
await ctx.app.applicationConfig.hooks.afterValidate(ctx, validatedData);
|
|
2695
|
+
}
|
|
2696
|
+
return next();
|
|
1651
2697
|
};
|
|
1652
2698
|
}
|
|
1653
|
-
function
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
}
|
|
1660
|
-
|
|
2699
|
+
async function runValidation(schema, data) {
|
|
2700
|
+
if (isZod(schema)) {
|
|
2701
|
+
return validateZod(schema, data);
|
|
2702
|
+
}
|
|
2703
|
+
if (isTypeBox(schema)) {
|
|
2704
|
+
return validateTypeBox(schema, data);
|
|
2705
|
+
}
|
|
2706
|
+
if (isAjv(schema)) {
|
|
2707
|
+
return validateAjv(schema, data);
|
|
2708
|
+
}
|
|
2709
|
+
if (isValibotWrapper(schema)) {
|
|
2710
|
+
return validateValibotWrapper(schema, data);
|
|
2711
|
+
}
|
|
2712
|
+
if (isClass(schema)) {
|
|
2713
|
+
return validateClassValidator(schema, data);
|
|
2714
|
+
}
|
|
2715
|
+
if (isTypeBox(schema)) {
|
|
2716
|
+
return validateTypeBox(schema, data);
|
|
2717
|
+
}
|
|
2718
|
+
if (isAjv(schema)) {
|
|
2719
|
+
return validateAjv(schema, data);
|
|
2720
|
+
}
|
|
2721
|
+
if (isValibotWrapper(schema)) {
|
|
2722
|
+
return validateValibotWrapper(schema, data);
|
|
2723
|
+
}
|
|
2724
|
+
if (typeof schema === "function") {
|
|
2725
|
+
return schema(data);
|
|
2726
|
+
}
|
|
2727
|
+
throw new Error("Unknown validator type provided. Please use a supported library (Zod, Ajv, TypeBox) or a custom function.");
|
|
2728
|
+
}
|
|
2729
|
+
const ajv = new Ajv({ coerceTypes: true, allErrors: true });
|
|
2730
|
+
addFormats(ajv);
|
|
2731
|
+
const compiledValidators = /* @__PURE__ */ new WeakMap();
|
|
2732
|
+
function openApiValidator() {
|
|
1661
2733
|
return async (ctx, next) => {
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
set(
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
} else
|
|
1676
|
-
const
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
2734
|
+
const app = ctx.app;
|
|
2735
|
+
if (!app || !app.openApiSpec) {
|
|
2736
|
+
return next();
|
|
2737
|
+
}
|
|
2738
|
+
let cache = compiledValidators.get(app);
|
|
2739
|
+
if (!cache) {
|
|
2740
|
+
cache = compileValidators(app.openApiSpec);
|
|
2741
|
+
compiledValidators.set(app, cache);
|
|
2742
|
+
}
|
|
2743
|
+
const method = ctx.req.method.toLowerCase();
|
|
2744
|
+
let matchPath;
|
|
2745
|
+
if (cache.has(ctx.path)) {
|
|
2746
|
+
matchPath = ctx.path;
|
|
2747
|
+
} else {
|
|
2748
|
+
for (const specPath of cache.keys()) {
|
|
2749
|
+
const regexStr = "^" + specPath.replace(/{([^}]+)}/g, "([^/]+)") + "$";
|
|
2750
|
+
const regex = new RegExp(regexStr);
|
|
2751
|
+
const match = regex.exec(ctx.path);
|
|
2752
|
+
if (match) {
|
|
2753
|
+
matchPath = specPath;
|
|
2754
|
+
break;
|
|
2755
|
+
}
|
|
1683
2756
|
}
|
|
1684
2757
|
}
|
|
1685
|
-
if (
|
|
1686
|
-
|
|
2758
|
+
if (!matchPath) {
|
|
2759
|
+
return next();
|
|
1687
2760
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
2761
|
+
const validators = cache.get(matchPath)?.[method];
|
|
2762
|
+
if (!validators) {
|
|
2763
|
+
return next();
|
|
1691
2764
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
2765
|
+
const errors = [];
|
|
2766
|
+
if (validators.body) {
|
|
2767
|
+
let body;
|
|
2768
|
+
try {
|
|
2769
|
+
body = await ctx.req.json().catch(() => ({}));
|
|
2770
|
+
} catch {
|
|
2771
|
+
body = {};
|
|
1696
2772
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
2773
|
+
const valid = validators.body(body);
|
|
2774
|
+
if (!valid && validators.body.errors) {
|
|
2775
|
+
errors.push(...validators.body.errors.map((e) => ({ ...e, location: "body" })));
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
if (validators.query) {
|
|
2779
|
+
const query = Object.fromEntries(new URL(ctx.req.url).searchParams.entries());
|
|
2780
|
+
const valid = validators.query(query);
|
|
2781
|
+
if (!valid && validators.query.errors) {
|
|
2782
|
+
errors.push(...validators.query.errors.map((e) => ({ ...e, location: "query" })));
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (validators.params) {
|
|
2786
|
+
let params = ctx.params;
|
|
2787
|
+
if (Object.keys(params).length === 0 && matchPath) {
|
|
2788
|
+
const paramNames = (matchPath.match(/{([^}]+)}/g) || []).map((s) => s.slice(1, -1));
|
|
2789
|
+
if (paramNames.length > 0) {
|
|
2790
|
+
const regexStr = "^" + matchPath.replace(/{([^}]+)}/g, "([^/]+)") + "$";
|
|
2791
|
+
const regex = new RegExp(regexStr);
|
|
2792
|
+
const match = regex.exec(ctx.path);
|
|
2793
|
+
if (match) {
|
|
2794
|
+
params = {};
|
|
2795
|
+
paramNames.forEach((name, i) => {
|
|
2796
|
+
params[name] = match[i + 1];
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
1705
2799
|
}
|
|
1706
2800
|
}
|
|
1707
|
-
|
|
1708
|
-
|
|
2801
|
+
const valid = validators.params(params);
|
|
2802
|
+
if (!valid && validators.params.errors) {
|
|
2803
|
+
errors.push(...validators.params.errors.map((e) => ({ ...e, location: "path" })));
|
|
1709
2804
|
}
|
|
1710
|
-
return new Response(null, {
|
|
1711
|
-
status: opts.optionsSuccessStatus || 204,
|
|
1712
|
-
headers
|
|
1713
|
-
});
|
|
1714
2805
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
2806
|
+
if (validators.headers) {
|
|
2807
|
+
const headers = Object.fromEntries(ctx.req.headers.entries());
|
|
2808
|
+
const valid = validators.headers(headers);
|
|
2809
|
+
if (!valid && validators.headers.errors) {
|
|
2810
|
+
errors.push(...validators.headers.errors.map((e) => ({ ...e, location: "header" })));
|
|
1719
2811
|
}
|
|
1720
2812
|
}
|
|
1721
|
-
|
|
2813
|
+
if (errors.length > 0) {
|
|
2814
|
+
throw new ValidationError(errors);
|
|
2815
|
+
}
|
|
2816
|
+
return next();
|
|
1722
2817
|
};
|
|
1723
2818
|
}
|
|
2819
|
+
function compileValidators(spec) {
|
|
2820
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2821
|
+
for (const [path2, pathItem] of Object.entries(spec.paths || {})) {
|
|
2822
|
+
const pathValidators = {};
|
|
2823
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
2824
|
+
if (method === "parameters" || method === "summary" || method === "description") continue;
|
|
2825
|
+
const oper = operation;
|
|
2826
|
+
const validators = {};
|
|
2827
|
+
if (oper.requestBody?.content?.["application/json"]?.schema) {
|
|
2828
|
+
validators.body = ajv.compile(oper.requestBody.content["application/json"].schema);
|
|
2829
|
+
}
|
|
2830
|
+
const parameters = [...oper.parameters || [], ...pathItem.parameters || []];
|
|
2831
|
+
const queryProps = {};
|
|
2832
|
+
const pathProps = {};
|
|
2833
|
+
const headerProps = {};
|
|
2834
|
+
const queryRequired = [];
|
|
2835
|
+
const pathRequired = [];
|
|
2836
|
+
const headerRequired = [];
|
|
2837
|
+
for (const param of parameters) {
|
|
2838
|
+
if (param.in === "query") {
|
|
2839
|
+
queryProps[param.name] = param.schema || {};
|
|
2840
|
+
if (param.required) queryRequired.push(param.name);
|
|
2841
|
+
} else if (param.in === "path") {
|
|
2842
|
+
pathProps[param.name] = param.schema || {};
|
|
2843
|
+
pathRequired.push(param.name);
|
|
2844
|
+
} else if (param.in === "header") {
|
|
2845
|
+
headerProps[param.name] = param.schema || {};
|
|
2846
|
+
if (param.required) headerRequired.push(param.name);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
if (Object.keys(queryProps).length > 0) {
|
|
2850
|
+
validators.query = ajv.compile({
|
|
2851
|
+
type: "object",
|
|
2852
|
+
properties: queryProps,
|
|
2853
|
+
required: queryRequired.length > 0 ? queryRequired : void 0
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
if (Object.keys(pathProps).length > 0) {
|
|
2857
|
+
validators.params = ajv.compile({
|
|
2858
|
+
type: "object",
|
|
2859
|
+
properties: pathProps,
|
|
2860
|
+
required: pathRequired.length > 0 ? pathRequired : void 0
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
if (Object.keys(headerProps).length > 0) {
|
|
2864
|
+
validators.headers = ajv.compile({
|
|
2865
|
+
type: "object",
|
|
2866
|
+
properties: headerProps,
|
|
2867
|
+
required: headerRequired.length > 0 ? headerRequired : void 0
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
pathValidators[method] = validators;
|
|
2871
|
+
}
|
|
2872
|
+
cache.set(path2, pathValidators);
|
|
2873
|
+
}
|
|
2874
|
+
return cache;
|
|
2875
|
+
}
|
|
1724
2876
|
function RateLimit(options = {}) {
|
|
1725
2877
|
const windowMs = options.windowMs || 60 * 1e3;
|
|
1726
2878
|
const max = options.max || 5;
|
|
@@ -1740,7 +2892,7 @@ function RateLimit(options = {}) {
|
|
|
1740
2892
|
}
|
|
1741
2893
|
}
|
|
1742
2894
|
}, windowMs);
|
|
1743
|
-
|
|
2895
|
+
interval.unref?.();
|
|
1744
2896
|
return async (ctx, next) => {
|
|
1745
2897
|
if (skip(ctx)) return next();
|
|
1746
2898
|
const key = keyGenerator(ctx);
|
|
@@ -1811,10 +2963,43 @@ class ScalarPlugin extends ShokupanRouter {
|
|
|
1811
2963
|
this.get("/scalar.js", (ctx) => {
|
|
1812
2964
|
return ctx.file(__dirname + "/../../node_modules/@scalar/api-reference/dist/browser/standalone.js");
|
|
1813
2965
|
});
|
|
1814
|
-
this.get("/openapi.json", (ctx) => {
|
|
1815
|
-
|
|
2966
|
+
this.get("/openapi.json", async (ctx) => {
|
|
2967
|
+
let spec;
|
|
2968
|
+
if (this.root.openApiSpec) {
|
|
2969
|
+
try {
|
|
2970
|
+
spec = structuredClone(this.root.openApiSpec);
|
|
2971
|
+
} catch (e) {
|
|
2972
|
+
spec = Object.assign({}, this.root.openApiSpec);
|
|
2973
|
+
}
|
|
2974
|
+
} else {
|
|
2975
|
+
spec = await (this.root || this).generateApiSpec();
|
|
2976
|
+
}
|
|
2977
|
+
if (this.pluginOptions.baseDocument) {
|
|
2978
|
+
deepMerge(spec, this.pluginOptions.baseDocument);
|
|
2979
|
+
}
|
|
2980
|
+
return ctx.json(spec);
|
|
1816
2981
|
});
|
|
1817
2982
|
}
|
|
2983
|
+
// New lifecycle method to be called by router.mount
|
|
2984
|
+
onMount(parent) {
|
|
2985
|
+
if (parent.onStart) {
|
|
2986
|
+
parent.onStart(async () => {
|
|
2987
|
+
if (this.pluginOptions.enableStaticAnalysis) {
|
|
2988
|
+
try {
|
|
2989
|
+
const entrypoint = process.argv[1];
|
|
2990
|
+
console.log(`[ScalarPlugin] Running eager static analysis on entrypoint: ${entrypoint}`);
|
|
2991
|
+
const analyzer = new openapiAnalyzer.OpenAPIAnalyzer(process.cwd(), entrypoint);
|
|
2992
|
+
let staticSpec = await analyzer.analyze();
|
|
2993
|
+
if (!this.pluginOptions.baseDocument) this.pluginOptions.baseDocument = {};
|
|
2994
|
+
deepMerge(this.pluginOptions.baseDocument, staticSpec);
|
|
2995
|
+
console.log("[ScalarPlugin] Static analysis completed successfully.");
|
|
2996
|
+
} catch (err) {
|
|
2997
|
+
console.error("[ScalarPlugin] Failed to run static analysis:", err);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
1818
3003
|
}
|
|
1819
3004
|
function SecurityHeaders(options = {}) {
|
|
1820
3005
|
return async (ctx, next) => {
|
|
@@ -2121,134 +3306,6 @@ function Session(options) {
|
|
|
2121
3306
|
return result;
|
|
2122
3307
|
};
|
|
2123
3308
|
}
|
|
2124
|
-
class ValidationError extends Error {
|
|
2125
|
-
constructor(errors) {
|
|
2126
|
-
super("Validation Error");
|
|
2127
|
-
this.errors = errors;
|
|
2128
|
-
}
|
|
2129
|
-
status = 400;
|
|
2130
|
-
}
|
|
2131
|
-
function isZod(schema) {
|
|
2132
|
-
return typeof schema?.safeParse === "function";
|
|
2133
|
-
}
|
|
2134
|
-
async function validateZod(schema, data) {
|
|
2135
|
-
const result = await schema.safeParseAsync(data);
|
|
2136
|
-
if (!result.success) {
|
|
2137
|
-
throw new ValidationError(result.error.errors);
|
|
2138
|
-
}
|
|
2139
|
-
return result.data;
|
|
2140
|
-
}
|
|
2141
|
-
function isTypeBox(schema) {
|
|
2142
|
-
return typeof schema?.Check === "function" && typeof schema?.Errors === "function";
|
|
2143
|
-
}
|
|
2144
|
-
function validateTypeBox(schema, data) {
|
|
2145
|
-
if (!schema.Check(data)) {
|
|
2146
|
-
throw new ValidationError([...schema.Errors(data)]);
|
|
2147
|
-
}
|
|
2148
|
-
return data;
|
|
2149
|
-
}
|
|
2150
|
-
function isAjv(schema) {
|
|
2151
|
-
return typeof schema === "function" && "errors" in schema;
|
|
2152
|
-
}
|
|
2153
|
-
function validateAjv(schema, data) {
|
|
2154
|
-
const valid = schema(data);
|
|
2155
|
-
if (!valid) {
|
|
2156
|
-
throw new ValidationError(schema.errors);
|
|
2157
|
-
}
|
|
2158
|
-
return data;
|
|
2159
|
-
}
|
|
2160
|
-
const valibot = (schema, parser) => {
|
|
2161
|
-
return {
|
|
2162
|
-
_valibot: true,
|
|
2163
|
-
schema,
|
|
2164
|
-
parser
|
|
2165
|
-
};
|
|
2166
|
-
};
|
|
2167
|
-
function isValibotWrapper(schema) {
|
|
2168
|
-
return schema?._valibot === true;
|
|
2169
|
-
}
|
|
2170
|
-
async function validateValibotWrapper(wrapper, data) {
|
|
2171
|
-
const result = await wrapper.parser(wrapper.schema, data);
|
|
2172
|
-
if (!result.success) {
|
|
2173
|
-
throw new ValidationError(result.issues);
|
|
2174
|
-
}
|
|
2175
|
-
return result.output;
|
|
2176
|
-
}
|
|
2177
|
-
const safelyGetBody = async (ctx) => {
|
|
2178
|
-
const req = ctx.req;
|
|
2179
|
-
if (req._bodyParsed) {
|
|
2180
|
-
return req._bodyValue;
|
|
2181
|
-
}
|
|
2182
|
-
try {
|
|
2183
|
-
let data;
|
|
2184
|
-
if (typeof req.json === "function") {
|
|
2185
|
-
data = await req.json();
|
|
2186
|
-
} else {
|
|
2187
|
-
data = req.body;
|
|
2188
|
-
if (typeof data === "string") {
|
|
2189
|
-
try {
|
|
2190
|
-
data = JSON.parse(data);
|
|
2191
|
-
} catch {
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
req._bodyParsed = true;
|
|
2196
|
-
req._bodyValue = data;
|
|
2197
|
-
Object.defineProperty(req, "json", {
|
|
2198
|
-
value: async () => req._bodyValue,
|
|
2199
|
-
configurable: true
|
|
2200
|
-
});
|
|
2201
|
-
return data;
|
|
2202
|
-
} catch (e) {
|
|
2203
|
-
return {};
|
|
2204
|
-
}
|
|
2205
|
-
};
|
|
2206
|
-
function validate(config) {
|
|
2207
|
-
return async (ctx, next) => {
|
|
2208
|
-
if (config.params) {
|
|
2209
|
-
ctx.params = await runValidation(config.params, ctx.params);
|
|
2210
|
-
}
|
|
2211
|
-
if (config.query) {
|
|
2212
|
-
const url = new URL(ctx.req.url);
|
|
2213
|
-
const queryObj = Object.fromEntries(url.searchParams.entries());
|
|
2214
|
-
await runValidation(config.query, queryObj);
|
|
2215
|
-
}
|
|
2216
|
-
if (config.headers) {
|
|
2217
|
-
const headersObj = Object.fromEntries(ctx.req.headers.entries());
|
|
2218
|
-
await runValidation(config.headers, headersObj);
|
|
2219
|
-
}
|
|
2220
|
-
if (config.body) {
|
|
2221
|
-
const body = await safelyGetBody(ctx);
|
|
2222
|
-
const validBody = await runValidation(config.body, body);
|
|
2223
|
-
const req = ctx.req;
|
|
2224
|
-
req._bodyValue = validBody;
|
|
2225
|
-
Object.defineProperty(req, "json", {
|
|
2226
|
-
value: async () => validBody,
|
|
2227
|
-
configurable: true
|
|
2228
|
-
});
|
|
2229
|
-
ctx.body = validBody;
|
|
2230
|
-
}
|
|
2231
|
-
return next();
|
|
2232
|
-
};
|
|
2233
|
-
}
|
|
2234
|
-
async function runValidation(schema, data) {
|
|
2235
|
-
if (isZod(schema)) {
|
|
2236
|
-
return validateZod(schema, data);
|
|
2237
|
-
}
|
|
2238
|
-
if (isTypeBox(schema)) {
|
|
2239
|
-
return validateTypeBox(schema, data);
|
|
2240
|
-
}
|
|
2241
|
-
if (isAjv(schema)) {
|
|
2242
|
-
return validateAjv(schema, data);
|
|
2243
|
-
}
|
|
2244
|
-
if (isValibotWrapper(schema)) {
|
|
2245
|
-
return validateValibotWrapper(schema, data);
|
|
2246
|
-
}
|
|
2247
|
-
if (typeof schema === "function") {
|
|
2248
|
-
return schema(data);
|
|
2249
|
-
}
|
|
2250
|
-
throw new Error("Unknown validator type provided. Please use a supported library (Zod, Ajv, TypeBox) or a custom function.");
|
|
2251
|
-
}
|
|
2252
3309
|
exports.$appRoot = $appRoot;
|
|
2253
3310
|
exports.$childControllers = $childControllers;
|
|
2254
3311
|
exports.$childRouters = $childRouters;
|
|
@@ -2262,6 +3319,8 @@ exports.$mountPath = $mountPath;
|
|
|
2262
3319
|
exports.$parent = $parent;
|
|
2263
3320
|
exports.$routeArgs = $routeArgs;
|
|
2264
3321
|
exports.$routeMethods = $routeMethods;
|
|
3322
|
+
exports.$routeSpec = $routeSpec;
|
|
3323
|
+
exports.$routes = $routes;
|
|
2265
3324
|
exports.All = All;
|
|
2266
3325
|
exports.AuthPlugin = AuthPlugin;
|
|
2267
3326
|
exports.Body = Body;
|
|
@@ -2297,9 +3356,12 @@ exports.ShokupanContext = ShokupanContext;
|
|
|
2297
3356
|
exports.ShokupanRequest = ShokupanRequest;
|
|
2298
3357
|
exports.ShokupanResponse = ShokupanResponse;
|
|
2299
3358
|
exports.ShokupanRouter = ShokupanRouter;
|
|
3359
|
+
exports.Spec = Spec;
|
|
2300
3360
|
exports.Use = Use;
|
|
2301
3361
|
exports.ValidationError = ValidationError;
|
|
2302
3362
|
exports.compose = compose;
|
|
3363
|
+
exports.openApiValidator = openApiValidator;
|
|
3364
|
+
exports.useExpress = useExpress;
|
|
2303
3365
|
exports.valibot = valibot;
|
|
2304
3366
|
exports.validate = validate;
|
|
2305
3367
|
//# sourceMappingURL=index.cjs.map
|