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/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 = new 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.get(key);
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.has(key);
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.url = new URL(request.url);
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
- url;
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.url.pathname;
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
- const h = new Headers(this.response.headers);
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
- new Headers(headers).forEach((v, k) => h.set(k, v));
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
- return new Response(body, { status, headers });
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
- const finalStatus = status ?? this.response.status;
185
- return new Response(JSON.stringify(data), { status: finalStatus, headers: finalHeaders });
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
- const finalStatus = status ?? this.response.status;
194
- return new Response(data, { status: finalStatus, headers: finalHeaders });
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
- return new Response(html, { status: finalStatus, headers: finalHeaders });
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
- return new Response(null, { status, headers });
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
- return new Response(null, { status, headers });
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
- return new Response(Bun.file(path, fileOptions), { status, headers });
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 provider = new NodeTracerProvider({
350
- resource: resourceFromAttributes({
351
- [ATTR_SERVICE_NAME]: "basic-service"
352
- }),
353
- spanProcessors: [
354
- new SimpleSpanProcessor(
355
- new OTLPTraceExporter({
356
- url: "http://localhost:4318/v1/traces"
357
- // Default OTLP port
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
- }, async (span) => {
526
+ const fn = middleware[i];
396
527
  try {
397
- const result = await fn.apply(this, args);
398
- return result;
528
+ return Promise.resolve(fn(context2, () => runner(i + 1)));
399
529
  } catch (err) {
400
- span.recordException(err);
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
- target[key] = target[key].concat(source[key]);
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
- const eta$1 = new Eta();
475
- const RouterRegistry = /* @__PURE__ */ new Map();
476
- const ShokupanApplicationTree = {};
477
- class ShokupanRouter {
478
- constructor(config) {
479
- this.config = config;
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
- // Internal marker to identify Router vs. Application
482
- [$isApplication] = false;
483
- [$isMounted] = false;
484
- [$isRouter] = true;
485
- [$appRoot];
486
- [$mountPath] = "/";
487
- [$parent] = null;
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
- get root() {
494
- return this[$appRoot];
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
- routes = [];
497
- currentGuards = [];
498
- isRouterInstance(target) {
499
- return typeof target === "object" && target !== null && $isRouter in target;
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
- * Mounts a controller instance to a path prefix.
503
- *
504
- * Controller can be a convection router or an arbitrary class.
505
- *
506
- * Routes are derived from method names:
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
- controller[$mountPath] = prefix;
517
- this[$childRouters].push(controller);
518
- controller[$parent] = this;
519
- const setRouterContext = (router) => {
520
- router[$appRoot] = this.root;
521
- router[$childRouters].forEach((child) => setRouterContext(child));
522
- };
523
- setRouterContext(controller);
524
- if (this[$appRoot]) ;
525
- controller[$appRoot] = this.root;
526
- controller[$isMounted] = true;
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
- let instance = controller;
529
- if (typeof controller === "function") {
530
- instance = Container.resolve(controller);
531
- const controllerPath = controller[$controllerPath];
532
- if (controllerPath) {
533
- const p1 = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
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
- instance[$mountPath] = prefix;
540
- this[$childControllers].push(instance);
541
- const controllerMiddleware = (typeof controller === "function" ? controller[$middleware] : instance[$middleware]) || [];
542
- const proto = Object.getPrototypeOf(instance);
543
- const methods = /* @__PURE__ */ new Set();
544
- let current = proto;
545
- while (current && current !== Object.prototype) {
546
- Object.getOwnPropertyNames(current).forEach((name) => methods.add(name));
547
- current = Object.getPrototypeOf(current);
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
- Object.getOwnPropertyNames(instance).forEach((name) => methods.add(name));
550
- const decoratedRoutes = instance[$routeMethods] || proto && proto[$routeMethods];
551
- const decoratedArgs = instance[$routeArgs] || proto && proto[$routeArgs];
552
- const methodMiddlewareMap = instance[$middleware] || proto && proto[$middleware];
553
- let routesAttached = 0;
554
- for (const name of methods) {
555
- if (name === "constructor") continue;
556
- if (["arguments", "caller", "callee"].includes(name)) continue;
557
- const originalHandler = instance[name];
558
- if (typeof originalHandler !== "function") continue;
559
- let method;
560
- let subPath = "";
561
- if (decoratedRoutes && decoratedRoutes.has(name)) {
562
- const config = decoratedRoutes.get(name);
563
- method = config.method;
564
- subPath = config.path;
565
- } else {
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 spec = { tags: [tagName] };
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.routes.map((r) => ({
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
- for (const route of this.routes) {
780
- if (route.method !== "ALL" && route.method !== method) continue;
781
- const match = route.regex.exec(path);
782
- if (match) {
783
- const params = {};
784
- route.keys.forEach((key, index) => {
785
- params[key] = match[index + 1];
786
- });
787
- return { handler: route.handler, params };
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 handler(ctx);
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
- this.routes.push({
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
- this.currentGuards.push({ handler: guardHandler, spec });
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
- const handler = async (ctx) => {
916
- let relative = ctx.path.slice(normalizedPrefix.length);
917
- if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
918
- if (relative.length === 0) relative = "/";
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
- const paths = {};
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
- use(middleware) {
1238
- this.middleware.push(middleware);
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 server = Bun.serve({
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
- const tracer2 = trace.getTracer("shokupan.application");
1313
- const store = asyncContext.getStore();
1314
- const attrs = {
1315
- attributes: {
1316
- "http.url": req.url,
1317
- "http.method": req.method
1318
- }
1319
- };
1320
- const parent = store?.get("span");
1321
- const ctx = parent ? trace.setSpan(context.active(), parent) : void 0;
1322
- return tracer2.startActiveSpan(`${req.method} ${new URL(req.url).pathname}`, attrs, ctx, (span) => {
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
- const runCallback = () => {
1327
- const request = req;
1328
- const handle = async () => {
1329
- const ctx2 = new ShokupanContext(request);
1330
- const fn = compose(this.middleware);
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
- const result = await fn(ctx2, async () => {
1333
- const match = this.find(req.method, ctx2.path);
1334
- if (match) {
1335
- ctx2.params = match.params;
1336
- return match.handler(ctx2);
1337
- }
1338
- return null;
1339
- });
1340
- if (result instanceof Response) {
1341
- return result;
1342
- }
1343
- if (result === null || result === void 0) {
1344
- span.setAttribute("http.status_code", 404);
1345
- return ctx2.text("Not Found", 404);
1346
- }
1347
- if (typeof result === "object") {
1348
- return ctx2.json(result);
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
- return ctx2.text(String(result));
1351
- } catch (err) {
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
- return handle().finally(() => span.end());
1362
- };
1363
- if (this.applicationConfig.enableAsyncLocalStorage) {
1364
- return asyncContext.run(ctxMap, runCallback);
1365
- } else {
1366
- return runCallback();
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 provider2 = this.getProviderInstance(providerName, providerConfig);
1420
- if (!provider2) {
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 (provider2 instanceof GitHub) {
1429
- url = await provider2.createAuthorizationURL(state, scopes);
1430
- } else if (provider2 instanceof Google || provider2 instanceof MicrosoftEntraId || provider2 instanceof Auth0 || provider2 instanceof Okta) {
1431
- url = await provider2.createAuthorizationURL(state, codeVerifier, scopes);
1432
- } else if (provider2 instanceof Apple) {
1433
- url = await provider2.createAuthorizationURL(state, scopes);
1434
- } else if (provider2 instanceof OAuth2Client) {
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 provider2.createAuthorizationURL(providerConfig.authUrl, state, scopes);
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 (provider2 instanceof GitHub) {
1460
- tokens = await provider2.validateAuthorizationCode(code);
1461
- } else if (provider2 instanceof Google || provider2 instanceof MicrosoftEntraId) {
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 provider2.validateAuthorizationCode(code, storedVerifier);
1464
- } else if (provider2 instanceof Auth0 || provider2 instanceof Okta) {
1465
- tokens = await provider2.validateAuthorizationCode(code, storedVerifier || "");
1466
- } else if (provider2 instanceof Apple) {
1467
- tokens = await provider2.validateAuthorizationCode(code);
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 (provider2 instanceof OAuth2Client) {
2208
+ } else if (provider instanceof OAuth2Client) {
1470
2209
  if (!providerConfig.tokenUrl) return ctx.text("Config error: tokenUrl required for oauth2", 500);
1471
- tokens = await provider2.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
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(provider2, token, config, idToken) {
1489
- let user = { id: "unknown", provider: provider2 };
1490
- if (provider2 === "github") {
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: provider2,
2239
+ provider,
1501
2240
  raw: data
1502
2241
  };
1503
- } else if (provider2 === "google") {
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: provider2,
2252
+ provider,
1514
2253
  raw: data
1515
2254
  };
1516
- } else if (provider2 === "microsoft") {
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: provider2,
2264
+ provider,
1526
2265
  raw: data
1527
2266
  };
1528
- } else if (provider2 === "auth0" || provider2 === "okta") {
2267
+ } else if (provider === "auth0" || provider === "okta") {
1529
2268
  const domain = config.domain.startsWith("http") ? config.domain : `https://${config.domain}`;
1530
- const endpoint = provider2 === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
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: provider2,
2279
+ provider,
1541
2280
  raw: data
1542
2281
  };
1543
- } else if (provider2 === "apple") {
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: provider2,
2288
+ provider,
1550
2289
  raw: payload
1551
2290
  };
1552
2291
  }
1553
- } else if (provider2 === "oauth2") {
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: provider2,
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 ?? 1024;
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
- const response = await next();
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
- if (method === "br") {
1615
- compressed = require("node:zlib").brotliCompressSync(body);
1616
- } else if (method === "gzip") {
1617
- compressed = Bun.gzipSync(body);
1618
- } else {
1619
- compressed = Bun.deflateSync(body);
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 Cors(options = {}) {
1635
- const defaults2 = {
1636
- origin: "*",
1637
- methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
1638
- preflightContinue: false,
1639
- optionsSuccessStatus: 204
1640
- };
1641
- const opts = { ...defaults2, ...options };
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 headers = new Headers();
1644
- const origin = ctx.headers.get("origin");
1645
- const set = (k, v) => headers.set(k, v);
1646
- const append = (k, v) => headers.append(k, v);
1647
- if (opts.origin === "*") {
1648
- set("Access-Control-Allow-Origin", "*");
1649
- } else if (typeof opts.origin === "string") {
1650
- set("Access-Control-Allow-Origin", opts.origin);
1651
- } else if (Array.isArray(opts.origin)) {
1652
- if (origin && opts.origin.includes(origin)) {
1653
- set("Access-Control-Allow-Origin", origin);
1654
- append("Vary", "Origin");
1655
- }
1656
- } else if (typeof opts.origin === "function") {
1657
- const allowed = opts.origin(ctx);
1658
- if (allowed === true && origin) {
1659
- set("Access-Control-Allow-Origin", origin);
1660
- append("Vary", "Origin");
1661
- } else if (typeof allowed === "string") {
1662
- set("Access-Control-Allow-Origin", allowed);
1663
- append("Vary", "Origin");
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 (opts.credentials) {
1667
- set("Access-Control-Allow-Credentials", "true");
2739
+ if (!matchPath) {
2740
+ return next();
1668
2741
  }
1669
- if (opts.exposedHeaders) {
1670
- const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(",") : opts.exposedHeaders;
1671
- if (exposed) set("Access-Control-Expose-Headers", exposed);
2742
+ const validators = cache.get(matchPath)?.[method];
2743
+ if (!validators) {
2744
+ return next();
1672
2745
  }
1673
- if (ctx.method === "OPTIONS") {
1674
- if (opts.methods) {
1675
- const methods = Array.isArray(opts.methods) ? opts.methods.join(",") : opts.methods;
1676
- set("Access-Control-Allow-Methods", methods);
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
- if (opts.allowedHeaders) {
1679
- const h = Array.isArray(opts.allowedHeaders) ? opts.allowedHeaders.join(",") : opts.allowedHeaders;
1680
- set("Access-Control-Allow-Headers", h);
1681
- } else {
1682
- const reqHeaders = ctx.headers.get("access-control-request-headers");
1683
- if (reqHeaders) {
1684
- set("Access-Control-Allow-Headers", reqHeaders);
1685
- append("Vary", "Access-Control-Request-Headers");
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
- if (opts.maxAge) {
1689
- set("Access-Control-Max-Age", String(opts.maxAge));
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
- const response = await next();
1697
- if (response instanceof Response) {
1698
- for (const [key, value] of headers.entries()) {
1699
- response.headers.set(key, value);
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
- return response;
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
- if (interval.unref) interval.unref();
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
- return (this.root || this).generateApiSpec();
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
  };