shokupan 0.0.1 → 0.1.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,17 +1,15 @@
1
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
2
  import { Eta } from "eta";
8
3
  import { stat, readdir } from "fs/promises";
9
4
  import { resolve, join, basename } from "path";
10
5
  import { AsyncLocalStorage } from "node:async_hooks";
11
6
  import { OAuth2Client, Okta, Auth0, Apple, MicrosoftEntraId, Google, GitHub, generateState, generateCodeVerifier } from "arctic";
12
7
  import * as jose from "jose";
8
+ import { OpenAPIAnalyzer } from "./openapi-analyzer-cjdGeQ5a.js";
13
9
  import { randomUUID, createHmac } from "crypto";
14
10
  import { EventEmitter } from "events";
11
+ import { plainToInstance } from "class-transformer";
12
+ import { validateOrReject } from "class-validator";
15
13
  class ShokupanResponse {
16
14
  _headers = new Headers();
17
15
  _status = 200;
@@ -67,8 +65,10 @@ class ShokupanResponse {
67
65
  }
68
66
  }
69
67
  class ShokupanContext {
70
- constructor(request, state) {
68
+ constructor(request, server, state, app) {
71
69
  this.request = request;
70
+ this.server = server;
71
+ this.app = app;
72
72
  this.url = new URL(request.url);
73
73
  this.state = state || {};
74
74
  this.response = new ShokupanResponse();
@@ -101,12 +101,55 @@ class ShokupanContext {
101
101
  get query() {
102
102
  return Object.fromEntries(this.url.searchParams);
103
103
  }
104
+ /**
105
+ * Client IP address
106
+ */
107
+ get ip() {
108
+ return this.server?.requestIP(this.request);
109
+ }
110
+ /**
111
+ * Request hostname (e.g. "localhost")
112
+ */
113
+ get hostname() {
114
+ return this.url.hostname;
115
+ }
116
+ /**
117
+ * Request host (e.g. "localhost:3000")
118
+ */
119
+ get host() {
120
+ return this.url.host;
121
+ }
122
+ /**
123
+ * Request protocol (e.g. "http:", "https:")
124
+ */
125
+ get protocol() {
126
+ return this.url.protocol;
127
+ }
128
+ /**
129
+ * Whether request is secure (https)
130
+ */
131
+ get secure() {
132
+ return this.url.protocol === "https:";
133
+ }
134
+ /**
135
+ * Request origin (e.g. "http://localhost:3000")
136
+ */
137
+ get origin() {
138
+ return this.url.origin;
139
+ }
104
140
  /**
105
141
  * Request headers
106
142
  */
107
143
  get headers() {
108
144
  return this.request.headers;
109
145
  }
146
+ /**
147
+ * Get a request header
148
+ * @param name Header name
149
+ */
150
+ get(name) {
151
+ return this.request.headers.get(name);
152
+ }
110
153
  /**
111
154
  * Base response object
112
155
  */
@@ -115,6 +158,8 @@ class ShokupanContext {
115
158
  }
116
159
  /**
117
160
  * Helper to set a header on the response
161
+ * @param key Header key
162
+ * @param value Header value
118
163
  */
119
164
  set(key, value) {
120
165
  this.response.set(key, value);
@@ -226,6 +271,23 @@ class ShokupanContext {
226
271
  const status = responseOptions?.status ?? this.response.status;
227
272
  return new Response(Bun.file(path, fileOptions), { status, headers });
228
273
  }
274
+ /**
275
+ * JSX Rendering Function
276
+ */
277
+ renderer;
278
+ /**
279
+ * Render a JSX element
280
+ * @param element JSX Element
281
+ * @param status HTTP Status
282
+ * @param headers HTTP Headers
283
+ */
284
+ async jsx(element, args, status, headers) {
285
+ if (!this.renderer) {
286
+ throw new Error("No JSX renderer configured");
287
+ }
288
+ const html = await this.renderer(element, args);
289
+ return this.html(html, status, headers);
290
+ }
229
291
  }
230
292
  const $isApplication = /* @__PURE__ */ Symbol.for("Shokupan.app");
231
293
  const $appRoot = /* @__PURE__ */ Symbol.for("Shokupan.app-root");
@@ -240,6 +302,8 @@ const $childRouters = /* @__PURE__ */ Symbol.for("Shokupan.child-routers");
240
302
  const $childControllers = /* @__PURE__ */ Symbol.for("Shokupan.child-controllers");
241
303
  const $mountPath = /* @__PURE__ */ Symbol.for("Shokupan.mount-path");
242
304
  const $dispatch = /* @__PURE__ */ Symbol.for("Shokupan.dispatch");
305
+ const $routes = /* @__PURE__ */ Symbol.for("Shokupan.routes");
306
+ const $routeSpec = /* @__PURE__ */ Symbol.for("Shokupan.routeSpec");
243
307
  const HTTPMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "ALL"];
244
308
  var RouteParamType = /* @__PURE__ */ ((RouteParamType2) => {
245
309
  RouteParamType2["BODY"] = "BODY";
@@ -292,6 +356,14 @@ const Query = createParamDecorator(RouteParamType.QUERY);
292
356
  const Headers$1 = createParamDecorator(RouteParamType.HEADER);
293
357
  const Req = createParamDecorator(RouteParamType.REQUEST);
294
358
  const Ctx = createParamDecorator(RouteParamType.CONTEXT);
359
+ function Spec(spec) {
360
+ return (target, propertyKey, descriptor) => {
361
+ if (!target[$routeSpec]) {
362
+ target[$routeSpec] = /* @__PURE__ */ new Map();
363
+ }
364
+ target[$routeSpec].set(propertyKey, spec);
365
+ };
366
+ }
295
367
  function createMethodDecorator(method) {
296
368
  return (path = "/") => {
297
369
  return (target, propertyKey, descriptor) => {
@@ -346,20 +418,6 @@ function Inject(token) {
346
418
  });
347
419
  };
348
420
  }
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
421
  const tracer = trace.getTracer("shokupan.middleware");
364
422
  function traceMiddleware(fn, name) {
365
423
  const middlewareName = fn.name || "anonymous middleware";
@@ -449,7 +507,6 @@ class ShokupanRequestBase {
449
507
  }
450
508
  }
451
509
  const ShokupanRequest = ShokupanRequestBase;
452
- const asyncContext = new AsyncLocalStorage();
453
510
  function isObject(item) {
454
511
  return item && typeof item === "object" && !Array.isArray(item);
455
512
  }
@@ -463,7 +520,17 @@ function deepMerge(target, ...sources) {
463
520
  deepMerge(target[key], source[key]);
464
521
  } else if (Array.isArray(source[key])) {
465
522
  if (!target[key]) Object.assign(target, { [key]: [] });
466
- target[key] = target[key].concat(source[key]);
523
+ if (key === "tags") {
524
+ target[key] = source[key];
525
+ continue;
526
+ }
527
+ const mergedArray = target[key].concat(source[key]);
528
+ const isPrimitive = (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean";
529
+ if (mergedArray.every(isPrimitive)) {
530
+ target[key] = Array.from(new Set(mergedArray));
531
+ } else {
532
+ target[key] = mergedArray;
533
+ }
467
534
  } else {
468
535
  Object.assign(target, { [key]: source[key] });
469
536
  }
@@ -471,12 +538,583 @@ function deepMerge(target, ...sources) {
471
538
  }
472
539
  return deepMerge(target, ...sources);
473
540
  }
541
+ function analyzeHandler(handler) {
542
+ const handlerSource = handler.toString();
543
+ const inferredSpec = {};
544
+ if (handlerSource.includes("ctx.body") || handlerSource.includes("await ctx.req.json()")) {
545
+ inferredSpec.requestBody = {
546
+ content: { "application/json": { schema: { type: "object" } } }
547
+ };
548
+ }
549
+ const queryParams = /* @__PURE__ */ new Map();
550
+ const queryIntMatch = handlerSource.match(/parseInt\(ctx\.query\.(\w+)\)/g);
551
+ if (queryIntMatch) {
552
+ queryIntMatch.forEach((match) => {
553
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
554
+ if (paramName) queryParams.set(paramName, { type: "integer", format: "int32" });
555
+ });
556
+ }
557
+ const queryFloatMatch = handlerSource.match(/parseFloat\(ctx\.query\.(\w+)\)/g);
558
+ if (queryFloatMatch) {
559
+ queryFloatMatch.forEach((match) => {
560
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
561
+ if (paramName) queryParams.set(paramName, { type: "number", format: "float" });
562
+ });
563
+ }
564
+ const queryNumberMatch = handlerSource.match(/Number\(ctx\.query\.(\w+)\)/g);
565
+ if (queryNumberMatch) {
566
+ queryNumberMatch.forEach((match) => {
567
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
568
+ if (paramName && !queryParams.has(paramName)) {
569
+ queryParams.set(paramName, { type: "number" });
570
+ }
571
+ });
572
+ }
573
+ const queryBoolMatch = handlerSource.match(/(?:Boolean\(ctx\.query\.(\w+)\)|!+ctx\.query\.(\w+))/g);
574
+ if (queryBoolMatch) {
575
+ queryBoolMatch.forEach((match) => {
576
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
577
+ if (paramName && !queryParams.has(paramName)) {
578
+ queryParams.set(paramName, { type: "boolean" });
579
+ }
580
+ });
581
+ }
582
+ const queryMatch = handlerSource.match(/ctx\.query\.(\w+)/g);
583
+ if (queryMatch) {
584
+ queryMatch.forEach((match) => {
585
+ const paramName = match.split(".")[2];
586
+ if (paramName && !queryParams.has(paramName)) {
587
+ queryParams.set(paramName, { type: "string" });
588
+ }
589
+ });
590
+ }
591
+ if (queryParams.size > 0) {
592
+ if (!inferredSpec.parameters) inferredSpec.parameters = [];
593
+ queryParams.forEach((schema, paramName) => {
594
+ inferredSpec.parameters.push({
595
+ name: paramName,
596
+ in: "query",
597
+ schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
598
+ });
599
+ });
600
+ }
601
+ const pathParams = /* @__PURE__ */ new Map();
602
+ const paramIntMatch = handlerSource.match(/parseInt\(ctx\.params\.(\w+)\)/g);
603
+ if (paramIntMatch) {
604
+ paramIntMatch.forEach((match) => {
605
+ const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
606
+ if (paramName) pathParams.set(paramName, { type: "integer", format: "int32" });
607
+ });
608
+ }
609
+ const paramFloatMatch = handlerSource.match(/parseFloat\(ctx\.params\.(\w+)\)/g);
610
+ if (paramFloatMatch) {
611
+ paramFloatMatch.forEach((match) => {
612
+ const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
613
+ if (paramName) pathParams.set(paramName, { type: "number", format: "float" });
614
+ });
615
+ }
616
+ if (pathParams.size > 0) {
617
+ if (!inferredSpec.parameters) inferredSpec.parameters = [];
618
+ pathParams.forEach((schema, paramName) => {
619
+ inferredSpec.parameters.push({
620
+ name: paramName,
621
+ in: "path",
622
+ required: true,
623
+ schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
624
+ });
625
+ });
626
+ }
627
+ const headerMatch = handlerSource.match(/ctx\.get\(['"](\w+)['"]\)/g);
628
+ if (headerMatch) {
629
+ if (!inferredSpec.parameters) inferredSpec.parameters = [];
630
+ headerMatch.forEach((match) => {
631
+ const headerName = match.match(/['"](\w+)['"]/)?.[1];
632
+ if (headerName) {
633
+ inferredSpec.parameters.push({
634
+ name: headerName,
635
+ in: "header",
636
+ schema: { type: "string" }
637
+ });
638
+ }
639
+ });
640
+ }
641
+ const responses = {};
642
+ if (handlerSource.includes("ctx.json(")) {
643
+ responses["200"] = {
644
+ description: "Successful response",
645
+ content: {
646
+ "application/json": { schema: { type: "object" } }
647
+ }
648
+ };
649
+ }
650
+ if (handlerSource.includes("ctx.html(")) {
651
+ responses["200"] = {
652
+ description: "Successful response",
653
+ content: {
654
+ "text/html": { schema: { type: "string" } }
655
+ }
656
+ };
657
+ }
658
+ if (handlerSource.includes("ctx.text(")) {
659
+ responses["200"] = {
660
+ description: "Successful response",
661
+ content: {
662
+ "text/plain": { schema: { type: "string" } }
663
+ }
664
+ };
665
+ }
666
+ if (handlerSource.includes("ctx.file(")) {
667
+ responses["200"] = {
668
+ description: "File download",
669
+ content: {
670
+ "application/octet-stream": { schema: { type: "string", format: "binary" } }
671
+ }
672
+ };
673
+ }
674
+ if (handlerSource.includes("ctx.redirect(")) {
675
+ responses["302"] = {
676
+ description: "Redirect"
677
+ };
678
+ }
679
+ if (!responses["200"] && /return\s+\{/.test(handlerSource)) {
680
+ responses["200"] = {
681
+ description: "Successful response",
682
+ content: {
683
+ "application/json": { schema: { type: "object" } }
684
+ }
685
+ };
686
+ }
687
+ const errorStatusMatch = handlerSource.match(/ctx\.(?:json|text|html)\([^)]+,\s*(\d{3,})\)/g);
688
+ if (errorStatusMatch) {
689
+ errorStatusMatch.forEach((match) => {
690
+ const statusCode = match.match(/,\s*(\d{3,})\)/)?.[1];
691
+ if (statusCode && statusCode !== "200") {
692
+ responses[statusCode] = {
693
+ description: `Error response (${statusCode})`
694
+ };
695
+ }
696
+ });
697
+ }
698
+ if (Object.keys(responses).length > 0) {
699
+ inferredSpec.responses = responses;
700
+ }
701
+ return { inferredSpec };
702
+ }
703
+ async function generateOpenApi(rootRouter, options = {}) {
704
+ const paths = {};
705
+ const tagGroups = /* @__PURE__ */ new Map();
706
+ const defaultTagGroup = options.defaultTagGroup || "General";
707
+ const defaultTagName = options.defaultTag || "Application";
708
+ let astRoutes = [];
709
+ try {
710
+ const { OpenAPIAnalyzer: OpenAPIAnalyzer2 } = await import("./openapi-analyzer-cjdGeQ5a.js");
711
+ const analyzer = new OpenAPIAnalyzer2(process.cwd());
712
+ const { applications } = await analyzer.analyze();
713
+ const appMap = /* @__PURE__ */ new Map();
714
+ applications.forEach((app) => {
715
+ appMap.set(app.name, app);
716
+ if (app.name !== app.className) {
717
+ appMap.set(app.className, app);
718
+ }
719
+ });
720
+ const getExpandedRoutes = (app, prefix = "", seen = /* @__PURE__ */ new Set()) => {
721
+ if (seen.has(app.name)) return [];
722
+ const newSeen = new Set(seen);
723
+ newSeen.add(app.name);
724
+ const expanded = [];
725
+ for (const route of app.routes) {
726
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
727
+ const cleanPath = route.path.startsWith("/") ? route.path : "/" + route.path;
728
+ let joined = cleanPrefix + cleanPath;
729
+ if (joined.length > 1 && joined.endsWith("/")) {
730
+ joined = joined.slice(0, -1);
731
+ }
732
+ expanded.push({
733
+ ...route,
734
+ path: joined || "/"
735
+ });
736
+ }
737
+ if (app.mounted) {
738
+ for (const mount of app.mounted) {
739
+ const targetApp = appMap.get(mount.target);
740
+ if (targetApp) {
741
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
742
+ const mountPrefix = mount.prefix.startsWith("/") ? mount.prefix : "/" + mount.prefix;
743
+ expanded.push(...getExpandedRoutes(targetApp, cleanPrefix + mountPrefix, newSeen));
744
+ }
745
+ }
746
+ }
747
+ return expanded;
748
+ };
749
+ applications.forEach((app) => {
750
+ astRoutes.push(...getExpandedRoutes(app));
751
+ });
752
+ const dedupedRoutes = /* @__PURE__ */ new Map();
753
+ for (const route of astRoutes) {
754
+ const key = `${route.method.toUpperCase()}:${route.path}`;
755
+ let score = 0;
756
+ if (route.responseSchema) score += 10;
757
+ if (route.handlerSource) score += 5;
758
+ if (!dedupedRoutes.has(key) || score > dedupedRoutes.get(key).score) {
759
+ dedupedRoutes.set(key, { route, score });
760
+ }
761
+ }
762
+ astRoutes = Array.from(dedupedRoutes.values()).map((v) => v.route);
763
+ } catch (e) {
764
+ console.warn("OpenAPI AST analysis failed or skipped:", e);
765
+ }
766
+ const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
767
+ let group = currentGroup;
768
+ let tag = defaultTag;
769
+ if (router.config?.group) group = router.config.group;
770
+ if (router.config?.name) {
771
+ tag = router.config.name;
772
+ } else {
773
+ const mountPath = router[$mountPath];
774
+ if (mountPath && mountPath !== "/") {
775
+ const segments = mountPath.split("/").filter(Boolean);
776
+ if (segments.length > 0) {
777
+ const lastSegment = segments[segments.length - 1];
778
+ tag = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
779
+ }
780
+ }
781
+ }
782
+ if (!tagGroups.has(group)) tagGroups.set(group, /* @__PURE__ */ new Set());
783
+ const routes = router[$routes] || [];
784
+ for (const route of routes) {
785
+ const routeGroup = route.group || group;
786
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
787
+ const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
788
+ let fullPath = cleanPrefix + cleanSubPath || "/";
789
+ fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
790
+ if (fullPath.length > 1 && fullPath.endsWith("/")) {
791
+ fullPath = fullPath.slice(0, -1);
792
+ }
793
+ if (!paths[fullPath]) paths[fullPath] = {};
794
+ const operation = {
795
+ responses: { "200": { description: "Successful response" } },
796
+ tags: [tag]
797
+ };
798
+ if (route.guards) {
799
+ for (const guard of route.guards) {
800
+ if (guard.spec) {
801
+ if (guard.spec.security) {
802
+ const existing = operation.security || [];
803
+ for (const req of guard.spec.security) {
804
+ const reqStr = JSON.stringify(req);
805
+ if (!existing.some((e) => JSON.stringify(e) === reqStr)) {
806
+ existing.push(req);
807
+ }
808
+ }
809
+ operation.security = existing;
810
+ }
811
+ if (guard.spec.responses) {
812
+ operation.responses = { ...operation.responses, ...guard.spec.responses };
813
+ }
814
+ }
815
+ }
816
+ }
817
+ let astMatch = astRoutes.find(
818
+ (r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
819
+ );
820
+ if (!astMatch) {
821
+ let runtimeSource = route.handler.toString();
822
+ if (route.handler.originalHandler) {
823
+ runtimeSource = route.handler.originalHandler.toString();
824
+ }
825
+ const runtimeHandlerSrc = runtimeSource.replace(/\s+/g, " ");
826
+ const sameMethodRoutes = astRoutes.filter((r) => r.method.toUpperCase() === route.method.toUpperCase());
827
+ astMatch = sameMethodRoutes.find((r) => {
828
+ const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
829
+ if (!astHandlerSrc || astHandlerSrc.length < 20) return false;
830
+ const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
831
+ return match;
832
+ });
833
+ }
834
+ const potentialMatches = astRoutes.filter(
835
+ (r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
836
+ );
837
+ if (potentialMatches.length > 1) {
838
+ const runtimeHandlerSrc = route.handler.toString().replace(/\s+/g, " ");
839
+ const preciseMatch = potentialMatches.find((r) => {
840
+ const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
841
+ const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
842
+ return match;
843
+ });
844
+ if (preciseMatch) {
845
+ astMatch = preciseMatch;
846
+ }
847
+ }
848
+ if (astMatch) {
849
+ if (astMatch.summary) operation.summary = astMatch.summary;
850
+ if (astMatch.description) operation.description = astMatch.description;
851
+ if (astMatch.tags) operation.tags = astMatch.tags;
852
+ if (astMatch.operationId) operation.operationId = astMatch.operationId;
853
+ if (astMatch.requestTypes?.body) {
854
+ operation.requestBody = {
855
+ content: {
856
+ "application/json": { schema: astMatch.requestTypes.body }
857
+ }
858
+ };
859
+ }
860
+ if (astMatch.responseSchema) {
861
+ operation.responses["200"] = {
862
+ description: "Successful response",
863
+ content: {
864
+ "application/json": { schema: astMatch.responseSchema }
865
+ }
866
+ };
867
+ } else if (astMatch.responseType) {
868
+ const contentType = astMatch.responseType === "string" ? "text/plain" : "application/json";
869
+ operation.responses["200"] = {
870
+ description: "Successful response",
871
+ content: {
872
+ [contentType]: { schema: { type: astMatch.responseType } }
873
+ }
874
+ };
875
+ }
876
+ const params = [];
877
+ if (astMatch.requestTypes?.query) {
878
+ for (const [name, _type] of Object.entries(astMatch.requestTypes.query)) {
879
+ params.push({ name, in: "query", schema: { type: "string" } });
880
+ }
881
+ }
882
+ if (params.length > 0) {
883
+ operation.parameters = params;
884
+ }
885
+ }
886
+ if (route.keys.length > 0) {
887
+ const pathParams = route.keys.map((key) => ({
888
+ name: key,
889
+ in: "path",
890
+ required: true,
891
+ schema: { type: "string" }
892
+ }));
893
+ const existingParams = operation.parameters || [];
894
+ const mergedParams = [...existingParams];
895
+ pathParams.forEach((p) => {
896
+ const idx = mergedParams.findIndex((ep) => ep.in === "path" && ep.name === p.name);
897
+ if (idx >= 0) {
898
+ mergedParams[idx] = deepMerge(mergedParams[idx], p);
899
+ } else {
900
+ mergedParams.push(p);
901
+ }
902
+ });
903
+ operation.parameters = mergedParams;
904
+ }
905
+ const { inferredSpec } = analyzeHandler(route.handler);
906
+ if (inferredSpec) {
907
+ if (inferredSpec.parameters) {
908
+ const existingParams = operation.parameters || [];
909
+ const mergedParams = [...existingParams];
910
+ for (const p of inferredSpec.parameters) {
911
+ const idx = mergedParams.findIndex((ep) => ep.name === p.name && ep.in === p.in);
912
+ if (idx >= 0) {
913
+ mergedParams[idx] = deepMerge(mergedParams[idx], p);
914
+ } else {
915
+ mergedParams.push(p);
916
+ }
917
+ }
918
+ operation.parameters = mergedParams;
919
+ delete inferredSpec.parameters;
920
+ }
921
+ deepMerge(operation, inferredSpec);
922
+ }
923
+ if (route.handlerSpec) {
924
+ const spec = route.handlerSpec;
925
+ if (spec.summary) operation.summary = spec.summary;
926
+ if (spec.description) operation.description = spec.description;
927
+ if (spec.operationId) operation.operationId = spec.operationId;
928
+ if (spec.tags) operation.tags = spec.tags;
929
+ if (spec.security) operation.security = spec.security;
930
+ if (spec.responses) {
931
+ operation.responses = { ...operation.responses, ...spec.responses };
932
+ }
933
+ }
934
+ if (!operation.tags || operation.tags.length === 0) operation.tags = [tag];
935
+ if (operation.tags) {
936
+ operation.tags = Array.from(new Set(operation.tags));
937
+ for (const t of operation.tags) {
938
+ if (!tagGroups.has(routeGroup)) tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
939
+ tagGroups.get(routeGroup)?.add(t);
940
+ }
941
+ }
942
+ const methodLower = route.method.toLowerCase();
943
+ if (methodLower === "all") {
944
+ ["get", "post", "put", "delete", "patch"].forEach((m) => {
945
+ if (!paths[fullPath][m]) paths[fullPath][m] = { ...operation };
946
+ });
947
+ } else {
948
+ paths[fullPath][methodLower] = operation;
949
+ }
950
+ }
951
+ for (const controller of router[$childControllers]) {
952
+ const controllerName = controller.constructor.name || "UnknownController";
953
+ tagGroups.get(group)?.add(controllerName);
954
+ }
955
+ for (const child of router[$childRouters]) {
956
+ const mountPath = child[$mountPath];
957
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
958
+ const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
959
+ const nextPrefix = cleanPrefix + cleanMount || "/";
960
+ collect(child, nextPrefix, group, tag);
961
+ }
962
+ };
963
+ collect(rootRouter);
964
+ const xTagGroups = [];
965
+ for (const [name, tags] of tagGroups) {
966
+ xTagGroups.push({ name, tags: Array.from(tags).sort() });
967
+ }
968
+ return {
969
+ openapi: "3.1.0",
970
+ info: { title: "Shokupan API", version: "1.0.0", ...options.info },
971
+ paths,
972
+ components: options.components,
973
+ servers: options.servers,
974
+ tags: options.tags,
975
+ externalDocs: options.externalDocs,
976
+ "x-tagGroups": xTagGroups
977
+ };
978
+ }
474
979
  const eta$1 = new Eta();
980
+ function serveStatic(ctx, config, prefix) {
981
+ const rootPath = resolve(config.root || ".");
982
+ const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
983
+ return async () => {
984
+ let relative = ctx.path.slice(normalizedPrefix.length);
985
+ if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
986
+ if (relative.length === 0) relative = "/";
987
+ relative = decodeURIComponent(relative);
988
+ const requestPath = join(rootPath, relative);
989
+ if (!requestPath.startsWith(rootPath)) {
990
+ return ctx.json({ error: "Forbidden" }, 403);
991
+ }
992
+ if (requestPath.includes("\0")) {
993
+ return ctx.json({ error: "Forbidden" }, 403);
994
+ }
995
+ if (config.hooks?.onRequest) {
996
+ const res = await config.hooks.onRequest(ctx);
997
+ if (res) return res;
998
+ }
999
+ if (config.exclude) {
1000
+ for (const pattern of config.exclude) {
1001
+ if (pattern instanceof RegExp) {
1002
+ if (pattern.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
1003
+ } else if (typeof pattern === "string") {
1004
+ if (relative.includes(pattern)) return ctx.json({ error: "Forbidden" }, 403);
1005
+ }
1006
+ }
1007
+ }
1008
+ if (basename(requestPath).startsWith(".")) {
1009
+ const behavior = config.dotfiles || "ignore";
1010
+ if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
1011
+ if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
1012
+ }
1013
+ let finalPath = requestPath;
1014
+ let stats;
1015
+ try {
1016
+ stats = await stat(requestPath);
1017
+ } catch (e) {
1018
+ if (config.extensions) {
1019
+ for (const ext of config.extensions) {
1020
+ const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
1021
+ try {
1022
+ const s = await stat(p);
1023
+ if (s.isFile()) {
1024
+ finalPath = p;
1025
+ stats = s;
1026
+ break;
1027
+ }
1028
+ } catch {
1029
+ }
1030
+ }
1031
+ }
1032
+ if (!stats) return ctx.json({ error: "Not Found" }, 404);
1033
+ }
1034
+ if (stats.isDirectory()) {
1035
+ if (!ctx.path.endsWith("/")) {
1036
+ const query = ctx.url.search;
1037
+ return ctx.redirect(ctx.path + "/" + query, 302);
1038
+ }
1039
+ let indexes = [];
1040
+ if (config.index === void 0) {
1041
+ indexes = ["index.html", "index.htm"];
1042
+ } else if (Array.isArray(config.index)) {
1043
+ indexes = config.index;
1044
+ } else if (config.index) {
1045
+ indexes = [config.index];
1046
+ }
1047
+ let foundIndex = false;
1048
+ for (const idx of indexes) {
1049
+ const idxPath = join(finalPath, idx);
1050
+ try {
1051
+ const idxStats = await stat(idxPath);
1052
+ if (idxStats.isFile()) {
1053
+ finalPath = idxPath;
1054
+ foundIndex = true;
1055
+ break;
1056
+ }
1057
+ } catch {
1058
+ }
1059
+ }
1060
+ if (!foundIndex) {
1061
+ if (config.listDirectory) {
1062
+ try {
1063
+ const files = await readdir(requestPath);
1064
+ const listing = eta$1.renderString(`
1065
+ <!DOCTYPE html>
1066
+ <html>
1067
+ <head>
1068
+ <title>Index of <%= it.relative %></title>
1069
+ <style>
1070
+ body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
1071
+ ul { list-style: none; padding: 0; }
1072
+ li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
1073
+ a { text-decoration: none; color: #0066cc; }
1074
+ a:hover { text-decoration: underline; }
1075
+ h1 { font-size: 1.5rem; margin-bottom: 1rem; }
1076
+ </style>
1077
+ </head>
1078
+ <body>
1079
+ <h1>Index of <%= it.relative %></h1>
1080
+ <ul>
1081
+ <% if (it.relative !== '/') { %>
1082
+ <li><a href="../">../</a></li>
1083
+ <% } %>
1084
+ <% it.files.forEach(function(f) { %>
1085
+ <li><a href="<%= f %>"><%= f %></a></li>
1086
+ <% }) %>
1087
+ </ul>
1088
+ </body>
1089
+ </html>
1090
+ `, { relative, files, join });
1091
+ return new Response(listing, { headers: { "Content-Type": "text/html" } });
1092
+ } catch (e) {
1093
+ return ctx.json({ error: "Internal Server Error" }, 500);
1094
+ }
1095
+ } else {
1096
+ return ctx.json({ error: "Forbidden" }, 403);
1097
+ }
1098
+ }
1099
+ }
1100
+ const file = Bun.file(finalPath);
1101
+ let response = new Response(file);
1102
+ if (config.hooks?.onResponse) {
1103
+ const hooked = await config.hooks.onResponse(ctx, response);
1104
+ if (hooked) response = hooked;
1105
+ }
1106
+ return response;
1107
+ };
1108
+ }
1109
+ const asyncContext = new AsyncLocalStorage();
475
1110
  const RouterRegistry = /* @__PURE__ */ new Map();
476
1111
  const ShokupanApplicationTree = {};
477
1112
  class ShokupanRouter {
478
1113
  constructor(config) {
479
1114
  this.config = config;
1115
+ if (config?.requestTimeout) {
1116
+ this.requestTimeout = config.requestTimeout;
1117
+ }
480
1118
  }
481
1119
  // Internal marker to identify Router vs. Application
482
1120
  [$isApplication] = false;
@@ -484,6 +1122,7 @@ class ShokupanRouter {
484
1122
  [$isRouter] = true;
485
1123
  [$appRoot];
486
1124
  [$mountPath] = "/";
1125
+ // Public via Symbol for OpenAPI generator
487
1126
  [$parent] = null;
488
1127
  [$childRouters] = [];
489
1128
  [$childControllers] = [];
@@ -493,7 +1132,8 @@ class ShokupanRouter {
493
1132
  get root() {
494
1133
  return this[$appRoot];
495
1134
  }
496
- routes = [];
1135
+ [$routes] = [];
1136
+ // Public via Symbol for OpenAPI generator
497
1137
  currentGuards = [];
498
1138
  isRouterInstance(target) {
499
1139
  return typeof target === "object" && target !== null && $isRouter in target;
@@ -509,6 +1149,12 @@ class ShokupanRouter {
509
1149
  * - postCreate(ctx) -> POST /prefix/create
510
1150
  */
511
1151
  mount(prefix, controller) {
1152
+ const isRouter = this.isRouterInstance(controller);
1153
+ const isFunction = typeof controller === "function";
1154
+ const controllersOnly = this.config?.controllersOnly ?? this.rootConfig?.controllersOnly ?? false;
1155
+ if (controllersOnly && !isFunction && !isRouter) {
1156
+ throw new Error(`[Shokupan] strict controller check failed: ${controller.constructor.name || typeof controller} is not a class constructor.`);
1157
+ }
512
1158
  if (this.isRouterInstance(controller)) {
513
1159
  if (controller[$isMounted]) {
514
1160
  throw new Error("Router is already mounted");
@@ -535,6 +1181,15 @@ class ShokupanRouter {
535
1181
  prefix = p1 + p2;
536
1182
  if (!prefix) prefix = "/";
537
1183
  }
1184
+ } else {
1185
+ const ctor = instance.constructor;
1186
+ const controllerPath = ctor[$controllerPath];
1187
+ if (controllerPath) {
1188
+ const p1 = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
1189
+ const p2 = controllerPath.startsWith("/") ? controllerPath : "/" + controllerPath;
1190
+ prefix = p1 + p2;
1191
+ if (!prefix) prefix = "/";
1192
+ }
538
1193
  }
539
1194
  instance[$mountPath] = prefix;
540
1195
  this[$childControllers].push(instance);
@@ -652,8 +1307,14 @@ class ShokupanRouter {
652
1307
  return composed(ctx, () => wrappedHandler(ctx));
653
1308
  };
654
1309
  }
1310
+ finalHandler.originalHandler = originalHandler;
1311
+ if (finalHandler !== wrappedHandler) {
1312
+ wrappedHandler.originalHandler = originalHandler;
1313
+ }
655
1314
  const tagName = instance.constructor.name;
656
- const spec = { tags: [tagName] };
1315
+ const decoratedSpecs = instance[$routeSpec] || proto && proto[$routeSpec];
1316
+ const userSpec = decoratedSpecs && decoratedSpecs.get(name);
1317
+ const spec = { tags: [tagName], ...userSpec };
657
1318
  this.add({ method, path: normalizedPath, handler: finalHandler, spec });
658
1319
  }
659
1320
  }
@@ -668,7 +1329,7 @@ class ShokupanRouter {
668
1329
  * Returns all routes attached to this router and its descendants.
669
1330
  */
670
1331
  getRoutes() {
671
- const routes = this.routes.map((r) => ({
1332
+ const routes = this[$routes].map((r) => ({
672
1333
  method: r.method,
673
1334
  path: r.path,
674
1335
  handler: r.handler
@@ -769,6 +1430,30 @@ class ShokupanRouter {
769
1430
  data: result
770
1431
  };
771
1432
  }
1433
+ applyHooks(match) {
1434
+ if (!this.config?.hooks) return match;
1435
+ const hooks = this.config.hooks;
1436
+ const originalHandler = match.handler;
1437
+ match.handler = async (ctx) => {
1438
+ if (hooks.onRequestStart) await hooks.onRequestStart(ctx);
1439
+ try {
1440
+ const result = await originalHandler(ctx);
1441
+ if (hooks.onRequestEnd) await hooks.onRequestEnd(ctx);
1442
+ return result;
1443
+ } catch (err) {
1444
+ if (hooks.onError) {
1445
+ try {
1446
+ await hooks.onError(err, ctx);
1447
+ } catch (e) {
1448
+ console.error("Error in router onError hook:", e);
1449
+ }
1450
+ }
1451
+ throw err;
1452
+ }
1453
+ };
1454
+ match.handler.originalHandler = originalHandler.originalHandler || originalHandler;
1455
+ return match;
1456
+ }
772
1457
  /**
773
1458
  * Find a route matching the given method and path.
774
1459
  * @param method HTTP method
@@ -776,7 +1461,7 @@ class ShokupanRouter {
776
1461
  * @returns Route handler and parameters if found, otherwise null
777
1462
  */
778
1463
  find(method, path) {
779
- for (const route of this.routes) {
1464
+ for (const route of this[$routes]) {
780
1465
  if (route.method !== "ALL" && route.method !== method) continue;
781
1466
  const match = route.regex.exec(path);
782
1467
  if (match) {
@@ -784,7 +1469,7 @@ class ShokupanRouter {
784
1469
  route.keys.forEach((key, index) => {
785
1470
  params[key] = match[index + 1];
786
1471
  });
787
- return { handler: route.handler, params };
1472
+ return this.applyHooks({ handler: route.handler, params });
788
1473
  }
789
1474
  }
790
1475
  for (const child of this[$childRouters]) {
@@ -792,13 +1477,13 @@ class ShokupanRouter {
792
1477
  if (path === prefix || path.startsWith(prefix + "/")) {
793
1478
  const subPath = path.slice(prefix.length) || "/";
794
1479
  const match = child.find(method, subPath);
795
- if (match) return match;
1480
+ if (match) return this.applyHooks(match);
796
1481
  }
797
1482
  if (prefix.endsWith("/")) {
798
1483
  if (path.startsWith(prefix)) {
799
1484
  const subPath = path.slice(prefix.length) || "/";
800
1485
  const match = child.find(method, subPath);
801
- if (match) return match;
1486
+ if (match) return this.applyHooks(match);
802
1487
  }
803
1488
  }
804
1489
  }
@@ -816,6 +1501,7 @@ class ShokupanRouter {
816
1501
  };
817
1502
  }
818
1503
  // --- Functional Routing ---
1504
+ requestTimeout;
819
1505
  /**
820
1506
  * Adds a route to the router.
821
1507
  *
@@ -823,12 +1509,25 @@ class ShokupanRouter {
823
1509
  * @param path - URL path
824
1510
  * @param spec - OpenAPI specification for the route
825
1511
  * @param handler - Route handler function
1512
+ * @param requestTimeout - Timeout for this route in milliseconds
826
1513
  */
827
- add({ method, path, spec, handler, regex: customRegex, group }) {
1514
+ add({ method, path, spec, handler, regex: customRegex, group, requestTimeout, renderer }) {
828
1515
  const { regex, keys } = customRegex ? { regex: customRegex, keys: [] } : this.parsePath(path);
829
1516
  let wrappedHandler = handler;
830
1517
  const routeGuards = [...this.currentGuards];
1518
+ const effectiveTimeout = requestTimeout ?? this.requestTimeout ?? this.rootConfig?.requestTimeout;
1519
+ if (effectiveTimeout !== void 0 && effectiveTimeout > 0) {
1520
+ const originalHandler = wrappedHandler;
1521
+ wrappedHandler = async (ctx) => {
1522
+ if (ctx.server) {
1523
+ ctx.server.timeout(ctx.req, effectiveTimeout / 1e3);
1524
+ }
1525
+ return originalHandler(ctx);
1526
+ };
1527
+ wrappedHandler.originalHandler = originalHandler.originalHandler || originalHandler;
1528
+ }
831
1529
  if (routeGuards.length > 0) {
1530
+ const innerHandler = wrappedHandler;
832
1531
  wrappedHandler = async (ctx) => {
833
1532
  for (const guard of routeGuards) {
834
1533
  let guardPassed = false;
@@ -853,10 +1552,18 @@ class ShokupanRouter {
853
1552
  return ctx.json({ error: "Forbidden" }, 403);
854
1553
  }
855
1554
  }
856
- return handler(ctx);
1555
+ return innerHandler(ctx);
1556
+ };
1557
+ }
1558
+ const effectiveRenderer = renderer ?? this.config?.renderer ?? this.rootConfig?.renderer;
1559
+ if (effectiveRenderer) {
1560
+ const innerHandler = wrappedHandler;
1561
+ wrappedHandler = async (ctx) => {
1562
+ ctx.renderer = effectiveRenderer;
1563
+ return innerHandler(ctx);
857
1564
  };
858
1565
  }
859
- this.routes.push({
1566
+ this[$routes].push({
860
1567
  method,
861
1568
  path,
862
1569
  regex,
@@ -864,7 +1571,9 @@ class ShokupanRouter {
864
1571
  handler: wrappedHandler,
865
1572
  handlerSpec: spec,
866
1573
  group,
867
- guards: routeGuards.length > 0 ? routeGuards : void 0
1574
+ guards: routeGuards.length > 0 ? routeGuards : void 0,
1575
+ requestTimeout: effectiveTimeout
1576
+ // Save for inspection? Or just relying on closure
868
1577
  });
869
1578
  return this;
870
1579
  }
@@ -909,133 +1618,12 @@ class ShokupanRouter {
909
1618
  */
910
1619
  static(uriPath, options) {
911
1620
  const config = typeof options === "string" ? { root: options } : options;
912
- const rootPath = resolve(config.root || ".");
913
1621
  const prefix = uriPath.startsWith("/") ? uriPath : "/" + uriPath;
914
1622
  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;
1623
+ serveStatic(null, config, prefix);
1624
+ const routeHandler = async (ctx) => {
1625
+ const runner = serveStatic(ctx, config, prefix);
1626
+ return runner();
1039
1627
  };
1040
1628
  let groupName = "Static";
1041
1629
  const segments = normalizedPrefix.split("/").filter(Boolean);
@@ -1054,8 +1642,8 @@ class ShokupanRouter {
1054
1642
  const pattern = `^${normalizedPrefix}(/.*)?$`;
1055
1643
  const regex = new RegExp(pattern);
1056
1644
  const displayPath = normalizedPrefix === "/" ? "/*" : normalizedPrefix + "/*";
1057
- this.add({ method: "GET", path: displayPath, handler, spec, regex });
1058
- this.add({ method: "HEAD", path: displayPath, handler, spec, regex });
1645
+ this.add({ method: "GET", path: displayPath, handler: routeHandler, spec, regex });
1646
+ this.add({ method: "HEAD", path: displayPath, handler: routeHandler, spec, regex });
1059
1647
  return this;
1060
1648
  }
1061
1649
  /**
@@ -1090,137 +1678,23 @@ class ShokupanRouter {
1090
1678
  }
1091
1679
  /**
1092
1680
  * Generates an OpenAPI 3.1 Document by recursing through the router and its descendants.
1681
+ * Now includes runtime analysis of handler functions to infer request/response types.
1093
1682
  */
1094
1683
  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
- };
1684
+ return generateOpenApi(this, options);
1213
1685
  }
1214
1686
  }
1215
1687
  const defaults = {
1216
1688
  port: 3e3,
1217
1689
  hostname: "localhost",
1218
1690
  development: process.env.NODE_ENV !== "production",
1219
- enableAsyncLocalStorage: false
1691
+ enableAsyncLocalStorage: false,
1692
+ reusePort: false
1220
1693
  };
1221
1694
  trace.getTracer("shokupan.application");
1222
1695
  class Shokupan extends ShokupanRouter {
1223
1696
  applicationConfig = {};
1697
+ openApiSpec;
1224
1698
  middleware = [];
1225
1699
  get logger() {
1226
1700
  return this.applicationConfig.logger;
@@ -1238,24 +1712,41 @@ class Shokupan extends ShokupanRouter {
1238
1712
  this.middleware.push(middleware);
1239
1713
  return this;
1240
1714
  }
1715
+ startupHooks = [];
1716
+ /**
1717
+ * Registers a callback to be executed before the server starts listening.
1718
+ */
1719
+ onStart(callback) {
1720
+ this.startupHooks.push(callback);
1721
+ return this;
1722
+ }
1241
1723
  /**
1242
1724
  * Starts the application server.
1243
1725
  *
1244
1726
  * @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
1727
  * @returns The server instance.
1246
1728
  */
1247
- listen(port) {
1729
+ async listen(port) {
1248
1730
  const finalPort = port ?? this.applicationConfig.port ?? 3e3;
1249
1731
  if (finalPort < 0 || finalPort > 65535) {
1250
1732
  throw new Error("Invalid port number");
1251
1733
  }
1734
+ for (const hook of this.startupHooks) {
1735
+ await hook();
1736
+ }
1737
+ if (this.applicationConfig.enableOpenApiGen) {
1738
+ this.openApiSpec = await generateOpenApi(this);
1739
+ }
1252
1740
  if (port === 0 && process.platform === "linux") ;
1253
- const server = Bun.serve({
1741
+ const serveOptions = {
1254
1742
  port: finalPort,
1255
1743
  hostname: this.applicationConfig.hostname,
1256
1744
  development: this.applicationConfig.development,
1257
- fetch: this.fetch.bind(this)
1258
- });
1745
+ fetch: this.fetch.bind(this),
1746
+ reusePort: this.applicationConfig.reusePort,
1747
+ idleTimeout: this.applicationConfig.readTimeout ? this.applicationConfig.readTimeout / 1e3 : void 0
1748
+ };
1749
+ const server = this.applicationConfig.serverFactory ? await this.applicationConfig.serverFactory(serveOptions) : Bun.serve(serveOptions);
1259
1750
  console.log(`Shokupan server listening on http://${server.hostname}:${server.port}`);
1260
1751
  return server;
1261
1752
  }
@@ -1306,9 +1797,10 @@ class Shokupan extends ShokupanRouter {
1306
1797
  * This logic contains the middleware chain and router dispatch.
1307
1798
  *
1308
1799
  * @param req - The request to handle.
1800
+ * @param server - The server instance.
1309
1801
  * @returns The response to send.
1310
1802
  */
1311
- async fetch(req) {
1803
+ async fetch(req, server) {
1312
1804
  const tracer2 = trace.getTracer("shokupan.application");
1313
1805
  const store = asyncContext.getStore();
1314
1806
  const attrs = {
@@ -1325,10 +1817,13 @@ class Shokupan extends ShokupanRouter {
1325
1817
  ctxMap.set("request", req);
1326
1818
  const runCallback = () => {
1327
1819
  const request = req;
1820
+ const ctx2 = new ShokupanContext(request, server, void 0, this);
1328
1821
  const handle = async () => {
1329
- const ctx2 = new ShokupanContext(request);
1330
- const fn = compose(this.middleware);
1331
1822
  try {
1823
+ if (this.applicationConfig.hooks?.onRequestStart) {
1824
+ await this.applicationConfig.hooks.onRequestStart(ctx2);
1825
+ }
1826
+ const fn = compose(this.middleware);
1332
1827
  const result = await fn(ctx2, async () => {
1333
1828
  const match = this.find(req.method, ctx2.path);
1334
1829
  if (match) {
@@ -1337,17 +1832,24 @@ class Shokupan extends ShokupanRouter {
1337
1832
  }
1338
1833
  return null;
1339
1834
  });
1835
+ let response;
1340
1836
  if (result instanceof Response) {
1341
- return result;
1342
- }
1343
- if (result === null || result === void 0) {
1837
+ response = result;
1838
+ } else if (result === null || result === void 0) {
1344
1839
  span.setAttribute("http.status_code", 404);
1345
- return ctx2.text("Not Found", 404);
1840
+ response = ctx2.text("Not Found", 404);
1841
+ } else if (typeof result === "object") {
1842
+ response = ctx2.json(result);
1843
+ } else {
1844
+ response = ctx2.text(String(result));
1845
+ }
1846
+ if (this.applicationConfig.hooks?.onRequestEnd) {
1847
+ await this.applicationConfig.hooks.onRequestEnd(ctx2);
1346
1848
  }
1347
- if (typeof result === "object") {
1348
- return ctx2.json(result);
1849
+ if (this.applicationConfig.hooks?.onResponseStart) {
1850
+ await this.applicationConfig.hooks.onResponseStart(ctx2, response);
1349
1851
  }
1350
- return ctx2.text(String(result));
1852
+ return response;
1351
1853
  } catch (err) {
1352
1854
  console.error(err);
1353
1855
  span.recordException(err);
@@ -1355,10 +1857,46 @@ class Shokupan extends ShokupanRouter {
1355
1857
  const status = err.status || err.statusCode || 500;
1356
1858
  const body = { error: err.message || "Internal Server Error" };
1357
1859
  if (err.errors) body.errors = err.errors;
1860
+ if (this.applicationConfig.hooks?.onError) {
1861
+ try {
1862
+ await this.applicationConfig.hooks.onError(err, ctx2);
1863
+ } catch (hookErr) {
1864
+ console.error("Error in onError hook:", hookErr);
1865
+ }
1866
+ }
1358
1867
  return ctx2.json(body, status);
1359
1868
  }
1360
1869
  };
1361
- return handle().finally(() => span.end());
1870
+ let executionPromise = handle();
1871
+ const timeoutMs = this.applicationConfig.requestTimeout;
1872
+ if (timeoutMs && timeoutMs > 0 && this.applicationConfig.hooks?.onRequestTimeout) {
1873
+ let timeoutId;
1874
+ const timeoutPromise = new Promise((_, reject) => {
1875
+ timeoutId = setTimeout(async () => {
1876
+ try {
1877
+ if (this.applicationConfig.hooks?.onRequestTimeout) {
1878
+ await this.applicationConfig.hooks.onRequestTimeout(ctx2);
1879
+ }
1880
+ } catch (e) {
1881
+ console.error("Error in onRequestTimeout hook:", e);
1882
+ }
1883
+ reject(new Error("Request Timeout"));
1884
+ }, timeoutMs);
1885
+ });
1886
+ executionPromise = Promise.race([executionPromise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
1887
+ }
1888
+ return executionPromise.catch((err) => {
1889
+ if (err.message === "Request Timeout") {
1890
+ return ctx2.text("Request Timeout", 408);
1891
+ }
1892
+ console.error("Unexpected error in request execution:", err);
1893
+ return ctx2.text("Internal Server Error", 500);
1894
+ }).then(async (res) => {
1895
+ if (this.applicationConfig.hooks?.onResponseEnd) {
1896
+ await this.applicationConfig.hooks.onResponseEnd(ctx2, res);
1897
+ }
1898
+ return res;
1899
+ }).finally(() => span.end());
1362
1900
  };
1363
1901
  if (this.applicationConfig.enableAsyncLocalStorage) {
1364
1902
  return asyncContext.run(ctxMap, runCallback);
@@ -1416,8 +1954,8 @@ class AuthPlugin extends ShokupanRouter {
1416
1954
  init() {
1417
1955
  for (const [providerName, providerConfig] of Object.entries(this.authConfig.providers)) {
1418
1956
  if (!providerConfig) continue;
1419
- const provider2 = this.getProviderInstance(providerName, providerConfig);
1420
- if (!provider2) {
1957
+ const provider = this.getProviderInstance(providerName, providerConfig);
1958
+ if (!provider) {
1421
1959
  continue;
1422
1960
  }
1423
1961
  this.get(`/auth/${providerName}/login`, async (ctx) => {
@@ -1425,15 +1963,15 @@ class AuthPlugin extends ShokupanRouter {
1425
1963
  const codeVerifier = providerName === "google" || providerName === "microsoft" || providerName === "auth0" || providerName === "okta" ? generateCodeVerifier() : void 0;
1426
1964
  const scopes = providerConfig.scopes || [];
1427
1965
  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) {
1966
+ if (provider instanceof GitHub) {
1967
+ url = await provider.createAuthorizationURL(state, scopes);
1968
+ } else if (provider instanceof Google || provider instanceof MicrosoftEntraId || provider instanceof Auth0 || provider instanceof Okta) {
1969
+ url = await provider.createAuthorizationURL(state, codeVerifier, scopes);
1970
+ } else if (provider instanceof Apple) {
1971
+ url = await provider.createAuthorizationURL(state, scopes);
1972
+ } else if (provider instanceof OAuth2Client) {
1435
1973
  if (!providerConfig.authUrl) return ctx.text("Config error: authUrl required for oauth2", 500);
1436
- url = await provider2.createAuthorizationURL(providerConfig.authUrl, state, scopes);
1974
+ url = await provider.createAuthorizationURL(providerConfig.authUrl, state, scopes);
1437
1975
  } else {
1438
1976
  return ctx.text("Provider config error", 500);
1439
1977
  }
@@ -1456,19 +1994,19 @@ class AuthPlugin extends ShokupanRouter {
1456
1994
  try {
1457
1995
  let tokens;
1458
1996
  let idToken;
1459
- if (provider2 instanceof GitHub) {
1460
- tokens = await provider2.validateAuthorizationCode(code);
1461
- } else if (provider2 instanceof Google || provider2 instanceof MicrosoftEntraId) {
1997
+ if (provider instanceof GitHub) {
1998
+ tokens = await provider.validateAuthorizationCode(code);
1999
+ } else if (provider instanceof Google || provider instanceof MicrosoftEntraId) {
1462
2000
  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);
2001
+ tokens = await provider.validateAuthorizationCode(code, storedVerifier);
2002
+ } else if (provider instanceof Auth0 || provider instanceof Okta) {
2003
+ tokens = await provider.validateAuthorizationCode(code, storedVerifier || "");
2004
+ } else if (provider instanceof Apple) {
2005
+ tokens = await provider.validateAuthorizationCode(code);
1468
2006
  idToken = tokens.idToken;
1469
- } else if (provider2 instanceof OAuth2Client) {
2007
+ } else if (provider instanceof OAuth2Client) {
1470
2008
  if (!providerConfig.tokenUrl) return ctx.text("Config error: tokenUrl required for oauth2", 500);
1471
- tokens = await provider2.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
2009
+ tokens = await provider.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
1472
2010
  }
1473
2011
  const accessToken = tokens.accessToken || tokens.access_token;
1474
2012
  const user = await this.fetchUser(providerName, accessToken, providerConfig, idToken);
@@ -1485,9 +2023,9 @@ class AuthPlugin extends ShokupanRouter {
1485
2023
  });
1486
2024
  }
1487
2025
  }
1488
- async fetchUser(provider2, token, config, idToken) {
1489
- let user = { id: "unknown", provider: provider2 };
1490
- if (provider2 === "github") {
2026
+ async fetchUser(provider, token, config, idToken) {
2027
+ let user = { id: "unknown", provider };
2028
+ if (provider === "github") {
1491
2029
  const res = await fetch("https://api.github.com/user", {
1492
2030
  headers: { Authorization: `Bearer ${token}` }
1493
2031
  });
@@ -1497,10 +2035,10 @@ class AuthPlugin extends ShokupanRouter {
1497
2035
  name: data.name || data.login,
1498
2036
  email: data.email,
1499
2037
  picture: data.avatar_url,
1500
- provider: provider2,
2038
+ provider,
1501
2039
  raw: data
1502
2040
  };
1503
- } else if (provider2 === "google") {
2041
+ } else if (provider === "google") {
1504
2042
  const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
1505
2043
  headers: { Authorization: `Bearer ${token}` }
1506
2044
  });
@@ -1510,10 +2048,10 @@ class AuthPlugin extends ShokupanRouter {
1510
2048
  name: data.name,
1511
2049
  email: data.email,
1512
2050
  picture: data.picture,
1513
- provider: provider2,
2051
+ provider,
1514
2052
  raw: data
1515
2053
  };
1516
- } else if (provider2 === "microsoft") {
2054
+ } else if (provider === "microsoft") {
1517
2055
  const res = await fetch("https://graph.microsoft.com/v1.0/me", {
1518
2056
  headers: { Authorization: `Bearer ${token}` }
1519
2057
  });
@@ -1522,12 +2060,12 @@ class AuthPlugin extends ShokupanRouter {
1522
2060
  id: data.id,
1523
2061
  name: data.displayName,
1524
2062
  email: data.mail || data.userPrincipalName,
1525
- provider: provider2,
2063
+ provider,
1526
2064
  raw: data
1527
2065
  };
1528
- } else if (provider2 === "auth0" || provider2 === "okta") {
2066
+ } else if (provider === "auth0" || provider === "okta") {
1529
2067
  const domain = config.domain.startsWith("http") ? config.domain : `https://${config.domain}`;
1530
- const endpoint = provider2 === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
2068
+ const endpoint = provider === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
1531
2069
  const res = await fetch(endpoint, {
1532
2070
  headers: { Authorization: `Bearer ${token}` }
1533
2071
  });
@@ -1537,20 +2075,20 @@ class AuthPlugin extends ShokupanRouter {
1537
2075
  name: data.name,
1538
2076
  email: data.email,
1539
2077
  picture: data.picture,
1540
- provider: provider2,
2078
+ provider,
1541
2079
  raw: data
1542
2080
  };
1543
- } else if (provider2 === "apple") {
2081
+ } else if (provider === "apple") {
1544
2082
  if (idToken) {
1545
2083
  const payload = jose.decodeJwt(idToken);
1546
2084
  user = {
1547
2085
  id: payload.sub,
1548
2086
  email: payload["email"],
1549
- provider: provider2,
2087
+ provider,
1550
2088
  raw: payload
1551
2089
  };
1552
2090
  }
1553
- } else if (provider2 === "oauth2") {
2091
+ } else if (provider === "oauth2") {
1554
2092
  if (config.userInfoUrl) {
1555
2093
  const res = await fetch(config.userInfoUrl, {
1556
2094
  headers: { Authorization: `Bearer ${token}` }
@@ -1561,7 +2099,7 @@ class AuthPlugin extends ShokupanRouter {
1561
2099
  name: data.name,
1562
2100
  email: data.email,
1563
2101
  picture: data.picture,
1564
- provider: provider2,
2102
+ provider,
1565
2103
  raw: data
1566
2104
  };
1567
2105
  }
@@ -1702,6 +2240,66 @@ function Cors(options = {}) {
1702
2240
  return response;
1703
2241
  };
1704
2242
  }
2243
+ function useExpress(expressMiddleware) {
2244
+ return async (ctx, next) => {
2245
+ return new Promise((resolve2, reject) => {
2246
+ const reqStore = {
2247
+ method: ctx.method,
2248
+ url: ctx.url.pathname + ctx.url.search,
2249
+ path: ctx.url.pathname,
2250
+ query: ctx.query,
2251
+ headers: ctx.headers,
2252
+ get: (name) => ctx.headers.get(name)
2253
+ };
2254
+ const req = new Proxy(ctx.request, {
2255
+ get(target, prop) {
2256
+ if (prop in reqStore) return reqStore[prop];
2257
+ const val = target[prop];
2258
+ if (typeof val === "function") return val.bind(target);
2259
+ return val;
2260
+ },
2261
+ set(target, prop, value) {
2262
+ reqStore[prop] = value;
2263
+ ctx.state[prop] = value;
2264
+ return true;
2265
+ }
2266
+ });
2267
+ const res = {
2268
+ locals: {},
2269
+ statusCode: 200,
2270
+ setHeader: (name, value) => {
2271
+ ctx.response.headers.set(name, value);
2272
+ },
2273
+ set: (name, value) => {
2274
+ ctx.response.headers.set(name, value);
2275
+ },
2276
+ end: (chunk) => {
2277
+ resolve2(new Response(chunk, { status: res.statusCode }));
2278
+ },
2279
+ status: (code) => {
2280
+ res.statusCode = code;
2281
+ return res;
2282
+ },
2283
+ send: (body) => {
2284
+ let content = body;
2285
+ if (typeof body === "object") content = JSON.stringify(body);
2286
+ resolve2(new Response(content, { status: res.statusCode }));
2287
+ },
2288
+ json: (body) => {
2289
+ resolve2(Response.json(body, { status: res.statusCode }));
2290
+ }
2291
+ };
2292
+ try {
2293
+ expressMiddleware(req, res, (err) => {
2294
+ if (err) return reject(err);
2295
+ resolve2(next());
2296
+ });
2297
+ } catch (err) {
2298
+ reject(err);
2299
+ }
2300
+ });
2301
+ };
2302
+ }
1705
2303
  function RateLimit(options = {}) {
1706
2304
  const windowMs = options.windowMs || 60 * 1e3;
1707
2305
  const max = options.max || 5;
@@ -1792,10 +2390,43 @@ class ScalarPlugin extends ShokupanRouter {
1792
2390
  this.get("/scalar.js", (ctx) => {
1793
2391
  return ctx.file(__dirname + "/../../node_modules/@scalar/api-reference/dist/browser/standalone.js");
1794
2392
  });
1795
- this.get("/openapi.json", (ctx) => {
1796
- return (this.root || this).generateApiSpec();
2393
+ this.get("/openapi.json", async (ctx) => {
2394
+ let spec;
2395
+ if (this.root.openApiSpec) {
2396
+ try {
2397
+ spec = structuredClone(this.root.openApiSpec);
2398
+ } catch (e) {
2399
+ spec = Object.assign({}, this.root.openApiSpec);
2400
+ }
2401
+ } else {
2402
+ spec = await (this.root || this).generateApiSpec();
2403
+ }
2404
+ if (this.pluginOptions.baseDocument) {
2405
+ deepMerge(spec, this.pluginOptions.baseDocument);
2406
+ }
2407
+ return ctx.json(spec);
1797
2408
  });
1798
2409
  }
2410
+ // New lifecycle method to be called by router.mount
2411
+ onMount(parent) {
2412
+ if (parent.onStart) {
2413
+ parent.onStart(async () => {
2414
+ if (this.pluginOptions.enableStaticAnalysis) {
2415
+ try {
2416
+ const entrypoint = process.argv[1];
2417
+ console.log(`[ScalarPlugin] Running eager static analysis on entrypoint: ${entrypoint}`);
2418
+ const analyzer = new OpenAPIAnalyzer(process.cwd(), entrypoint);
2419
+ let staticSpec = await analyzer.analyze();
2420
+ if (!this.pluginOptions.baseDocument) this.pluginOptions.baseDocument = {};
2421
+ deepMerge(this.pluginOptions.baseDocument, staticSpec);
2422
+ console.log("[ScalarPlugin] Static analysis completed successfully.");
2423
+ } catch (err) {
2424
+ console.error("[ScalarPlugin] Failed to run static analysis:", err);
2425
+ }
2426
+ }
2427
+ });
2428
+ }
2429
+ }
1799
2430
  }
1800
2431
  function SecurityHeaders(options = {}) {
1801
2432
  return async (ctx, next) => {
@@ -2155,6 +2786,30 @@ async function validateValibotWrapper(wrapper, data) {
2155
2786
  }
2156
2787
  return result.output;
2157
2788
  }
2789
+ function isClass(schema) {
2790
+ try {
2791
+ if (typeof schema === "function" && /^\s*class\s+/.test(schema.toString())) {
2792
+ return true;
2793
+ }
2794
+ return typeof schema === "function" && schema.prototype && schema.name;
2795
+ } catch {
2796
+ return false;
2797
+ }
2798
+ }
2799
+ async function validateClassValidator(schema, data) {
2800
+ const object = plainToInstance(schema, data);
2801
+ try {
2802
+ await validateOrReject(object);
2803
+ return object;
2804
+ } catch (errors) {
2805
+ const formattedErrors = Array.isArray(errors) ? errors.map((err) => ({
2806
+ property: err.property,
2807
+ constraints: err.constraints,
2808
+ children: err.children
2809
+ })) : errors;
2810
+ throw new ValidationError(formattedErrors);
2811
+ }
2812
+ }
2158
2813
  const safelyGetBody = async (ctx) => {
2159
2814
  const req = ctx.req;
2160
2815
  if (req._bodyParsed) {
@@ -2186,21 +2841,38 @@ const safelyGetBody = async (ctx) => {
2186
2841
  };
2187
2842
  function validate(config) {
2188
2843
  return async (ctx, next) => {
2844
+ const dataToValidate = {};
2845
+ if (config.params) dataToValidate.params = ctx.params;
2846
+ let queryObj;
2847
+ if (config.query) {
2848
+ const url = new URL(ctx.req.url);
2849
+ queryObj = Object.fromEntries(url.searchParams.entries());
2850
+ dataToValidate.query = queryObj;
2851
+ }
2852
+ if (config.headers) dataToValidate.headers = Object.fromEntries(ctx.req.headers.entries());
2853
+ let body;
2854
+ if (config.body) {
2855
+ body = await safelyGetBody(ctx);
2856
+ dataToValidate.body = body;
2857
+ }
2858
+ if (ctx.app?.applicationConfig.hooks?.beforeValidate) {
2859
+ await ctx.app.applicationConfig.hooks.beforeValidate(ctx, dataToValidate);
2860
+ }
2189
2861
  if (config.params) {
2190
2862
  ctx.params = await runValidation(config.params, ctx.params);
2191
2863
  }
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);
2864
+ let validQuery;
2865
+ if (config.query && queryObj) {
2866
+ validQuery = await runValidation(config.query, queryObj);
2196
2867
  }
2197
2868
  if (config.headers) {
2198
2869
  const headersObj = Object.fromEntries(ctx.req.headers.entries());
2199
2870
  await runValidation(config.headers, headersObj);
2200
2871
  }
2872
+ let validBody;
2201
2873
  if (config.body) {
2202
- const body = await safelyGetBody(ctx);
2203
- const validBody = await runValidation(config.body, body);
2874
+ const b = body ?? await safelyGetBody(ctx);
2875
+ validBody = await runValidation(config.body, b);
2204
2876
  const req = ctx.req;
2205
2877
  req._bodyValue = validBody;
2206
2878
  Object.defineProperty(req, "json", {
@@ -2209,6 +2881,13 @@ function validate(config) {
2209
2881
  });
2210
2882
  ctx.body = validBody;
2211
2883
  }
2884
+ if (ctx.app?.applicationConfig.hooks?.afterValidate) {
2885
+ const validatedData = { ...dataToValidate };
2886
+ if (config.params) validatedData.params = ctx.params;
2887
+ if (config.query) validatedData.query = validQuery;
2888
+ if (config.body) validatedData.body = validBody;
2889
+ await ctx.app.applicationConfig.hooks.afterValidate(ctx, validatedData);
2890
+ }
2212
2891
  return next();
2213
2892
  };
2214
2893
  }
@@ -2225,6 +2904,18 @@ async function runValidation(schema, data) {
2225
2904
  if (isValibotWrapper(schema)) {
2226
2905
  return validateValibotWrapper(schema, data);
2227
2906
  }
2907
+ if (isClass(schema)) {
2908
+ return validateClassValidator(schema, data);
2909
+ }
2910
+ if (isTypeBox(schema)) {
2911
+ return validateTypeBox(schema, data);
2912
+ }
2913
+ if (isAjv(schema)) {
2914
+ return validateAjv(schema, data);
2915
+ }
2916
+ if (isValibotWrapper(schema)) {
2917
+ return validateValibotWrapper(schema, data);
2918
+ }
2228
2919
  if (typeof schema === "function") {
2229
2920
  return schema(data);
2230
2921
  }
@@ -2244,6 +2935,8 @@ export {
2244
2935
  $parent,
2245
2936
  $routeArgs,
2246
2937
  $routeMethods,
2938
+ $routeSpec,
2939
+ $routes,
2247
2940
  All,
2248
2941
  AuthPlugin,
2249
2942
  Body,
@@ -2279,9 +2972,11 @@ export {
2279
2972
  ShokupanRequest,
2280
2973
  ShokupanResponse,
2281
2974
  ShokupanRouter,
2975
+ Spec,
2282
2976
  Use,
2283
2977
  ValidationError,
2284
2978
  compose,
2979
+ useExpress,
2285
2980
  valibot,
2286
2981
  validate
2287
2982
  };