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