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.cjs CHANGED
@@ -1,19 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const api = require("@opentelemetry/api");
4
- const exporterTraceOtlpProto = require("@opentelemetry/exporter-trace-otlp-proto");
5
- const resources = require("@opentelemetry/resources");
6
- const sdkTraceBase = require("@opentelemetry/sdk-trace-base");
7
- const sdkTraceNode = require("@opentelemetry/sdk-trace-node");
8
- const semanticConventions = require("@opentelemetry/semantic-conventions");
9
4
  const eta$2 = require("eta");
10
5
  const promises = require("fs/promises");
11
6
  const path = require("path");
12
7
  const node_async_hooks = require("node:async_hooks");
13
8
  const arctic = require("arctic");
14
9
  const jose = require("jose");
10
+ const openapiAnalyzer = require("./openapi-analyzer-CFqgSLNK.cjs");
15
11
  const crypto = require("crypto");
16
12
  const events = require("events");
13
+ const classTransformer = require("class-transformer");
14
+ const classValidator = require("class-validator");
17
15
  function _interopNamespaceDefault(e) {
18
16
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
19
17
  if (e) {
@@ -86,8 +84,10 @@ class ShokupanResponse {
86
84
  }
87
85
  }
88
86
  class ShokupanContext {
89
- constructor(request, state) {
87
+ constructor(request, server, state, app) {
90
88
  this.request = request;
89
+ this.server = server;
90
+ this.app = app;
91
91
  this.url = new URL(request.url);
92
92
  this.state = state || {};
93
93
  this.response = new ShokupanResponse();
@@ -120,12 +120,55 @@ class ShokupanContext {
120
120
  get query() {
121
121
  return Object.fromEntries(this.url.searchParams);
122
122
  }
123
+ /**
124
+ * Client IP address
125
+ */
126
+ get ip() {
127
+ return this.server?.requestIP(this.request);
128
+ }
129
+ /**
130
+ * Request hostname (e.g. "localhost")
131
+ */
132
+ get hostname() {
133
+ return this.url.hostname;
134
+ }
135
+ /**
136
+ * Request host (e.g. "localhost:3000")
137
+ */
138
+ get host() {
139
+ return this.url.host;
140
+ }
141
+ /**
142
+ * Request protocol (e.g. "http:", "https:")
143
+ */
144
+ get protocol() {
145
+ return this.url.protocol;
146
+ }
147
+ /**
148
+ * Whether request is secure (https)
149
+ */
150
+ get secure() {
151
+ return this.url.protocol === "https:";
152
+ }
153
+ /**
154
+ * Request origin (e.g. "http://localhost:3000")
155
+ */
156
+ get origin() {
157
+ return this.url.origin;
158
+ }
123
159
  /**
124
160
  * Request headers
125
161
  */
126
162
  get headers() {
127
163
  return this.request.headers;
128
164
  }
165
+ /**
166
+ * Get a request header
167
+ * @param name Header name
168
+ */
169
+ get(name) {
170
+ return this.request.headers.get(name);
171
+ }
129
172
  /**
130
173
  * Base response object
131
174
  */
@@ -134,6 +177,8 @@ class ShokupanContext {
134
177
  }
135
178
  /**
136
179
  * Helper to set a header on the response
180
+ * @param key Header key
181
+ * @param value Header value
137
182
  */
138
183
  set(key, value) {
139
184
  this.response.set(key, value);
@@ -245,6 +290,23 @@ class ShokupanContext {
245
290
  const status = responseOptions?.status ?? this.response.status;
246
291
  return new Response(Bun.file(path2, fileOptions), { status, headers });
247
292
  }
293
+ /**
294
+ * JSX Rendering Function
295
+ */
296
+ renderer;
297
+ /**
298
+ * Render a JSX element
299
+ * @param element JSX Element
300
+ * @param status HTTP Status
301
+ * @param headers HTTP Headers
302
+ */
303
+ async jsx(element, args, status, headers) {
304
+ if (!this.renderer) {
305
+ throw new Error("No JSX renderer configured");
306
+ }
307
+ const html = await this.renderer(element, args);
308
+ return this.html(html, status, headers);
309
+ }
248
310
  }
249
311
  const $isApplication = /* @__PURE__ */ Symbol.for("Shokupan.app");
250
312
  const $appRoot = /* @__PURE__ */ Symbol.for("Shokupan.app-root");
@@ -259,6 +321,8 @@ const $childRouters = /* @__PURE__ */ Symbol.for("Shokupan.child-routers");
259
321
  const $childControllers = /* @__PURE__ */ Symbol.for("Shokupan.child-controllers");
260
322
  const $mountPath = /* @__PURE__ */ Symbol.for("Shokupan.mount-path");
261
323
  const $dispatch = /* @__PURE__ */ Symbol.for("Shokupan.dispatch");
324
+ const $routes = /* @__PURE__ */ Symbol.for("Shokupan.routes");
325
+ const $routeSpec = /* @__PURE__ */ Symbol.for("Shokupan.routeSpec");
262
326
  const HTTPMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "ALL"];
263
327
  var RouteParamType = /* @__PURE__ */ ((RouteParamType2) => {
264
328
  RouteParamType2["BODY"] = "BODY";
@@ -311,6 +375,14 @@ const Query = createParamDecorator(RouteParamType.QUERY);
311
375
  const Headers$1 = createParamDecorator(RouteParamType.HEADER);
312
376
  const Req = createParamDecorator(RouteParamType.REQUEST);
313
377
  const Ctx = createParamDecorator(RouteParamType.CONTEXT);
378
+ function Spec(spec) {
379
+ return (target, propertyKey, descriptor) => {
380
+ if (!target[$routeSpec]) {
381
+ target[$routeSpec] = /* @__PURE__ */ new Map();
382
+ }
383
+ target[$routeSpec].set(propertyKey, spec);
384
+ };
385
+ }
314
386
  function createMethodDecorator(method) {
315
387
  return (path2 = "/") => {
316
388
  return (target, propertyKey, descriptor) => {
@@ -365,20 +437,6 @@ function Inject(token) {
365
437
  });
366
438
  };
367
439
  }
368
- const provider = new sdkTraceNode.NodeTracerProvider({
369
- resource: resources.resourceFromAttributes({
370
- [semanticConventions.ATTR_SERVICE_NAME]: "basic-service"
371
- }),
372
- spanProcessors: [
373
- new sdkTraceBase.SimpleSpanProcessor(
374
- new exporterTraceOtlpProto.OTLPTraceExporter({
375
- url: "http://localhost:4318/v1/traces"
376
- // Default OTLP port
377
- })
378
- )
379
- ]
380
- });
381
- provider.register();
382
440
  const tracer = api.trace.getTracer("shokupan.middleware");
383
441
  function traceMiddleware(fn, name) {
384
442
  const middlewareName = fn.name || "anonymous middleware";
@@ -468,7 +526,6 @@ class ShokupanRequestBase {
468
526
  }
469
527
  }
470
528
  const ShokupanRequest = ShokupanRequestBase;
471
- const asyncContext = new node_async_hooks.AsyncLocalStorage();
472
529
  function isObject(item) {
473
530
  return item && typeof item === "object" && !Array.isArray(item);
474
531
  }
@@ -482,7 +539,17 @@ function deepMerge(target, ...sources) {
482
539
  deepMerge(target[key], source[key]);
483
540
  } else if (Array.isArray(source[key])) {
484
541
  if (!target[key]) Object.assign(target, { [key]: [] });
485
- target[key] = target[key].concat(source[key]);
542
+ if (key === "tags") {
543
+ target[key] = source[key];
544
+ continue;
545
+ }
546
+ const mergedArray = target[key].concat(source[key]);
547
+ const isPrimitive = (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean";
548
+ if (mergedArray.every(isPrimitive)) {
549
+ target[key] = Array.from(new Set(mergedArray));
550
+ } else {
551
+ target[key] = mergedArray;
552
+ }
486
553
  } else {
487
554
  Object.assign(target, { [key]: source[key] });
488
555
  }
@@ -490,12 +557,583 @@ function deepMerge(target, ...sources) {
490
557
  }
491
558
  return deepMerge(target, ...sources);
492
559
  }
560
+ function analyzeHandler(handler) {
561
+ const handlerSource = handler.toString();
562
+ const inferredSpec = {};
563
+ if (handlerSource.includes("ctx.body") || handlerSource.includes("await ctx.req.json()")) {
564
+ inferredSpec.requestBody = {
565
+ content: { "application/json": { schema: { type: "object" } } }
566
+ };
567
+ }
568
+ const queryParams = /* @__PURE__ */ new Map();
569
+ const queryIntMatch = handlerSource.match(/parseInt\(ctx\.query\.(\w+)\)/g);
570
+ if (queryIntMatch) {
571
+ queryIntMatch.forEach((match) => {
572
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
573
+ if (paramName) queryParams.set(paramName, { type: "integer", format: "int32" });
574
+ });
575
+ }
576
+ const queryFloatMatch = handlerSource.match(/parseFloat\(ctx\.query\.(\w+)\)/g);
577
+ if (queryFloatMatch) {
578
+ queryFloatMatch.forEach((match) => {
579
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
580
+ if (paramName) queryParams.set(paramName, { type: "number", format: "float" });
581
+ });
582
+ }
583
+ const queryNumberMatch = handlerSource.match(/Number\(ctx\.query\.(\w+)\)/g);
584
+ if (queryNumberMatch) {
585
+ queryNumberMatch.forEach((match) => {
586
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
587
+ if (paramName && !queryParams.has(paramName)) {
588
+ queryParams.set(paramName, { type: "number" });
589
+ }
590
+ });
591
+ }
592
+ const queryBoolMatch = handlerSource.match(/(?:Boolean\(ctx\.query\.(\w+)\)|!+ctx\.query\.(\w+))/g);
593
+ if (queryBoolMatch) {
594
+ queryBoolMatch.forEach((match) => {
595
+ const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
596
+ if (paramName && !queryParams.has(paramName)) {
597
+ queryParams.set(paramName, { type: "boolean" });
598
+ }
599
+ });
600
+ }
601
+ const queryMatch = handlerSource.match(/ctx\.query\.(\w+)/g);
602
+ if (queryMatch) {
603
+ queryMatch.forEach((match) => {
604
+ const paramName = match.split(".")[2];
605
+ if (paramName && !queryParams.has(paramName)) {
606
+ queryParams.set(paramName, { type: "string" });
607
+ }
608
+ });
609
+ }
610
+ if (queryParams.size > 0) {
611
+ if (!inferredSpec.parameters) inferredSpec.parameters = [];
612
+ queryParams.forEach((schema, paramName) => {
613
+ inferredSpec.parameters.push({
614
+ name: paramName,
615
+ in: "query",
616
+ schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
617
+ });
618
+ });
619
+ }
620
+ const pathParams = /* @__PURE__ */ new Map();
621
+ const paramIntMatch = handlerSource.match(/parseInt\(ctx\.params\.(\w+)\)/g);
622
+ if (paramIntMatch) {
623
+ paramIntMatch.forEach((match) => {
624
+ const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
625
+ if (paramName) pathParams.set(paramName, { type: "integer", format: "int32" });
626
+ });
627
+ }
628
+ const paramFloatMatch = handlerSource.match(/parseFloat\(ctx\.params\.(\w+)\)/g);
629
+ if (paramFloatMatch) {
630
+ paramFloatMatch.forEach((match) => {
631
+ const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
632
+ if (paramName) pathParams.set(paramName, { type: "number", format: "float" });
633
+ });
634
+ }
635
+ if (pathParams.size > 0) {
636
+ if (!inferredSpec.parameters) inferredSpec.parameters = [];
637
+ pathParams.forEach((schema, paramName) => {
638
+ inferredSpec.parameters.push({
639
+ name: paramName,
640
+ in: "path",
641
+ required: true,
642
+ schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
643
+ });
644
+ });
645
+ }
646
+ const headerMatch = handlerSource.match(/ctx\.get\(['"](\w+)['"]\)/g);
647
+ if (headerMatch) {
648
+ if (!inferredSpec.parameters) inferredSpec.parameters = [];
649
+ headerMatch.forEach((match) => {
650
+ const headerName = match.match(/['"](\w+)['"]/)?.[1];
651
+ if (headerName) {
652
+ inferredSpec.parameters.push({
653
+ name: headerName,
654
+ in: "header",
655
+ schema: { type: "string" }
656
+ });
657
+ }
658
+ });
659
+ }
660
+ const responses = {};
661
+ if (handlerSource.includes("ctx.json(")) {
662
+ responses["200"] = {
663
+ description: "Successful response",
664
+ content: {
665
+ "application/json": { schema: { type: "object" } }
666
+ }
667
+ };
668
+ }
669
+ if (handlerSource.includes("ctx.html(")) {
670
+ responses["200"] = {
671
+ description: "Successful response",
672
+ content: {
673
+ "text/html": { schema: { type: "string" } }
674
+ }
675
+ };
676
+ }
677
+ if (handlerSource.includes("ctx.text(")) {
678
+ responses["200"] = {
679
+ description: "Successful response",
680
+ content: {
681
+ "text/plain": { schema: { type: "string" } }
682
+ }
683
+ };
684
+ }
685
+ if (handlerSource.includes("ctx.file(")) {
686
+ responses["200"] = {
687
+ description: "File download",
688
+ content: {
689
+ "application/octet-stream": { schema: { type: "string", format: "binary" } }
690
+ }
691
+ };
692
+ }
693
+ if (handlerSource.includes("ctx.redirect(")) {
694
+ responses["302"] = {
695
+ description: "Redirect"
696
+ };
697
+ }
698
+ if (!responses["200"] && /return\s+\{/.test(handlerSource)) {
699
+ responses["200"] = {
700
+ description: "Successful response",
701
+ content: {
702
+ "application/json": { schema: { type: "object" } }
703
+ }
704
+ };
705
+ }
706
+ const errorStatusMatch = handlerSource.match(/ctx\.(?:json|text|html)\([^)]+,\s*(\d{3,})\)/g);
707
+ if (errorStatusMatch) {
708
+ errorStatusMatch.forEach((match) => {
709
+ const statusCode = match.match(/,\s*(\d{3,})\)/)?.[1];
710
+ if (statusCode && statusCode !== "200") {
711
+ responses[statusCode] = {
712
+ description: `Error response (${statusCode})`
713
+ };
714
+ }
715
+ });
716
+ }
717
+ if (Object.keys(responses).length > 0) {
718
+ inferredSpec.responses = responses;
719
+ }
720
+ return { inferredSpec };
721
+ }
722
+ async function generateOpenApi(rootRouter, options = {}) {
723
+ const paths = {};
724
+ const tagGroups = /* @__PURE__ */ new Map();
725
+ const defaultTagGroup = options.defaultTagGroup || "General";
726
+ const defaultTagName = options.defaultTag || "Application";
727
+ let astRoutes = [];
728
+ try {
729
+ const { OpenAPIAnalyzer } = await Promise.resolve().then(() => require("./openapi-analyzer-CFqgSLNK.cjs"));
730
+ const analyzer = new OpenAPIAnalyzer(process.cwd());
731
+ const { applications } = await analyzer.analyze();
732
+ const appMap = /* @__PURE__ */ new Map();
733
+ applications.forEach((app) => {
734
+ appMap.set(app.name, app);
735
+ if (app.name !== app.className) {
736
+ appMap.set(app.className, app);
737
+ }
738
+ });
739
+ const getExpandedRoutes = (app, prefix = "", seen = /* @__PURE__ */ new Set()) => {
740
+ if (seen.has(app.name)) return [];
741
+ const newSeen = new Set(seen);
742
+ newSeen.add(app.name);
743
+ const expanded = [];
744
+ for (const route of app.routes) {
745
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
746
+ const cleanPath = route.path.startsWith("/") ? route.path : "/" + route.path;
747
+ let joined = cleanPrefix + cleanPath;
748
+ if (joined.length > 1 && joined.endsWith("/")) {
749
+ joined = joined.slice(0, -1);
750
+ }
751
+ expanded.push({
752
+ ...route,
753
+ path: joined || "/"
754
+ });
755
+ }
756
+ if (app.mounted) {
757
+ for (const mount of app.mounted) {
758
+ const targetApp = appMap.get(mount.target);
759
+ if (targetApp) {
760
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
761
+ const mountPrefix = mount.prefix.startsWith("/") ? mount.prefix : "/" + mount.prefix;
762
+ expanded.push(...getExpandedRoutes(targetApp, cleanPrefix + mountPrefix, newSeen));
763
+ }
764
+ }
765
+ }
766
+ return expanded;
767
+ };
768
+ applications.forEach((app) => {
769
+ astRoutes.push(...getExpandedRoutes(app));
770
+ });
771
+ const dedupedRoutes = /* @__PURE__ */ new Map();
772
+ for (const route of astRoutes) {
773
+ const key = `${route.method.toUpperCase()}:${route.path}`;
774
+ let score = 0;
775
+ if (route.responseSchema) score += 10;
776
+ if (route.handlerSource) score += 5;
777
+ if (!dedupedRoutes.has(key) || score > dedupedRoutes.get(key).score) {
778
+ dedupedRoutes.set(key, { route, score });
779
+ }
780
+ }
781
+ astRoutes = Array.from(dedupedRoutes.values()).map((v) => v.route);
782
+ } catch (e) {
783
+ console.warn("OpenAPI AST analysis failed or skipped:", e);
784
+ }
785
+ const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
786
+ let group = currentGroup;
787
+ let tag = defaultTag;
788
+ if (router.config?.group) group = router.config.group;
789
+ if (router.config?.name) {
790
+ tag = router.config.name;
791
+ } else {
792
+ const mountPath = router[$mountPath];
793
+ if (mountPath && mountPath !== "/") {
794
+ const segments = mountPath.split("/").filter(Boolean);
795
+ if (segments.length > 0) {
796
+ const lastSegment = segments[segments.length - 1];
797
+ tag = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
798
+ }
799
+ }
800
+ }
801
+ if (!tagGroups.has(group)) tagGroups.set(group, /* @__PURE__ */ new Set());
802
+ const routes = router[$routes] || [];
803
+ for (const route of routes) {
804
+ const routeGroup = route.group || group;
805
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
806
+ const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
807
+ let fullPath = cleanPrefix + cleanSubPath || "/";
808
+ fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
809
+ if (fullPath.length > 1 && fullPath.endsWith("/")) {
810
+ fullPath = fullPath.slice(0, -1);
811
+ }
812
+ if (!paths[fullPath]) paths[fullPath] = {};
813
+ const operation = {
814
+ responses: { "200": { description: "Successful response" } },
815
+ tags: [tag]
816
+ };
817
+ if (route.guards) {
818
+ for (const guard of route.guards) {
819
+ if (guard.spec) {
820
+ if (guard.spec.security) {
821
+ const existing = operation.security || [];
822
+ for (const req of guard.spec.security) {
823
+ const reqStr = JSON.stringify(req);
824
+ if (!existing.some((e) => JSON.stringify(e) === reqStr)) {
825
+ existing.push(req);
826
+ }
827
+ }
828
+ operation.security = existing;
829
+ }
830
+ if (guard.spec.responses) {
831
+ operation.responses = { ...operation.responses, ...guard.spec.responses };
832
+ }
833
+ }
834
+ }
835
+ }
836
+ let astMatch = astRoutes.find(
837
+ (r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
838
+ );
839
+ if (!astMatch) {
840
+ let runtimeSource = route.handler.toString();
841
+ if (route.handler.originalHandler) {
842
+ runtimeSource = route.handler.originalHandler.toString();
843
+ }
844
+ const runtimeHandlerSrc = runtimeSource.replace(/\s+/g, " ");
845
+ const sameMethodRoutes = astRoutes.filter((r) => r.method.toUpperCase() === route.method.toUpperCase());
846
+ astMatch = sameMethodRoutes.find((r) => {
847
+ const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
848
+ if (!astHandlerSrc || astHandlerSrc.length < 20) return false;
849
+ const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
850
+ return match;
851
+ });
852
+ }
853
+ const potentialMatches = astRoutes.filter(
854
+ (r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
855
+ );
856
+ if (potentialMatches.length > 1) {
857
+ const runtimeHandlerSrc = route.handler.toString().replace(/\s+/g, " ");
858
+ const preciseMatch = potentialMatches.find((r) => {
859
+ const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
860
+ const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
861
+ return match;
862
+ });
863
+ if (preciseMatch) {
864
+ astMatch = preciseMatch;
865
+ }
866
+ }
867
+ if (astMatch) {
868
+ if (astMatch.summary) operation.summary = astMatch.summary;
869
+ if (astMatch.description) operation.description = astMatch.description;
870
+ if (astMatch.tags) operation.tags = astMatch.tags;
871
+ if (astMatch.operationId) operation.operationId = astMatch.operationId;
872
+ if (astMatch.requestTypes?.body) {
873
+ operation.requestBody = {
874
+ content: {
875
+ "application/json": { schema: astMatch.requestTypes.body }
876
+ }
877
+ };
878
+ }
879
+ if (astMatch.responseSchema) {
880
+ operation.responses["200"] = {
881
+ description: "Successful response",
882
+ content: {
883
+ "application/json": { schema: astMatch.responseSchema }
884
+ }
885
+ };
886
+ } else if (astMatch.responseType) {
887
+ const contentType = astMatch.responseType === "string" ? "text/plain" : "application/json";
888
+ operation.responses["200"] = {
889
+ description: "Successful response",
890
+ content: {
891
+ [contentType]: { schema: { type: astMatch.responseType } }
892
+ }
893
+ };
894
+ }
895
+ const params = [];
896
+ if (astMatch.requestTypes?.query) {
897
+ for (const [name, _type] of Object.entries(astMatch.requestTypes.query)) {
898
+ params.push({ name, in: "query", schema: { type: "string" } });
899
+ }
900
+ }
901
+ if (params.length > 0) {
902
+ operation.parameters = params;
903
+ }
904
+ }
905
+ if (route.keys.length > 0) {
906
+ const pathParams = route.keys.map((key) => ({
907
+ name: key,
908
+ in: "path",
909
+ required: true,
910
+ schema: { type: "string" }
911
+ }));
912
+ const existingParams = operation.parameters || [];
913
+ const mergedParams = [...existingParams];
914
+ pathParams.forEach((p) => {
915
+ const idx = mergedParams.findIndex((ep) => ep.in === "path" && ep.name === p.name);
916
+ if (idx >= 0) {
917
+ mergedParams[idx] = deepMerge(mergedParams[idx], p);
918
+ } else {
919
+ mergedParams.push(p);
920
+ }
921
+ });
922
+ operation.parameters = mergedParams;
923
+ }
924
+ const { inferredSpec } = analyzeHandler(route.handler);
925
+ if (inferredSpec) {
926
+ if (inferredSpec.parameters) {
927
+ const existingParams = operation.parameters || [];
928
+ const mergedParams = [...existingParams];
929
+ for (const p of inferredSpec.parameters) {
930
+ const idx = mergedParams.findIndex((ep) => ep.name === p.name && ep.in === p.in);
931
+ if (idx >= 0) {
932
+ mergedParams[idx] = deepMerge(mergedParams[idx], p);
933
+ } else {
934
+ mergedParams.push(p);
935
+ }
936
+ }
937
+ operation.parameters = mergedParams;
938
+ delete inferredSpec.parameters;
939
+ }
940
+ deepMerge(operation, inferredSpec);
941
+ }
942
+ if (route.handlerSpec) {
943
+ const spec = route.handlerSpec;
944
+ if (spec.summary) operation.summary = spec.summary;
945
+ if (spec.description) operation.description = spec.description;
946
+ if (spec.operationId) operation.operationId = spec.operationId;
947
+ if (spec.tags) operation.tags = spec.tags;
948
+ if (spec.security) operation.security = spec.security;
949
+ if (spec.responses) {
950
+ operation.responses = { ...operation.responses, ...spec.responses };
951
+ }
952
+ }
953
+ if (!operation.tags || operation.tags.length === 0) operation.tags = [tag];
954
+ if (operation.tags) {
955
+ operation.tags = Array.from(new Set(operation.tags));
956
+ for (const t of operation.tags) {
957
+ if (!tagGroups.has(routeGroup)) tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
958
+ tagGroups.get(routeGroup)?.add(t);
959
+ }
960
+ }
961
+ const methodLower = route.method.toLowerCase();
962
+ if (methodLower === "all") {
963
+ ["get", "post", "put", "delete", "patch"].forEach((m) => {
964
+ if (!paths[fullPath][m]) paths[fullPath][m] = { ...operation };
965
+ });
966
+ } else {
967
+ paths[fullPath][methodLower] = operation;
968
+ }
969
+ }
970
+ for (const controller of router[$childControllers]) {
971
+ const controllerName = controller.constructor.name || "UnknownController";
972
+ tagGroups.get(group)?.add(controllerName);
973
+ }
974
+ for (const child of router[$childRouters]) {
975
+ const mountPath = child[$mountPath];
976
+ const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
977
+ const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
978
+ const nextPrefix = cleanPrefix + cleanMount || "/";
979
+ collect(child, nextPrefix, group, tag);
980
+ }
981
+ };
982
+ collect(rootRouter);
983
+ const xTagGroups = [];
984
+ for (const [name, tags] of tagGroups) {
985
+ xTagGroups.push({ name, tags: Array.from(tags).sort() });
986
+ }
987
+ return {
988
+ openapi: "3.1.0",
989
+ info: { title: "Shokupan API", version: "1.0.0", ...options.info },
990
+ paths,
991
+ components: options.components,
992
+ servers: options.servers,
993
+ tags: options.tags,
994
+ externalDocs: options.externalDocs,
995
+ "x-tagGroups": xTagGroups
996
+ };
997
+ }
493
998
  const eta$1 = new eta$2.Eta();
999
+ function serveStatic(ctx, config, prefix) {
1000
+ const rootPath = path.resolve(config.root || ".");
1001
+ const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
1002
+ return async () => {
1003
+ let relative = ctx.path.slice(normalizedPrefix.length);
1004
+ if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
1005
+ if (relative.length === 0) relative = "/";
1006
+ relative = decodeURIComponent(relative);
1007
+ const requestPath = path.join(rootPath, relative);
1008
+ if (!requestPath.startsWith(rootPath)) {
1009
+ return ctx.json({ error: "Forbidden" }, 403);
1010
+ }
1011
+ if (requestPath.includes("\0")) {
1012
+ return ctx.json({ error: "Forbidden" }, 403);
1013
+ }
1014
+ if (config.hooks?.onRequest) {
1015
+ const res = await config.hooks.onRequest(ctx);
1016
+ if (res) return res;
1017
+ }
1018
+ if (config.exclude) {
1019
+ for (const pattern of config.exclude) {
1020
+ if (pattern instanceof RegExp) {
1021
+ if (pattern.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
1022
+ } else if (typeof pattern === "string") {
1023
+ if (relative.includes(pattern)) return ctx.json({ error: "Forbidden" }, 403);
1024
+ }
1025
+ }
1026
+ }
1027
+ if (path.basename(requestPath).startsWith(".")) {
1028
+ const behavior = config.dotfiles || "ignore";
1029
+ if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
1030
+ if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
1031
+ }
1032
+ let finalPath = requestPath;
1033
+ let stats;
1034
+ try {
1035
+ stats = await promises.stat(requestPath);
1036
+ } catch (e) {
1037
+ if (config.extensions) {
1038
+ for (const ext of config.extensions) {
1039
+ const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
1040
+ try {
1041
+ const s = await promises.stat(p);
1042
+ if (s.isFile()) {
1043
+ finalPath = p;
1044
+ stats = s;
1045
+ break;
1046
+ }
1047
+ } catch {
1048
+ }
1049
+ }
1050
+ }
1051
+ if (!stats) return ctx.json({ error: "Not Found" }, 404);
1052
+ }
1053
+ if (stats.isDirectory()) {
1054
+ if (!ctx.path.endsWith("/")) {
1055
+ const query = ctx.url.search;
1056
+ return ctx.redirect(ctx.path + "/" + query, 302);
1057
+ }
1058
+ let indexes = [];
1059
+ if (config.index === void 0) {
1060
+ indexes = ["index.html", "index.htm"];
1061
+ } else if (Array.isArray(config.index)) {
1062
+ indexes = config.index;
1063
+ } else if (config.index) {
1064
+ indexes = [config.index];
1065
+ }
1066
+ let foundIndex = false;
1067
+ for (const idx of indexes) {
1068
+ const idxPath = path.join(finalPath, idx);
1069
+ try {
1070
+ const idxStats = await promises.stat(idxPath);
1071
+ if (idxStats.isFile()) {
1072
+ finalPath = idxPath;
1073
+ foundIndex = true;
1074
+ break;
1075
+ }
1076
+ } catch {
1077
+ }
1078
+ }
1079
+ if (!foundIndex) {
1080
+ if (config.listDirectory) {
1081
+ try {
1082
+ const files = await promises.readdir(requestPath);
1083
+ const listing = eta$1.renderString(`
1084
+ <!DOCTYPE html>
1085
+ <html>
1086
+ <head>
1087
+ <title>Index of <%= it.relative %></title>
1088
+ <style>
1089
+ body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
1090
+ ul { list-style: none; padding: 0; }
1091
+ li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
1092
+ a { text-decoration: none; color: #0066cc; }
1093
+ a:hover { text-decoration: underline; }
1094
+ h1 { font-size: 1.5rem; margin-bottom: 1rem; }
1095
+ </style>
1096
+ </head>
1097
+ <body>
1098
+ <h1>Index of <%= it.relative %></h1>
1099
+ <ul>
1100
+ <% if (it.relative !== '/') { %>
1101
+ <li><a href="../">../</a></li>
1102
+ <% } %>
1103
+ <% it.files.forEach(function(f) { %>
1104
+ <li><a href="<%= f %>"><%= f %></a></li>
1105
+ <% }) %>
1106
+ </ul>
1107
+ </body>
1108
+ </html>
1109
+ `, { relative, files, join: path.join });
1110
+ return new Response(listing, { headers: { "Content-Type": "text/html" } });
1111
+ } catch (e) {
1112
+ return ctx.json({ error: "Internal Server Error" }, 500);
1113
+ }
1114
+ } else {
1115
+ return ctx.json({ error: "Forbidden" }, 403);
1116
+ }
1117
+ }
1118
+ }
1119
+ const file = Bun.file(finalPath);
1120
+ let response = new Response(file);
1121
+ if (config.hooks?.onResponse) {
1122
+ const hooked = await config.hooks.onResponse(ctx, response);
1123
+ if (hooked) response = hooked;
1124
+ }
1125
+ return response;
1126
+ };
1127
+ }
1128
+ const asyncContext = new node_async_hooks.AsyncLocalStorage();
494
1129
  const RouterRegistry = /* @__PURE__ */ new Map();
495
1130
  const ShokupanApplicationTree = {};
496
1131
  class ShokupanRouter {
497
1132
  constructor(config) {
498
1133
  this.config = config;
1134
+ if (config?.requestTimeout) {
1135
+ this.requestTimeout = config.requestTimeout;
1136
+ }
499
1137
  }
500
1138
  // Internal marker to identify Router vs. Application
501
1139
  [$isApplication] = false;
@@ -503,6 +1141,7 @@ class ShokupanRouter {
503
1141
  [$isRouter] = true;
504
1142
  [$appRoot];
505
1143
  [$mountPath] = "/";
1144
+ // Public via Symbol for OpenAPI generator
506
1145
  [$parent] = null;
507
1146
  [$childRouters] = [];
508
1147
  [$childControllers] = [];
@@ -512,7 +1151,8 @@ class ShokupanRouter {
512
1151
  get root() {
513
1152
  return this[$appRoot];
514
1153
  }
515
- routes = [];
1154
+ [$routes] = [];
1155
+ // Public via Symbol for OpenAPI generator
516
1156
  currentGuards = [];
517
1157
  isRouterInstance(target) {
518
1158
  return typeof target === "object" && target !== null && $isRouter in target;
@@ -528,6 +1168,12 @@ class ShokupanRouter {
528
1168
  * - postCreate(ctx) -> POST /prefix/create
529
1169
  */
530
1170
  mount(prefix, controller) {
1171
+ const isRouter = this.isRouterInstance(controller);
1172
+ const isFunction = typeof controller === "function";
1173
+ const controllersOnly = this.config?.controllersOnly ?? this.rootConfig?.controllersOnly ?? false;
1174
+ if (controllersOnly && !isFunction && !isRouter) {
1175
+ throw new Error(`[Shokupan] strict controller check failed: ${controller.constructor.name || typeof controller} is not a class constructor.`);
1176
+ }
531
1177
  if (this.isRouterInstance(controller)) {
532
1178
  if (controller[$isMounted]) {
533
1179
  throw new Error("Router is already mounted");
@@ -554,6 +1200,15 @@ class ShokupanRouter {
554
1200
  prefix = p1 + p2;
555
1201
  if (!prefix) prefix = "/";
556
1202
  }
1203
+ } else {
1204
+ const ctor = instance.constructor;
1205
+ const controllerPath = ctor[$controllerPath];
1206
+ if (controllerPath) {
1207
+ const p1 = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
1208
+ const p2 = controllerPath.startsWith("/") ? controllerPath : "/" + controllerPath;
1209
+ prefix = p1 + p2;
1210
+ if (!prefix) prefix = "/";
1211
+ }
557
1212
  }
558
1213
  instance[$mountPath] = prefix;
559
1214
  this[$childControllers].push(instance);
@@ -671,8 +1326,14 @@ class ShokupanRouter {
671
1326
  return composed(ctx, () => wrappedHandler(ctx));
672
1327
  };
673
1328
  }
1329
+ finalHandler.originalHandler = originalHandler;
1330
+ if (finalHandler !== wrappedHandler) {
1331
+ wrappedHandler.originalHandler = originalHandler;
1332
+ }
674
1333
  const tagName = instance.constructor.name;
675
- const spec = { tags: [tagName] };
1334
+ const decoratedSpecs = instance[$routeSpec] || proto && proto[$routeSpec];
1335
+ const userSpec = decoratedSpecs && decoratedSpecs.get(name);
1336
+ const spec = { tags: [tagName], ...userSpec };
676
1337
  this.add({ method, path: normalizedPath, handler: finalHandler, spec });
677
1338
  }
678
1339
  }
@@ -687,7 +1348,7 @@ class ShokupanRouter {
687
1348
  * Returns all routes attached to this router and its descendants.
688
1349
  */
689
1350
  getRoutes() {
690
- const routes = this.routes.map((r) => ({
1351
+ const routes = this[$routes].map((r) => ({
691
1352
  method: r.method,
692
1353
  path: r.path,
693
1354
  handler: r.handler
@@ -788,6 +1449,30 @@ class ShokupanRouter {
788
1449
  data: result
789
1450
  };
790
1451
  }
1452
+ applyHooks(match) {
1453
+ if (!this.config?.hooks) return match;
1454
+ const hooks = this.config.hooks;
1455
+ const originalHandler = match.handler;
1456
+ match.handler = async (ctx) => {
1457
+ if (hooks.onRequestStart) await hooks.onRequestStart(ctx);
1458
+ try {
1459
+ const result = await originalHandler(ctx);
1460
+ if (hooks.onRequestEnd) await hooks.onRequestEnd(ctx);
1461
+ return result;
1462
+ } catch (err) {
1463
+ if (hooks.onError) {
1464
+ try {
1465
+ await hooks.onError(err, ctx);
1466
+ } catch (e) {
1467
+ console.error("Error in router onError hook:", e);
1468
+ }
1469
+ }
1470
+ throw err;
1471
+ }
1472
+ };
1473
+ match.handler.originalHandler = originalHandler.originalHandler || originalHandler;
1474
+ return match;
1475
+ }
791
1476
  /**
792
1477
  * Find a route matching the given method and path.
793
1478
  * @param method HTTP method
@@ -795,7 +1480,7 @@ class ShokupanRouter {
795
1480
  * @returns Route handler and parameters if found, otherwise null
796
1481
  */
797
1482
  find(method, path2) {
798
- for (const route of this.routes) {
1483
+ for (const route of this[$routes]) {
799
1484
  if (route.method !== "ALL" && route.method !== method) continue;
800
1485
  const match = route.regex.exec(path2);
801
1486
  if (match) {
@@ -803,7 +1488,7 @@ class ShokupanRouter {
803
1488
  route.keys.forEach((key, index) => {
804
1489
  params[key] = match[index + 1];
805
1490
  });
806
- return { handler: route.handler, params };
1491
+ return this.applyHooks({ handler: route.handler, params });
807
1492
  }
808
1493
  }
809
1494
  for (const child of this[$childRouters]) {
@@ -811,13 +1496,13 @@ class ShokupanRouter {
811
1496
  if (path2 === prefix || path2.startsWith(prefix + "/")) {
812
1497
  const subPath = path2.slice(prefix.length) || "/";
813
1498
  const match = child.find(method, subPath);
814
- if (match) return match;
1499
+ if (match) return this.applyHooks(match);
815
1500
  }
816
1501
  if (prefix.endsWith("/")) {
817
1502
  if (path2.startsWith(prefix)) {
818
1503
  const subPath = path2.slice(prefix.length) || "/";
819
1504
  const match = child.find(method, subPath);
820
- if (match) return match;
1505
+ if (match) return this.applyHooks(match);
821
1506
  }
822
1507
  }
823
1508
  }
@@ -835,6 +1520,7 @@ class ShokupanRouter {
835
1520
  };
836
1521
  }
837
1522
  // --- Functional Routing ---
1523
+ requestTimeout;
838
1524
  /**
839
1525
  * Adds a route to the router.
840
1526
  *
@@ -842,12 +1528,25 @@ class ShokupanRouter {
842
1528
  * @param path - URL path
843
1529
  * @param spec - OpenAPI specification for the route
844
1530
  * @param handler - Route handler function
1531
+ * @param requestTimeout - Timeout for this route in milliseconds
845
1532
  */
846
- add({ method, path: path2, spec, handler, regex: customRegex, group }) {
1533
+ add({ method, path: path2, spec, handler, regex: customRegex, group, requestTimeout, renderer }) {
847
1534
  const { regex, keys } = customRegex ? { regex: customRegex, keys: [] } : this.parsePath(path2);
848
1535
  let wrappedHandler = handler;
849
1536
  const routeGuards = [...this.currentGuards];
1537
+ const effectiveTimeout = requestTimeout ?? this.requestTimeout ?? this.rootConfig?.requestTimeout;
1538
+ if (effectiveTimeout !== void 0 && effectiveTimeout > 0) {
1539
+ const originalHandler = wrappedHandler;
1540
+ wrappedHandler = async (ctx) => {
1541
+ if (ctx.server) {
1542
+ ctx.server.timeout(ctx.req, effectiveTimeout / 1e3);
1543
+ }
1544
+ return originalHandler(ctx);
1545
+ };
1546
+ wrappedHandler.originalHandler = originalHandler.originalHandler || originalHandler;
1547
+ }
850
1548
  if (routeGuards.length > 0) {
1549
+ const innerHandler = wrappedHandler;
851
1550
  wrappedHandler = async (ctx) => {
852
1551
  for (const guard of routeGuards) {
853
1552
  let guardPassed = false;
@@ -872,10 +1571,18 @@ class ShokupanRouter {
872
1571
  return ctx.json({ error: "Forbidden" }, 403);
873
1572
  }
874
1573
  }
875
- return handler(ctx);
1574
+ return innerHandler(ctx);
1575
+ };
1576
+ }
1577
+ const effectiveRenderer = renderer ?? this.config?.renderer ?? this.rootConfig?.renderer;
1578
+ if (effectiveRenderer) {
1579
+ const innerHandler = wrappedHandler;
1580
+ wrappedHandler = async (ctx) => {
1581
+ ctx.renderer = effectiveRenderer;
1582
+ return innerHandler(ctx);
876
1583
  };
877
1584
  }
878
- this.routes.push({
1585
+ this[$routes].push({
879
1586
  method,
880
1587
  path: path2,
881
1588
  regex,
@@ -883,7 +1590,9 @@ class ShokupanRouter {
883
1590
  handler: wrappedHandler,
884
1591
  handlerSpec: spec,
885
1592
  group,
886
- guards: routeGuards.length > 0 ? routeGuards : void 0
1593
+ guards: routeGuards.length > 0 ? routeGuards : void 0,
1594
+ requestTimeout: effectiveTimeout
1595
+ // Save for inspection? Or just relying on closure
887
1596
  });
888
1597
  return this;
889
1598
  }
@@ -928,133 +1637,12 @@ class ShokupanRouter {
928
1637
  */
929
1638
  static(uriPath, options) {
930
1639
  const config = typeof options === "string" ? { root: options } : options;
931
- const rootPath = path.resolve(config.root || ".");
932
1640
  const prefix = uriPath.startsWith("/") ? uriPath : "/" + uriPath;
933
1641
  const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
934
- const handler = async (ctx) => {
935
- let relative = ctx.path.slice(normalizedPrefix.length);
936
- if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
937
- if (relative.length === 0) relative = "/";
938
- relative = decodeURIComponent(relative);
939
- const requestPath = path.join(rootPath, relative);
940
- if (!requestPath.startsWith(rootPath)) {
941
- return ctx.json({ error: "Forbidden" }, 403);
942
- }
943
- if (requestPath.includes("\0")) {
944
- return ctx.json({ error: "Forbidden" }, 403);
945
- }
946
- if (config.hooks?.onRequest) {
947
- const res = await config.hooks.onRequest(ctx);
948
- if (res) return res;
949
- }
950
- if (config.exclude) {
951
- for (const pattern2 of config.exclude) {
952
- if (pattern2 instanceof RegExp) {
953
- if (pattern2.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
954
- } else if (typeof pattern2 === "string") {
955
- if (relative.includes(pattern2)) return ctx.json({ error: "Forbidden" }, 403);
956
- }
957
- }
958
- }
959
- if (path.basename(requestPath).startsWith(".")) {
960
- const behavior = config.dotfiles || "ignore";
961
- if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
962
- if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
963
- }
964
- let finalPath = requestPath;
965
- let stats;
966
- try {
967
- stats = await promises.stat(requestPath);
968
- } catch (e) {
969
- if (config.extensions) {
970
- for (const ext of config.extensions) {
971
- const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
972
- try {
973
- const s = await promises.stat(p);
974
- if (s.isFile()) {
975
- finalPath = p;
976
- stats = s;
977
- break;
978
- }
979
- } catch {
980
- }
981
- }
982
- }
983
- if (!stats) return ctx.json({ error: "Not Found" }, 404);
984
- }
985
- if (stats.isDirectory()) {
986
- if (!ctx.path.endsWith("/")) {
987
- const query = ctx.url.search;
988
- return ctx.redirect(ctx.path + "/" + query, 302);
989
- }
990
- let indexes = [];
991
- if (config.index === void 0) {
992
- indexes = ["index.html", "index.htm"];
993
- } else if (Array.isArray(config.index)) {
994
- indexes = config.index;
995
- } else if (config.index) {
996
- indexes = [config.index];
997
- }
998
- let foundIndex = false;
999
- for (const idx of indexes) {
1000
- const idxPath = path.join(finalPath, idx);
1001
- try {
1002
- const idxStats = await promises.stat(idxPath);
1003
- if (idxStats.isFile()) {
1004
- finalPath = idxPath;
1005
- foundIndex = true;
1006
- break;
1007
- }
1008
- } catch {
1009
- }
1010
- }
1011
- if (!foundIndex) {
1012
- if (config.listDirectory) {
1013
- try {
1014
- const files = await promises.readdir(requestPath);
1015
- const listing = eta$1.renderString(`
1016
- <!DOCTYPE html>
1017
- <html>
1018
- <head>
1019
- <title>Index of <%= it.relative %></title>
1020
- <style>
1021
- body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
1022
- ul { list-style: none; padding: 0; }
1023
- li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
1024
- a { text-decoration: none; color: #0066cc; }
1025
- a:hover { text-decoration: underline; }
1026
- h1 { font-size: 1.5rem; margin-bottom: 1rem; }
1027
- </style>
1028
- </head>
1029
- <body>
1030
- <h1>Index of <%= it.relative %></h1>
1031
- <ul>
1032
- <% if (it.relative !== '/') { %>
1033
- <li><a href="../">../</a></li>
1034
- <% } %>
1035
- <% it.files.forEach(function(f) { %>
1036
- <li><a href="<%= f %>"><%= f %></a></li>
1037
- <% }) %>
1038
- </ul>
1039
- </body>
1040
- </html>
1041
- `, { relative, files, join: path.join });
1042
- return new Response(listing, { headers: { "Content-Type": "text/html" } });
1043
- } catch (e) {
1044
- return ctx.json({ error: "Internal Server Error" }, 500);
1045
- }
1046
- } else {
1047
- return ctx.json({ error: "Forbidden" }, 403);
1048
- }
1049
- }
1050
- }
1051
- const file = Bun.file(finalPath);
1052
- let response = new Response(file);
1053
- if (config.hooks?.onResponse) {
1054
- const hooked = await config.hooks.onResponse(ctx, response);
1055
- if (hooked) response = hooked;
1056
- }
1057
- return response;
1642
+ serveStatic(null, config, prefix);
1643
+ const routeHandler = async (ctx) => {
1644
+ const runner = serveStatic(ctx, config, prefix);
1645
+ return runner();
1058
1646
  };
1059
1647
  let groupName = "Static";
1060
1648
  const segments = normalizedPrefix.split("/").filter(Boolean);
@@ -1073,8 +1661,8 @@ class ShokupanRouter {
1073
1661
  const pattern = `^${normalizedPrefix}(/.*)?$`;
1074
1662
  const regex = new RegExp(pattern);
1075
1663
  const displayPath = normalizedPrefix === "/" ? "/*" : normalizedPrefix + "/*";
1076
- this.add({ method: "GET", path: displayPath, handler, spec, regex });
1077
- this.add({ method: "HEAD", path: displayPath, handler, spec, regex });
1664
+ this.add({ method: "GET", path: displayPath, handler: routeHandler, spec, regex });
1665
+ this.add({ method: "HEAD", path: displayPath, handler: routeHandler, spec, regex });
1078
1666
  return this;
1079
1667
  }
1080
1668
  /**
@@ -1109,137 +1697,23 @@ class ShokupanRouter {
1109
1697
  }
1110
1698
  /**
1111
1699
  * Generates an OpenAPI 3.1 Document by recursing through the router and its descendants.
1700
+ * Now includes runtime analysis of handler functions to infer request/response types.
1112
1701
  */
1113
1702
  generateApiSpec(options = {}) {
1114
- const paths = {};
1115
- const tagGroups = /* @__PURE__ */ new Map();
1116
- const defaultTagGroup = options.defaultTagGroup || "General";
1117
- const defaultTagName = options.defaultTag || "Application";
1118
- const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
1119
- let group = currentGroup;
1120
- let tag = defaultTag;
1121
- if (router.config?.group) {
1122
- group = router.config.group;
1123
- }
1124
- if (router.config?.name) {
1125
- tag = router.config.name;
1126
- } else {
1127
- const mountPath = router[$mountPath];
1128
- if (mountPath && mountPath !== "/") {
1129
- const segments = mountPath.split("/").filter(Boolean);
1130
- if (segments.length > 0) {
1131
- const lastSegment = segments[segments.length - 1];
1132
- const humanized = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1133
- tag = humanized;
1134
- }
1135
- }
1136
- }
1137
- if (!tagGroups.has(group)) {
1138
- tagGroups.set(group, /* @__PURE__ */ new Set());
1139
- }
1140
- for (const route of router.routes) {
1141
- const routeGroup = route.group || group;
1142
- const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
1143
- const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
1144
- let fullPath = cleanPrefix + cleanSubPath || "/";
1145
- fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
1146
- if (!paths[fullPath]) {
1147
- paths[fullPath] = {};
1148
- }
1149
- const operation = {
1150
- responses: {
1151
- 200: { description: "OK" }
1152
- }
1153
- };
1154
- if (route.keys.length > 0) {
1155
- operation.parameters = route.keys.map((key) => ({
1156
- name: key,
1157
- in: "path",
1158
- required: true,
1159
- schema: { type: "string" }
1160
- }));
1161
- }
1162
- if (route.guards) {
1163
- for (const guard of route.guards) {
1164
- if (guard.spec) {
1165
- deepMerge(operation, guard.spec);
1166
- }
1167
- }
1168
- }
1169
- if (route.handlerSpec) {
1170
- deepMerge(operation, route.handlerSpec);
1171
- }
1172
- if (!operation.tags || operation.tags.length === 0) {
1173
- operation.tags = [tag];
1174
- }
1175
- if (operation.tags) {
1176
- operation.tags = Array.from(new Set(operation.tags));
1177
- for (const t of operation.tags) {
1178
- if (!tagGroups.has(routeGroup)) {
1179
- tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
1180
- }
1181
- tagGroups.get(routeGroup)?.add(t);
1182
- }
1183
- }
1184
- const methodLower = route.method.toLowerCase();
1185
- if (methodLower === "all") {
1186
- ["get", "post", "put", "delete", "patch"].forEach((m) => {
1187
- if (!paths[fullPath][m]) {
1188
- paths[fullPath][m] = { ...operation };
1189
- }
1190
- });
1191
- } else {
1192
- paths[fullPath][methodLower] = operation;
1193
- }
1194
- }
1195
- for (const controller of router[$childControllers]) {
1196
- const mountPath = controller[$mountPath] || "";
1197
- prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
1198
- mountPath.startsWith("/") ? mountPath : "/" + mountPath;
1199
- const controllerName = controller.constructor.name || "UnknownController";
1200
- tagGroups.get(group)?.add(controllerName);
1201
- }
1202
- for (const child of router[$childRouters]) {
1203
- const mountPath = child[$mountPath];
1204
- const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
1205
- const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
1206
- const nextPrefix = cleanPrefix + cleanMount || "/";
1207
- collect(child, nextPrefix, group, tag);
1208
- }
1209
- };
1210
- collect(this);
1211
- const xTagGroups = [];
1212
- for (const [name, tags] of tagGroups) {
1213
- xTagGroups.push({
1214
- name,
1215
- tags: Array.from(tags).sort()
1216
- });
1217
- }
1218
- return {
1219
- openapi: "3.1.0",
1220
- info: {
1221
- title: "Shokupan API",
1222
- version: "1.0.0",
1223
- ...options.info
1224
- },
1225
- paths,
1226
- components: options.components,
1227
- servers: options.servers,
1228
- tags: options.tags,
1229
- externalDocs: options.externalDocs,
1230
- "x-tagGroups": xTagGroups
1231
- };
1703
+ return generateOpenApi(this, options);
1232
1704
  }
1233
1705
  }
1234
1706
  const defaults = {
1235
1707
  port: 3e3,
1236
1708
  hostname: "localhost",
1237
1709
  development: process.env.NODE_ENV !== "production",
1238
- enableAsyncLocalStorage: false
1710
+ enableAsyncLocalStorage: false,
1711
+ reusePort: false
1239
1712
  };
1240
1713
  api.trace.getTracer("shokupan.application");
1241
1714
  class Shokupan extends ShokupanRouter {
1242
1715
  applicationConfig = {};
1716
+ openApiSpec;
1243
1717
  middleware = [];
1244
1718
  get logger() {
1245
1719
  return this.applicationConfig.logger;
@@ -1257,24 +1731,41 @@ class Shokupan extends ShokupanRouter {
1257
1731
  this.middleware.push(middleware);
1258
1732
  return this;
1259
1733
  }
1734
+ startupHooks = [];
1735
+ /**
1736
+ * Registers a callback to be executed before the server starts listening.
1737
+ */
1738
+ onStart(callback) {
1739
+ this.startupHooks.push(callback);
1740
+ return this;
1741
+ }
1260
1742
  /**
1261
1743
  * Starts the application server.
1262
1744
  *
1263
1745
  * @param port - The port to listen on. If not specified, the port from the configuration is used. If that is not specified, port 3000 is used.
1264
1746
  * @returns The server instance.
1265
1747
  */
1266
- listen(port) {
1748
+ async listen(port) {
1267
1749
  const finalPort = port ?? this.applicationConfig.port ?? 3e3;
1268
1750
  if (finalPort < 0 || finalPort > 65535) {
1269
1751
  throw new Error("Invalid port number");
1270
1752
  }
1753
+ for (const hook of this.startupHooks) {
1754
+ await hook();
1755
+ }
1756
+ if (this.applicationConfig.enableOpenApiGen) {
1757
+ this.openApiSpec = await generateOpenApi(this);
1758
+ }
1271
1759
  if (port === 0 && process.platform === "linux") ;
1272
- const server = Bun.serve({
1760
+ const serveOptions = {
1273
1761
  port: finalPort,
1274
1762
  hostname: this.applicationConfig.hostname,
1275
1763
  development: this.applicationConfig.development,
1276
- fetch: this.fetch.bind(this)
1277
- });
1764
+ fetch: this.fetch.bind(this),
1765
+ reusePort: this.applicationConfig.reusePort,
1766
+ idleTimeout: this.applicationConfig.readTimeout ? this.applicationConfig.readTimeout / 1e3 : void 0
1767
+ };
1768
+ const server = this.applicationConfig.serverFactory ? await this.applicationConfig.serverFactory(serveOptions) : Bun.serve(serveOptions);
1278
1769
  console.log(`Shokupan server listening on http://${server.hostname}:${server.port}`);
1279
1770
  return server;
1280
1771
  }
@@ -1325,9 +1816,10 @@ class Shokupan extends ShokupanRouter {
1325
1816
  * This logic contains the middleware chain and router dispatch.
1326
1817
  *
1327
1818
  * @param req - The request to handle.
1819
+ * @param server - The server instance.
1328
1820
  * @returns The response to send.
1329
1821
  */
1330
- async fetch(req) {
1822
+ async fetch(req, server) {
1331
1823
  const tracer2 = api.trace.getTracer("shokupan.application");
1332
1824
  const store = asyncContext.getStore();
1333
1825
  const attrs = {
@@ -1344,10 +1836,13 @@ class Shokupan extends ShokupanRouter {
1344
1836
  ctxMap.set("request", req);
1345
1837
  const runCallback = () => {
1346
1838
  const request = req;
1839
+ const ctx2 = new ShokupanContext(request, server, void 0, this);
1347
1840
  const handle = async () => {
1348
- const ctx2 = new ShokupanContext(request);
1349
- const fn = compose(this.middleware);
1350
1841
  try {
1842
+ if (this.applicationConfig.hooks?.onRequestStart) {
1843
+ await this.applicationConfig.hooks.onRequestStart(ctx2);
1844
+ }
1845
+ const fn = compose(this.middleware);
1351
1846
  const result = await fn(ctx2, async () => {
1352
1847
  const match = this.find(req.method, ctx2.path);
1353
1848
  if (match) {
@@ -1356,17 +1851,24 @@ class Shokupan extends ShokupanRouter {
1356
1851
  }
1357
1852
  return null;
1358
1853
  });
1854
+ let response;
1359
1855
  if (result instanceof Response) {
1360
- return result;
1361
- }
1362
- if (result === null || result === void 0) {
1856
+ response = result;
1857
+ } else if (result === null || result === void 0) {
1363
1858
  span.setAttribute("http.status_code", 404);
1364
- return ctx2.text("Not Found", 404);
1859
+ response = ctx2.text("Not Found", 404);
1860
+ } else if (typeof result === "object") {
1861
+ response = ctx2.json(result);
1862
+ } else {
1863
+ response = ctx2.text(String(result));
1864
+ }
1865
+ if (this.applicationConfig.hooks?.onRequestEnd) {
1866
+ await this.applicationConfig.hooks.onRequestEnd(ctx2);
1365
1867
  }
1366
- if (typeof result === "object") {
1367
- return ctx2.json(result);
1868
+ if (this.applicationConfig.hooks?.onResponseStart) {
1869
+ await this.applicationConfig.hooks.onResponseStart(ctx2, response);
1368
1870
  }
1369
- return ctx2.text(String(result));
1871
+ return response;
1370
1872
  } catch (err) {
1371
1873
  console.error(err);
1372
1874
  span.recordException(err);
@@ -1374,10 +1876,46 @@ class Shokupan extends ShokupanRouter {
1374
1876
  const status = err.status || err.statusCode || 500;
1375
1877
  const body = { error: err.message || "Internal Server Error" };
1376
1878
  if (err.errors) body.errors = err.errors;
1879
+ if (this.applicationConfig.hooks?.onError) {
1880
+ try {
1881
+ await this.applicationConfig.hooks.onError(err, ctx2);
1882
+ } catch (hookErr) {
1883
+ console.error("Error in onError hook:", hookErr);
1884
+ }
1885
+ }
1377
1886
  return ctx2.json(body, status);
1378
1887
  }
1379
1888
  };
1380
- return handle().finally(() => span.end());
1889
+ let executionPromise = handle();
1890
+ const timeoutMs = this.applicationConfig.requestTimeout;
1891
+ if (timeoutMs && timeoutMs > 0 && this.applicationConfig.hooks?.onRequestTimeout) {
1892
+ let timeoutId;
1893
+ const timeoutPromise = new Promise((_, reject) => {
1894
+ timeoutId = setTimeout(async () => {
1895
+ try {
1896
+ if (this.applicationConfig.hooks?.onRequestTimeout) {
1897
+ await this.applicationConfig.hooks.onRequestTimeout(ctx2);
1898
+ }
1899
+ } catch (e) {
1900
+ console.error("Error in onRequestTimeout hook:", e);
1901
+ }
1902
+ reject(new Error("Request Timeout"));
1903
+ }, timeoutMs);
1904
+ });
1905
+ executionPromise = Promise.race([executionPromise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
1906
+ }
1907
+ return executionPromise.catch((err) => {
1908
+ if (err.message === "Request Timeout") {
1909
+ return ctx2.text("Request Timeout", 408);
1910
+ }
1911
+ console.error("Unexpected error in request execution:", err);
1912
+ return ctx2.text("Internal Server Error", 500);
1913
+ }).then(async (res) => {
1914
+ if (this.applicationConfig.hooks?.onResponseEnd) {
1915
+ await this.applicationConfig.hooks.onResponseEnd(ctx2, res);
1916
+ }
1917
+ return res;
1918
+ }).finally(() => span.end());
1381
1919
  };
1382
1920
  if (this.applicationConfig.enableAsyncLocalStorage) {
1383
1921
  return asyncContext.run(ctxMap, runCallback);
@@ -1435,8 +1973,8 @@ class AuthPlugin extends ShokupanRouter {
1435
1973
  init() {
1436
1974
  for (const [providerName, providerConfig] of Object.entries(this.authConfig.providers)) {
1437
1975
  if (!providerConfig) continue;
1438
- const provider2 = this.getProviderInstance(providerName, providerConfig);
1439
- if (!provider2) {
1976
+ const provider = this.getProviderInstance(providerName, providerConfig);
1977
+ if (!provider) {
1440
1978
  continue;
1441
1979
  }
1442
1980
  this.get(`/auth/${providerName}/login`, async (ctx) => {
@@ -1444,15 +1982,15 @@ class AuthPlugin extends ShokupanRouter {
1444
1982
  const codeVerifier = providerName === "google" || providerName === "microsoft" || providerName === "auth0" || providerName === "okta" ? arctic.generateCodeVerifier() : void 0;
1445
1983
  const scopes = providerConfig.scopes || [];
1446
1984
  let url;
1447
- if (provider2 instanceof arctic.GitHub) {
1448
- url = await provider2.createAuthorizationURL(state, scopes);
1449
- } else if (provider2 instanceof arctic.Google || provider2 instanceof arctic.MicrosoftEntraId || provider2 instanceof arctic.Auth0 || provider2 instanceof arctic.Okta) {
1450
- url = await provider2.createAuthorizationURL(state, codeVerifier, scopes);
1451
- } else if (provider2 instanceof arctic.Apple) {
1452
- url = await provider2.createAuthorizationURL(state, scopes);
1453
- } else if (provider2 instanceof arctic.OAuth2Client) {
1985
+ if (provider instanceof arctic.GitHub) {
1986
+ url = await provider.createAuthorizationURL(state, scopes);
1987
+ } else if (provider instanceof arctic.Google || provider instanceof arctic.MicrosoftEntraId || provider instanceof arctic.Auth0 || provider instanceof arctic.Okta) {
1988
+ url = await provider.createAuthorizationURL(state, codeVerifier, scopes);
1989
+ } else if (provider instanceof arctic.Apple) {
1990
+ url = await provider.createAuthorizationURL(state, scopes);
1991
+ } else if (provider instanceof arctic.OAuth2Client) {
1454
1992
  if (!providerConfig.authUrl) return ctx.text("Config error: authUrl required for oauth2", 500);
1455
- url = await provider2.createAuthorizationURL(providerConfig.authUrl, state, scopes);
1993
+ url = await provider.createAuthorizationURL(providerConfig.authUrl, state, scopes);
1456
1994
  } else {
1457
1995
  return ctx.text("Provider config error", 500);
1458
1996
  }
@@ -1475,19 +2013,19 @@ class AuthPlugin extends ShokupanRouter {
1475
2013
  try {
1476
2014
  let tokens;
1477
2015
  let idToken;
1478
- if (provider2 instanceof arctic.GitHub) {
1479
- tokens = await provider2.validateAuthorizationCode(code);
1480
- } else if (provider2 instanceof arctic.Google || provider2 instanceof arctic.MicrosoftEntraId) {
2016
+ if (provider instanceof arctic.GitHub) {
2017
+ tokens = await provider.validateAuthorizationCode(code);
2018
+ } else if (provider instanceof arctic.Google || provider instanceof arctic.MicrosoftEntraId) {
1481
2019
  if (!storedVerifier) return ctx.text("Missing verifier", 400);
1482
- tokens = await provider2.validateAuthorizationCode(code, storedVerifier);
1483
- } else if (provider2 instanceof arctic.Auth0 || provider2 instanceof arctic.Okta) {
1484
- tokens = await provider2.validateAuthorizationCode(code, storedVerifier || "");
1485
- } else if (provider2 instanceof arctic.Apple) {
1486
- tokens = await provider2.validateAuthorizationCode(code);
2020
+ tokens = await provider.validateAuthorizationCode(code, storedVerifier);
2021
+ } else if (provider instanceof arctic.Auth0 || provider instanceof arctic.Okta) {
2022
+ tokens = await provider.validateAuthorizationCode(code, storedVerifier || "");
2023
+ } else if (provider instanceof arctic.Apple) {
2024
+ tokens = await provider.validateAuthorizationCode(code);
1487
2025
  idToken = tokens.idToken;
1488
- } else if (provider2 instanceof arctic.OAuth2Client) {
2026
+ } else if (provider instanceof arctic.OAuth2Client) {
1489
2027
  if (!providerConfig.tokenUrl) return ctx.text("Config error: tokenUrl required for oauth2", 500);
1490
- tokens = await provider2.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
2028
+ tokens = await provider.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
1491
2029
  }
1492
2030
  const accessToken = tokens.accessToken || tokens.access_token;
1493
2031
  const user = await this.fetchUser(providerName, accessToken, providerConfig, idToken);
@@ -1504,9 +2042,9 @@ class AuthPlugin extends ShokupanRouter {
1504
2042
  });
1505
2043
  }
1506
2044
  }
1507
- async fetchUser(provider2, token, config, idToken) {
1508
- let user = { id: "unknown", provider: provider2 };
1509
- if (provider2 === "github") {
2045
+ async fetchUser(provider, token, config, idToken) {
2046
+ let user = { id: "unknown", provider };
2047
+ if (provider === "github") {
1510
2048
  const res = await fetch("https://api.github.com/user", {
1511
2049
  headers: { Authorization: `Bearer ${token}` }
1512
2050
  });
@@ -1516,10 +2054,10 @@ class AuthPlugin extends ShokupanRouter {
1516
2054
  name: data.name || data.login,
1517
2055
  email: data.email,
1518
2056
  picture: data.avatar_url,
1519
- provider: provider2,
2057
+ provider,
1520
2058
  raw: data
1521
2059
  };
1522
- } else if (provider2 === "google") {
2060
+ } else if (provider === "google") {
1523
2061
  const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
1524
2062
  headers: { Authorization: `Bearer ${token}` }
1525
2063
  });
@@ -1529,10 +2067,10 @@ class AuthPlugin extends ShokupanRouter {
1529
2067
  name: data.name,
1530
2068
  email: data.email,
1531
2069
  picture: data.picture,
1532
- provider: provider2,
2070
+ provider,
1533
2071
  raw: data
1534
2072
  };
1535
- } else if (provider2 === "microsoft") {
2073
+ } else if (provider === "microsoft") {
1536
2074
  const res = await fetch("https://graph.microsoft.com/v1.0/me", {
1537
2075
  headers: { Authorization: `Bearer ${token}` }
1538
2076
  });
@@ -1541,12 +2079,12 @@ class AuthPlugin extends ShokupanRouter {
1541
2079
  id: data.id,
1542
2080
  name: data.displayName,
1543
2081
  email: data.mail || data.userPrincipalName,
1544
- provider: provider2,
2082
+ provider,
1545
2083
  raw: data
1546
2084
  };
1547
- } else if (provider2 === "auth0" || provider2 === "okta") {
2085
+ } else if (provider === "auth0" || provider === "okta") {
1548
2086
  const domain = config.domain.startsWith("http") ? config.domain : `https://${config.domain}`;
1549
- const endpoint = provider2 === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
2087
+ const endpoint = provider === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
1550
2088
  const res = await fetch(endpoint, {
1551
2089
  headers: { Authorization: `Bearer ${token}` }
1552
2090
  });
@@ -1556,20 +2094,20 @@ class AuthPlugin extends ShokupanRouter {
1556
2094
  name: data.name,
1557
2095
  email: data.email,
1558
2096
  picture: data.picture,
1559
- provider: provider2,
2097
+ provider,
1560
2098
  raw: data
1561
2099
  };
1562
- } else if (provider2 === "apple") {
2100
+ } else if (provider === "apple") {
1563
2101
  if (idToken) {
1564
2102
  const payload = jose__namespace.decodeJwt(idToken);
1565
2103
  user = {
1566
2104
  id: payload.sub,
1567
2105
  email: payload["email"],
1568
- provider: provider2,
2106
+ provider,
1569
2107
  raw: payload
1570
2108
  };
1571
2109
  }
1572
- } else if (provider2 === "oauth2") {
2110
+ } else if (provider === "oauth2") {
1573
2111
  if (config.userInfoUrl) {
1574
2112
  const res = await fetch(config.userInfoUrl, {
1575
2113
  headers: { Authorization: `Bearer ${token}` }
@@ -1580,7 +2118,7 @@ class AuthPlugin extends ShokupanRouter {
1580
2118
  name: data.name,
1581
2119
  email: data.email,
1582
2120
  picture: data.picture,
1583
- provider: provider2,
2121
+ provider,
1584
2122
  raw: data
1585
2123
  };
1586
2124
  }
@@ -1721,6 +2259,66 @@ function Cors(options = {}) {
1721
2259
  return response;
1722
2260
  };
1723
2261
  }
2262
+ function useExpress(expressMiddleware) {
2263
+ return async (ctx, next) => {
2264
+ return new Promise((resolve, reject) => {
2265
+ const reqStore = {
2266
+ method: ctx.method,
2267
+ url: ctx.url.pathname + ctx.url.search,
2268
+ path: ctx.url.pathname,
2269
+ query: ctx.query,
2270
+ headers: ctx.headers,
2271
+ get: (name) => ctx.headers.get(name)
2272
+ };
2273
+ const req = new Proxy(ctx.request, {
2274
+ get(target, prop) {
2275
+ if (prop in reqStore) return reqStore[prop];
2276
+ const val = target[prop];
2277
+ if (typeof val === "function") return val.bind(target);
2278
+ return val;
2279
+ },
2280
+ set(target, prop, value) {
2281
+ reqStore[prop] = value;
2282
+ ctx.state[prop] = value;
2283
+ return true;
2284
+ }
2285
+ });
2286
+ const res = {
2287
+ locals: {},
2288
+ statusCode: 200,
2289
+ setHeader: (name, value) => {
2290
+ ctx.response.headers.set(name, value);
2291
+ },
2292
+ set: (name, value) => {
2293
+ ctx.response.headers.set(name, value);
2294
+ },
2295
+ end: (chunk) => {
2296
+ resolve(new Response(chunk, { status: res.statusCode }));
2297
+ },
2298
+ status: (code) => {
2299
+ res.statusCode = code;
2300
+ return res;
2301
+ },
2302
+ send: (body) => {
2303
+ let content = body;
2304
+ if (typeof body === "object") content = JSON.stringify(body);
2305
+ resolve(new Response(content, { status: res.statusCode }));
2306
+ },
2307
+ json: (body) => {
2308
+ resolve(Response.json(body, { status: res.statusCode }));
2309
+ }
2310
+ };
2311
+ try {
2312
+ expressMiddleware(req, res, (err) => {
2313
+ if (err) return reject(err);
2314
+ resolve(next());
2315
+ });
2316
+ } catch (err) {
2317
+ reject(err);
2318
+ }
2319
+ });
2320
+ };
2321
+ }
1724
2322
  function RateLimit(options = {}) {
1725
2323
  const windowMs = options.windowMs || 60 * 1e3;
1726
2324
  const max = options.max || 5;
@@ -1811,10 +2409,43 @@ class ScalarPlugin extends ShokupanRouter {
1811
2409
  this.get("/scalar.js", (ctx) => {
1812
2410
  return ctx.file(__dirname + "/../../node_modules/@scalar/api-reference/dist/browser/standalone.js");
1813
2411
  });
1814
- this.get("/openapi.json", (ctx) => {
1815
- return (this.root || this).generateApiSpec();
2412
+ this.get("/openapi.json", async (ctx) => {
2413
+ let spec;
2414
+ if (this.root.openApiSpec) {
2415
+ try {
2416
+ spec = structuredClone(this.root.openApiSpec);
2417
+ } catch (e) {
2418
+ spec = Object.assign({}, this.root.openApiSpec);
2419
+ }
2420
+ } else {
2421
+ spec = await (this.root || this).generateApiSpec();
2422
+ }
2423
+ if (this.pluginOptions.baseDocument) {
2424
+ deepMerge(spec, this.pluginOptions.baseDocument);
2425
+ }
2426
+ return ctx.json(spec);
1816
2427
  });
1817
2428
  }
2429
+ // New lifecycle method to be called by router.mount
2430
+ onMount(parent) {
2431
+ if (parent.onStart) {
2432
+ parent.onStart(async () => {
2433
+ if (this.pluginOptions.enableStaticAnalysis) {
2434
+ try {
2435
+ const entrypoint = process.argv[1];
2436
+ console.log(`[ScalarPlugin] Running eager static analysis on entrypoint: ${entrypoint}`);
2437
+ const analyzer = new openapiAnalyzer.OpenAPIAnalyzer(process.cwd(), entrypoint);
2438
+ let staticSpec = await analyzer.analyze();
2439
+ if (!this.pluginOptions.baseDocument) this.pluginOptions.baseDocument = {};
2440
+ deepMerge(this.pluginOptions.baseDocument, staticSpec);
2441
+ console.log("[ScalarPlugin] Static analysis completed successfully.");
2442
+ } catch (err) {
2443
+ console.error("[ScalarPlugin] Failed to run static analysis:", err);
2444
+ }
2445
+ }
2446
+ });
2447
+ }
2448
+ }
1818
2449
  }
1819
2450
  function SecurityHeaders(options = {}) {
1820
2451
  return async (ctx, next) => {
@@ -2174,6 +2805,30 @@ async function validateValibotWrapper(wrapper, data) {
2174
2805
  }
2175
2806
  return result.output;
2176
2807
  }
2808
+ function isClass(schema) {
2809
+ try {
2810
+ if (typeof schema === "function" && /^\s*class\s+/.test(schema.toString())) {
2811
+ return true;
2812
+ }
2813
+ return typeof schema === "function" && schema.prototype && schema.name;
2814
+ } catch {
2815
+ return false;
2816
+ }
2817
+ }
2818
+ async function validateClassValidator(schema, data) {
2819
+ const object = classTransformer.plainToInstance(schema, data);
2820
+ try {
2821
+ await classValidator.validateOrReject(object);
2822
+ return object;
2823
+ } catch (errors) {
2824
+ const formattedErrors = Array.isArray(errors) ? errors.map((err) => ({
2825
+ property: err.property,
2826
+ constraints: err.constraints,
2827
+ children: err.children
2828
+ })) : errors;
2829
+ throw new ValidationError(formattedErrors);
2830
+ }
2831
+ }
2177
2832
  const safelyGetBody = async (ctx) => {
2178
2833
  const req = ctx.req;
2179
2834
  if (req._bodyParsed) {
@@ -2205,21 +2860,38 @@ const safelyGetBody = async (ctx) => {
2205
2860
  };
2206
2861
  function validate(config) {
2207
2862
  return async (ctx, next) => {
2863
+ const dataToValidate = {};
2864
+ if (config.params) dataToValidate.params = ctx.params;
2865
+ let queryObj;
2866
+ if (config.query) {
2867
+ const url = new URL(ctx.req.url);
2868
+ queryObj = Object.fromEntries(url.searchParams.entries());
2869
+ dataToValidate.query = queryObj;
2870
+ }
2871
+ if (config.headers) dataToValidate.headers = Object.fromEntries(ctx.req.headers.entries());
2872
+ let body;
2873
+ if (config.body) {
2874
+ body = await safelyGetBody(ctx);
2875
+ dataToValidate.body = body;
2876
+ }
2877
+ if (ctx.app?.applicationConfig.hooks?.beforeValidate) {
2878
+ await ctx.app.applicationConfig.hooks.beforeValidate(ctx, dataToValidate);
2879
+ }
2208
2880
  if (config.params) {
2209
2881
  ctx.params = await runValidation(config.params, ctx.params);
2210
2882
  }
2211
- if (config.query) {
2212
- const url = new URL(ctx.req.url);
2213
- const queryObj = Object.fromEntries(url.searchParams.entries());
2214
- await runValidation(config.query, queryObj);
2883
+ let validQuery;
2884
+ if (config.query && queryObj) {
2885
+ validQuery = await runValidation(config.query, queryObj);
2215
2886
  }
2216
2887
  if (config.headers) {
2217
2888
  const headersObj = Object.fromEntries(ctx.req.headers.entries());
2218
2889
  await runValidation(config.headers, headersObj);
2219
2890
  }
2891
+ let validBody;
2220
2892
  if (config.body) {
2221
- const body = await safelyGetBody(ctx);
2222
- const validBody = await runValidation(config.body, body);
2893
+ const b = body ?? await safelyGetBody(ctx);
2894
+ validBody = await runValidation(config.body, b);
2223
2895
  const req = ctx.req;
2224
2896
  req._bodyValue = validBody;
2225
2897
  Object.defineProperty(req, "json", {
@@ -2228,6 +2900,13 @@ function validate(config) {
2228
2900
  });
2229
2901
  ctx.body = validBody;
2230
2902
  }
2903
+ if (ctx.app?.applicationConfig.hooks?.afterValidate) {
2904
+ const validatedData = { ...dataToValidate };
2905
+ if (config.params) validatedData.params = ctx.params;
2906
+ if (config.query) validatedData.query = validQuery;
2907
+ if (config.body) validatedData.body = validBody;
2908
+ await ctx.app.applicationConfig.hooks.afterValidate(ctx, validatedData);
2909
+ }
2231
2910
  return next();
2232
2911
  };
2233
2912
  }
@@ -2244,6 +2923,18 @@ async function runValidation(schema, data) {
2244
2923
  if (isValibotWrapper(schema)) {
2245
2924
  return validateValibotWrapper(schema, data);
2246
2925
  }
2926
+ if (isClass(schema)) {
2927
+ return validateClassValidator(schema, data);
2928
+ }
2929
+ if (isTypeBox(schema)) {
2930
+ return validateTypeBox(schema, data);
2931
+ }
2932
+ if (isAjv(schema)) {
2933
+ return validateAjv(schema, data);
2934
+ }
2935
+ if (isValibotWrapper(schema)) {
2936
+ return validateValibotWrapper(schema, data);
2937
+ }
2247
2938
  if (typeof schema === "function") {
2248
2939
  return schema(data);
2249
2940
  }
@@ -2262,6 +2953,8 @@ exports.$mountPath = $mountPath;
2262
2953
  exports.$parent = $parent;
2263
2954
  exports.$routeArgs = $routeArgs;
2264
2955
  exports.$routeMethods = $routeMethods;
2956
+ exports.$routeSpec = $routeSpec;
2957
+ exports.$routes = $routes;
2265
2958
  exports.All = All;
2266
2959
  exports.AuthPlugin = AuthPlugin;
2267
2960
  exports.Body = Body;
@@ -2297,9 +2990,11 @@ exports.ShokupanContext = ShokupanContext;
2297
2990
  exports.ShokupanRequest = ShokupanRequest;
2298
2991
  exports.ShokupanResponse = ShokupanResponse;
2299
2992
  exports.ShokupanRouter = ShokupanRouter;
2993
+ exports.Spec = Spec;
2300
2994
  exports.Use = Use;
2301
2995
  exports.ValidationError = ValidationError;
2302
2996
  exports.compose = compose;
2997
+ exports.useExpress = useExpress;
2303
2998
  exports.valibot = valibot;
2304
2999
  exports.validate = validate;
2305
3000
  //# sourceMappingURL=index.cjs.map