@modularizer/plat 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.
Files changed (287) hide show
  1. package/README.md +574 -0
  2. package/bin/z2.js +5 -0
  3. package/dist/args/coerce.d.ts +54 -0
  4. package/dist/args/coerce.d.ts.map +1 -0
  5. package/dist/args/coerce.js +236 -0
  6. package/dist/args/coerce.js.map +1 -0
  7. package/dist/args/index.d.ts +2 -0
  8. package/dist/args/index.d.ts.map +1 -0
  9. package/dist/args/index.js +2 -0
  10. package/dist/args/index.js.map +1 -0
  11. package/dist/args/scalars.d.ts +87 -0
  12. package/dist/args/scalars.d.ts.map +1 -0
  13. package/dist/args/scalars.js +22 -0
  14. package/dist/args/scalars.js.map +1 -0
  15. package/dist/args/validate.d.ts +23 -0
  16. package/dist/args/validate.d.ts.map +1 -0
  17. package/dist/args/validate.js +185 -0
  18. package/dist/args/validate.js.map +1 -0
  19. package/dist/args/z2.d.ts +27 -0
  20. package/dist/args/z2.d.ts.map +1 -0
  21. package/dist/args/z2.js +24 -0
  22. package/dist/args/z2.js.map +1 -0
  23. package/dist/cli/commands/api-proxy.d.ts +9 -0
  24. package/dist/cli/commands/api-proxy.d.ts.map +1 -0
  25. package/dist/cli/commands/api-proxy.js +292 -0
  26. package/dist/cli/commands/api-proxy.js.map +1 -0
  27. package/dist/cli/commands/env.d.ts +32 -0
  28. package/dist/cli/commands/env.d.ts.map +1 -0
  29. package/dist/cli/commands/env.js +157 -0
  30. package/dist/cli/commands/env.js.map +1 -0
  31. package/dist/cli/commands/gen.d.ts +2 -0
  32. package/dist/cli/commands/gen.d.ts.map +1 -0
  33. package/dist/cli/commands/gen.js +88 -0
  34. package/dist/cli/commands/gen.js.map +1 -0
  35. package/dist/cli/commands/init-project.d.ts +9 -0
  36. package/dist/cli/commands/init-project.d.ts.map +1 -0
  37. package/dist/cli/commands/init-project.js +143 -0
  38. package/dist/cli/commands/init-project.js.map +1 -0
  39. package/dist/cli/commands/jwt-cmd.d.ts +10 -0
  40. package/dist/cli/commands/jwt-cmd.d.ts.map +1 -0
  41. package/dist/cli/commands/jwt-cmd.js +33 -0
  42. package/dist/cli/commands/jwt-cmd.js.map +1 -0
  43. package/dist/cli/commands/run.d.ts +5 -0
  44. package/dist/cli/commands/run.d.ts.map +1 -0
  45. package/dist/cli/commands/run.js +31 -0
  46. package/dist/cli/commands/run.js.map +1 -0
  47. package/dist/cli/commands/serve.d.ts +2 -0
  48. package/dist/cli/commands/serve.d.ts.map +1 -0
  49. package/dist/cli/commands/serve.js +43 -0
  50. package/dist/cli/commands/serve.js.map +1 -0
  51. package/dist/cli/commands/watch.d.ts +5 -0
  52. package/dist/cli/commands/watch.d.ts.map +1 -0
  53. package/dist/cli/commands/watch.js +119 -0
  54. package/dist/cli/commands/watch.js.map +1 -0
  55. package/dist/cli/index.d.ts +11 -0
  56. package/dist/cli/index.d.ts.map +1 -0
  57. package/dist/cli/index.js +11 -0
  58. package/dist/cli/index.js.map +1 -0
  59. package/dist/cli/jwt-helper.d.ts +32 -0
  60. package/dist/cli/jwt-helper.d.ts.map +1 -0
  61. package/dist/cli/jwt-helper.js +84 -0
  62. package/dist/cli/jwt-helper.js.map +1 -0
  63. package/dist/cli/proxy.d.ts +40 -0
  64. package/dist/cli/proxy.d.ts.map +1 -0
  65. package/dist/cli/proxy.js +171 -0
  66. package/dist/cli/proxy.js.map +1 -0
  67. package/dist/cli/runtime.d.ts +17 -0
  68. package/dist/cli/runtime.d.ts.map +1 -0
  69. package/dist/cli/runtime.js +286 -0
  70. package/dist/cli/runtime.js.map +1 -0
  71. package/dist/cli/spec-source.d.ts +13 -0
  72. package/dist/cli/spec-source.d.ts.map +1 -0
  73. package/dist/cli/spec-source.js +207 -0
  74. package/dist/cli/spec-source.js.map +1 -0
  75. package/dist/cli/z2.d.ts +7 -0
  76. package/dist/cli/z2.d.ts.map +1 -0
  77. package/dist/cli/z2.js +93 -0
  78. package/dist/cli/z2.js.map +1 -0
  79. package/dist/client/file-transport-plugin.d.ts +25 -0
  80. package/dist/client/file-transport-plugin.d.ts.map +1 -0
  81. package/dist/client/file-transport-plugin.js +80 -0
  82. package/dist/client/file-transport-plugin.js.map +1 -0
  83. package/dist/client/http-transport-plugin.d.ts +27 -0
  84. package/dist/client/http-transport-plugin.d.ts.map +1 -0
  85. package/dist/client/http-transport-plugin.js +48 -0
  86. package/dist/client/http-transport-plugin.js.map +1 -0
  87. package/dist/client/index.d.ts +6 -0
  88. package/dist/client/index.d.ts.map +1 -0
  89. package/dist/client/index.js +6 -0
  90. package/dist/client/index.js.map +1 -0
  91. package/dist/client/openapi-client.d.ts +332 -0
  92. package/dist/client/openapi-client.d.ts.map +1 -0
  93. package/dist/client/openapi-client.js +879 -0
  94. package/dist/client/openapi-client.js.map +1 -0
  95. package/dist/client/proxy.d.ts +5 -0
  96. package/dist/client/proxy.d.ts.map +1 -0
  97. package/dist/client/proxy.js +353 -0
  98. package/dist/client/proxy.js.map +1 -0
  99. package/dist/client/request-builder.d.ts +4 -0
  100. package/dist/client/request-builder.d.ts.map +1 -0
  101. package/dist/client/request-builder.js +88 -0
  102. package/dist/client/request-builder.js.map +1 -0
  103. package/dist/client/rpc-transport-plugin.d.ts +17 -0
  104. package/dist/client/rpc-transport-plugin.d.ts.map +1 -0
  105. package/dist/client/rpc-transport-plugin.js +69 -0
  106. package/dist/client/rpc-transport-plugin.js.map +1 -0
  107. package/dist/client/tools.d.ts +69 -0
  108. package/dist/client/tools.d.ts.map +1 -0
  109. package/dist/client/tools.js +122 -0
  110. package/dist/client/tools.js.map +1 -0
  111. package/dist/client/transport-plugin.d.ts +62 -0
  112. package/dist/client/transport-plugin.d.ts.map +1 -0
  113. package/dist/client/transport-plugin.js +40 -0
  114. package/dist/client/transport-plugin.js.map +1 -0
  115. package/dist/index.d.ts +9 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +8 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/logging.d.ts +9 -0
  120. package/dist/logging.d.ts.map +1 -0
  121. package/dist/logging.js +29 -0
  122. package/dist/logging.js.map +1 -0
  123. package/dist/rpc.d.ts +39 -0
  124. package/dist/rpc.d.ts.map +1 -0
  125. package/dist/rpc.js +2 -0
  126. package/dist/rpc.js.map +1 -0
  127. package/dist/server/auth/index.d.ts +4 -0
  128. package/dist/server/auth/index.d.ts.map +1 -0
  129. package/dist/server/auth/index.js +2 -0
  130. package/dist/server/auth/index.js.map +1 -0
  131. package/dist/server/auth/jwt.d.ts +27 -0
  132. package/dist/server/auth/jwt.d.ts.map +1 -0
  133. package/dist/server/auth/jwt.js +95 -0
  134. package/dist/server/auth/jwt.js.map +1 -0
  135. package/dist/server/cache/index.d.ts +2 -0
  136. package/dist/server/cache/index.d.ts.map +1 -0
  137. package/dist/server/cache/index.js +2 -0
  138. package/dist/server/cache/index.js.map +1 -0
  139. package/dist/server/cache/utils.d.ts +30 -0
  140. package/dist/server/cache/utils.d.ts.map +1 -0
  141. package/dist/server/cache/utils.js +116 -0
  142. package/dist/server/cache/utils.js.map +1 -0
  143. package/dist/server/call-sessions.d.ts +50 -0
  144. package/dist/server/call-sessions.d.ts.map +1 -0
  145. package/dist/server/call-sessions.js +90 -0
  146. package/dist/server/call-sessions.js.map +1 -0
  147. package/dist/server/config/bucket.d.ts +9 -0
  148. package/dist/server/config/bucket.d.ts.map +1 -0
  149. package/dist/server/config/bucket.js +57 -0
  150. package/dist/server/config/bucket.js.map +1 -0
  151. package/dist/server/config/defaults.d.ts +3 -0
  152. package/dist/server/config/defaults.d.ts.map +1 -0
  153. package/dist/server/config/defaults.js +29 -0
  154. package/dist/server/config/defaults.js.map +1 -0
  155. package/dist/server/config/index.d.ts +5 -0
  156. package/dist/server/config/index.d.ts.map +1 -0
  157. package/dist/server/config/index.js +5 -0
  158. package/dist/server/config/index.js.map +1 -0
  159. package/dist/server/config/logger.d.ts +3 -0
  160. package/dist/server/config/logger.d.ts.map +1 -0
  161. package/dist/server/config/logger.js +2 -0
  162. package/dist/server/config/logger.js.map +1 -0
  163. package/dist/server/config/types.d.ts +198 -0
  164. package/dist/server/config/types.d.ts.map +1 -0
  165. package/dist/server/config/types.js +2 -0
  166. package/dist/server/config/types.js.map +1 -0
  167. package/dist/server/core.d.ts +42 -0
  168. package/dist/server/core.d.ts.map +1 -0
  169. package/dist/server/core.js +202 -0
  170. package/dist/server/core.js.map +1 -0
  171. package/dist/server/env.d.ts +39 -0
  172. package/dist/server/env.d.ts.map +1 -0
  173. package/dist/server/env.js +66 -0
  174. package/dist/server/env.js.map +1 -0
  175. package/dist/server/file-queue-protocol-plugin.d.ts +7 -0
  176. package/dist/server/file-queue-protocol-plugin.d.ts.map +1 -0
  177. package/dist/server/file-queue-protocol-plugin.js +150 -0
  178. package/dist/server/file-queue-protocol-plugin.js.map +1 -0
  179. package/dist/server/file-queue.d.ts +25 -0
  180. package/dist/server/file-queue.d.ts.map +1 -0
  181. package/dist/server/file-queue.js +2 -0
  182. package/dist/server/file-queue.js.map +1 -0
  183. package/dist/server/help.d.ts +19 -0
  184. package/dist/server/help.d.ts.map +1 -0
  185. package/dist/server/help.js +91 -0
  186. package/dist/server/help.js.map +1 -0
  187. package/dist/server/index.d.ts +29 -0
  188. package/dist/server/index.d.ts.map +1 -0
  189. package/dist/server/index.js +29 -0
  190. package/dist/server/index.js.map +1 -0
  191. package/dist/server/operation-registry.d.ts +9 -0
  192. package/dist/server/operation-registry.d.ts.map +1 -0
  193. package/dist/server/operation-registry.js +16 -0
  194. package/dist/server/operation-registry.js.map +1 -0
  195. package/dist/server/param-aliases.d.ts +40 -0
  196. package/dist/server/param-aliases.d.ts.map +1 -0
  197. package/dist/server/param-aliases.js +112 -0
  198. package/dist/server/param-aliases.js.map +1 -0
  199. package/dist/server/protocol-plugin.d.ts +72 -0
  200. package/dist/server/protocol-plugin.d.ts.map +1 -0
  201. package/dist/server/protocol-plugin.js +2 -0
  202. package/dist/server/protocol-plugin.js.map +1 -0
  203. package/dist/server/rate-limit/index.d.ts +2 -0
  204. package/dist/server/rate-limit/index.d.ts.map +1 -0
  205. package/dist/server/rate-limit/index.js +2 -0
  206. package/dist/server/rate-limit/index.js.map +1 -0
  207. package/dist/server/rate-limit/utils.d.ts +27 -0
  208. package/dist/server/rate-limit/utils.d.ts.map +1 -0
  209. package/dist/server/rate-limit/utils.js +126 -0
  210. package/dist/server/rate-limit/utils.js.map +1 -0
  211. package/dist/server/routing.d.ts +39 -0
  212. package/dist/server/routing.d.ts.map +1 -0
  213. package/dist/server/routing.js +70 -0
  214. package/dist/server/routing.js.map +1 -0
  215. package/dist/server/rpc-protocol-plugin.d.ts +12 -0
  216. package/dist/server/rpc-protocol-plugin.d.ts.map +1 -0
  217. package/dist/server/rpc-protocol-plugin.js +134 -0
  218. package/dist/server/rpc-protocol-plugin.js.map +1 -0
  219. package/dist/server/server.d.ts +143 -0
  220. package/dist/server/server.d.ts.map +1 -0
  221. package/dist/server/server.js +1161 -0
  222. package/dist/server/server.js.map +1 -0
  223. package/dist/server/token-limit/index.d.ts +2 -0
  224. package/dist/server/token-limit/index.d.ts.map +1 -0
  225. package/dist/server/token-limit/index.js +2 -0
  226. package/dist/server/token-limit/index.js.map +1 -0
  227. package/dist/server/token-limit/utils.d.ts +44 -0
  228. package/dist/server/token-limit/utils.d.ts.map +1 -0
  229. package/dist/server/token-limit/utils.js +260 -0
  230. package/dist/server/token-limit/utils.js.map +1 -0
  231. package/dist/server/tools.d.ts +51 -0
  232. package/dist/server/tools.d.ts.map +1 -0
  233. package/dist/server/tools.js +225 -0
  234. package/dist/server/tools.js.map +1 -0
  235. package/dist/server/transports.d.ts +25 -0
  236. package/dist/server/transports.d.ts.map +1 -0
  237. package/dist/server/transports.js +2 -0
  238. package/dist/server/transports.js.map +1 -0
  239. package/dist/spec/decorators.d.ts +41 -0
  240. package/dist/spec/decorators.d.ts.map +1 -0
  241. package/dist/spec/decorators.js +93 -0
  242. package/dist/spec/decorators.js.map +1 -0
  243. package/dist/spec/index.d.ts +3 -0
  244. package/dist/spec/index.d.ts.map +1 -0
  245. package/dist/spec/index.js +3 -0
  246. package/dist/spec/index.js.map +1 -0
  247. package/dist/spec/metadata.d.ts +5 -0
  248. package/dist/spec/metadata.d.ts.map +1 -0
  249. package/dist/spec/metadata.js +37 -0
  250. package/dist/spec/metadata.js.map +1 -0
  251. package/dist/types/client.d.ts +81 -0
  252. package/dist/types/client.d.ts.map +1 -0
  253. package/dist/types/client.js +14 -0
  254. package/dist/types/client.js.map +1 -0
  255. package/dist/types/endpoints.d.ts +76 -0
  256. package/dist/types/endpoints.d.ts.map +1 -0
  257. package/dist/types/endpoints.js +2 -0
  258. package/dist/types/endpoints.js.map +1 -0
  259. package/dist/types/errors.d.ts +86 -0
  260. package/dist/types/errors.d.ts.map +1 -0
  261. package/dist/types/errors.js +153 -0
  262. package/dist/types/errors.js.map +1 -0
  263. package/dist/types/http.d.ts +80 -0
  264. package/dist/types/http.d.ts.map +1 -0
  265. package/dist/types/http.js +61 -0
  266. package/dist/types/http.js.map +1 -0
  267. package/dist/types/index.d.ts +9 -0
  268. package/dist/types/index.d.ts.map +1 -0
  269. package/dist/types/index.js +9 -0
  270. package/dist/types/index.js.map +1 -0
  271. package/dist/types/openapi.d.ts +220 -0
  272. package/dist/types/openapi.d.ts.map +1 -0
  273. package/dist/types/openapi.js +11 -0
  274. package/dist/types/openapi.js.map +1 -0
  275. package/dist/types/opts.d.ts +46 -0
  276. package/dist/types/opts.d.ts.map +1 -0
  277. package/dist/types/opts.js +2 -0
  278. package/dist/types/opts.js.map +1 -0
  279. package/dist/types/plugins.d.ts +93 -0
  280. package/dist/types/plugins.d.ts.map +1 -0
  281. package/dist/types/plugins.js +5 -0
  282. package/dist/types/plugins.js.map +1 -0
  283. package/dist/types/tools.d.ts +52 -0
  284. package/dist/types/tools.d.ts.map +1 -0
  285. package/dist/types/tools.js +2 -0
  286. package/dist/types/tools.js.map +1 -0
  287. package/package.json +84 -0
@@ -0,0 +1,1161 @@
1
+ import express from "express";
2
+ import { createServer as createHttpServer } from 'node:http';
3
+ import { applyRateLimitCheck, applyRateLimitRefund, createInMemoryRateLimit } from "./rate-limit";
4
+ import { applyTokenLimitCheck, applyTokenLimitFailure, applyTokenLimitResponse, createInMemoryTokenLimit } from "./token-limit";
5
+ import { applyCacheCheck, applyCacheStore, createInMemoryCache } from "./cache";
6
+ import { formatTool } from "./tools";
7
+ import swaggerUi from "swagger-ui-express";
8
+ import redocExpress from "redoc-express";
9
+ import { normalizeParameters } from "./param-aliases";
10
+ import { HttpError } from "../types";
11
+ import { generateRouteHelp, isHelpRequested } from "./help";
12
+ import { DEFAULT_OPTIONS, defaultLogger } from "./config";
13
+ import { DEFAULT_RPC_PATH } from '../rpc';
14
+ import { InMemoryCallSessionController } from "./call-sessions";
15
+ import { createRpcProtocolPlugin } from './rpc-protocol-plugin';
16
+ import { createFileQueueProtocolPlugin } from './file-queue-protocol-plugin';
17
+ import { PLATOperationRegistry } from './operation-registry';
18
+ import { PLATServerCore } from './core';
19
+ export class PLATServer {
20
+ app;
21
+ options;
22
+ logger;
23
+ routes = [];
24
+ registeredMethodNames = new Set();
25
+ registeredControllerNames = new Set();
26
+ tools = new Map(); // methodName -> ToolDefinition
27
+ operationRegistry = new PLATOperationRegistry();
28
+ core;
29
+ constructor(options = {}, ...ControllerClasses) {
30
+ this.app = express();
31
+ this.options = {
32
+ ...DEFAULT_OPTIONS,
33
+ ...options,
34
+ };
35
+ this.logger = options.logger || defaultLogger;
36
+ this.core = new PLATServerCore({
37
+ allowedMethodPrefixes: this.options.allowedMethodPrefixes,
38
+ disAllowedMethodPrefixes: this.options.disAllowedMethodPrefixes,
39
+ validateRouteOpts: this.options.validateRouteOpts,
40
+ }, {
41
+ routes: this.routes,
42
+ tools: this.tools,
43
+ operationRegistry: this.operationRegistry,
44
+ registeredMethodNames: this.registeredMethodNames,
45
+ registeredControllerNames: this.registeredControllerNames,
46
+ });
47
+ // Filter out identity mappings from paramCoercions (where key === value)
48
+ if (this.options.paramCoercions) {
49
+ const filtered = {};
50
+ for (const [key, value] of Object.entries(this.options.paramCoercions)) {
51
+ if (key !== value) {
52
+ filtered[key] = value;
53
+ }
54
+ }
55
+ this.options.paramCoercions = filtered;
56
+ }
57
+ // Lazy-init default controllers if plugins are enabled but no controller provided
58
+ if (this.options.rateLimit && !this.options.rateLimit.controller) {
59
+ this.options.rateLimit.controller = createInMemoryRateLimit();
60
+ }
61
+ if (this.options.tokenLimit && !this.options.tokenLimit.controller) {
62
+ this.options.tokenLimit.controller = createInMemoryTokenLimit();
63
+ }
64
+ if (this.options.cache && !this.options.cache.controller) {
65
+ this.options.cache.controller = createInMemoryCache();
66
+ }
67
+ if (this.options.calls && !this.options.calls.controller) {
68
+ this.options.calls.controller = new InMemoryCallSessionController();
69
+ }
70
+ // Setup middleware
71
+ this.setupMiddleware();
72
+ // Auto-register controllers passed to constructor
73
+ for (const ControllerClass of ControllerClasses) {
74
+ this.register(ControllerClass);
75
+ }
76
+ }
77
+ /**
78
+ * Setup Express middleware
79
+ */
80
+ setupMiddleware() {
81
+ // Body parser
82
+ this.app.use(express.json());
83
+ this.app.use(express.urlencoded({ extended: true }));
84
+ // CORS
85
+ if (this.options.cors) {
86
+ this.app.use(this.createCORSMiddleware(this.options.cors));
87
+ }
88
+ // Default headers
89
+ if (this.options.headers && Object.keys(this.options.headers).length > 0) {
90
+ this.app.use((req, res, next) => {
91
+ Object.entries(this.options.headers || {}).forEach(([key, value]) => {
92
+ res.setHeader(key, value);
93
+ });
94
+ next();
95
+ });
96
+ }
97
+ // Custom response JSON serializer
98
+ this.app.set('json replacer', (key, value) => {
99
+ // Default Date/DateTime serialization
100
+ if (value instanceof Date) {
101
+ return value.toISOString();
102
+ }
103
+ // Apply custom serializers
104
+ if (this.options.serializers) {
105
+ for (const [typeName, serializer] of Object.entries(this.options.serializers)) {
106
+ if (value && typeof value === 'object' && value.constructor.name === typeName) {
107
+ return serializer(value);
108
+ }
109
+ }
110
+ }
111
+ return value;
112
+ });
113
+ // Setup documentation UI
114
+ this.setupDocumentation();
115
+ this.setupCallRoutes();
116
+ // Setup redirects
117
+ if (this.options.redirects) {
118
+ for (const [from, to] of Object.entries(this.options.redirects)) {
119
+ this.app.get(from, (req, res) => {
120
+ res.redirect(to);
121
+ });
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Setup Swagger UI and ReDoc
127
+ */
128
+ setupDocumentation() {
129
+ // Always serve OpenAPI spec (even if empty)
130
+ this.app.get('/openapi.json', (req, res) => {
131
+ if (this.options.openapi) {
132
+ res.json(this.options.openapi);
133
+ }
134
+ else {
135
+ res.status(404).json({ error: 'OpenAPI spec not available' });
136
+ }
137
+ });
138
+ // List all available endpoints
139
+ this.app.get('/endpoints', (req, res) => {
140
+ const filtered = this.filterRoutes(this.routes, req.query);
141
+ // Format as array of one-line strings
142
+ if (req.query.format === 'json') {
143
+ res.json({
144
+ count: filtered.length,
145
+ endpoints: filtered.map(r => ({
146
+ method: r.method,
147
+ path: r.path,
148
+ params: this.extractPathParams(r.path),
149
+ methodName: r.methodName,
150
+ })),
151
+ });
152
+ }
153
+ else {
154
+ // Default: plain text format
155
+ const lines = filtered.map(r => this.formatRoute(r));
156
+ res.type('text/plain').send(lines.join('\n'));
157
+ }
158
+ });
159
+ // JQ-like filtering of OpenAPI spec
160
+ this.app.get('/openapi-jq', (req, res) => {
161
+ if (!this.options.openapi) {
162
+ return res.status(404).json({ error: 'OpenAPI spec not available' });
163
+ }
164
+ const filter = req.query.filter;
165
+ const result = filter ? this.applyJsonFilter(this.options.openapi, filter) : this.options.openapi;
166
+ res.json(result);
167
+ });
168
+ // Quick route listing by HTTP method
169
+ const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
170
+ for (const method of httpMethods) {
171
+ this.app.get(`/routes/${method}`, (req, res) => {
172
+ const methodUpper = method.toUpperCase();
173
+ const filtered = this.routes.filter(r => r.method === methodUpper);
174
+ const paths = filtered.map(r => r.path);
175
+ res.type('text/plain').send(paths.join('\n'));
176
+ });
177
+ }
178
+ // General help endpoint
179
+ this.app.get('/help', (req, res) => {
180
+ const help = {
181
+ endpoints: {
182
+ 'GET /endpoints': 'List all available API endpoints with descriptions',
183
+ 'GET /endpoints?method=POST': 'Filter endpoints by HTTP method',
184
+ 'GET /endpoints?search=users': 'Search endpoints by path or name',
185
+ 'GET /endpoints?format=json': 'Get endpoints as JSON with details',
186
+ 'GET /routes/get': 'List all GET endpoints (paths only)',
187
+ 'GET /routes/post': 'List all POST endpoints (paths only)',
188
+ 'GET /routes/put': 'List all PUT endpoints (paths only)',
189
+ 'GET /routes/patch': 'List all PATCH endpoints (paths only)',
190
+ 'GET /routes/delete': 'List all DELETE endpoints (paths only)',
191
+ },
192
+ documentation: {
193
+ 'GET /openapi.json': 'OpenAPI specification',
194
+ 'GET /openapi-jq?filter=.paths': 'Filter OpenAPI spec with JQ-like syntax',
195
+ 'GET /docs': 'Swagger UI documentation',
196
+ 'GET /redoc': 'ReDoc documentation',
197
+ 'GET /platCallStatus?id=...': 'Inspect a deferred HTTP call session',
198
+ 'GET /platCallEvents?id=...': 'Read deferred HTTP call events/logs',
199
+ 'GET /platCallResult?id=...': 'Read the final result for a deferred HTTP call',
200
+ 'POST /platCallCancel': 'Cancel a deferred HTTP call by id',
201
+ },
202
+ queryParameters: {
203
+ endpoints: {
204
+ method: 'Filter by HTTP method (GET, POST, etc.)',
205
+ search: 'Search in path and method name (case-insensitive)',
206
+ q: 'Alias for search',
207
+ path: 'Filter by path prefix',
208
+ format: 'Output format: "json" or plain text (default)',
209
+ },
210
+ routes: {
211
+ help: 'Show help for a specific endpoint (e.g., ?help=true)',
212
+ },
213
+ openapi_jq: {
214
+ filter: 'JQ-like filter (e.g., ".paths", ".info", ".paths.*.post")',
215
+ },
216
+ },
217
+ examples: [
218
+ 'curl http://localhost:3000/endpoints',
219
+ 'curl "http://localhost:3000/endpoints?method=POST"',
220
+ 'curl "http://localhost:3000/endpoints?search=user"',
221
+ 'curl "http://localhost:3000/endpoints?format=json"',
222
+ 'curl "http://localhost:3000/openapi-jq?filter=.paths"',
223
+ 'curl "http://localhost:3000/api/users?help=true"',
224
+ ],
225
+ };
226
+ res.json(help);
227
+ });
228
+ // List all tools in requested format (Claude, OpenAI, or schema)
229
+ // Optional: ?method=methodName to filter to single tool
230
+ // Optional: ?fmt=claude|openai|schema (default: claude)
231
+ this.app.get('/tools', (req, res) => {
232
+ const fmt = req.query.fmt || 'claude';
233
+ const methodFilter = req.query.method;
234
+ if (methodFilter) {
235
+ // Single tool
236
+ const tool = this.tools.get(methodFilter);
237
+ if (!tool || !this.matchesToolQuery(tool, req.query)) {
238
+ return res.status(404).json({ error: `Tool '${methodFilter}' not found` });
239
+ }
240
+ return res.json(formatTool(tool, fmt));
241
+ }
242
+ // All tools
243
+ const tools = Array.from(this.tools.values())
244
+ .filter(tool => this.matchesToolQuery(tool, req.query))
245
+ .map(tool => formatTool(tool, fmt));
246
+ res.json(tools);
247
+ });
248
+ if (!this.options.openapi) {
249
+ return;
250
+ }
251
+ this.mountDocUI();
252
+ }
253
+ /**
254
+ * Mount Swagger UI and ReDoc based on current options.
255
+ */
256
+ mountDocUI() {
257
+ const swaggerPath = this.options.swagger === true ? '/docs' : typeof this.options.swagger === 'string' ? this.options.swagger : null;
258
+ const redocPath = this.options.redoc === true ? '/redoc' : typeof this.options.redoc === 'string' ? this.options.redoc : null;
259
+ if (swaggerPath) {
260
+ this.app.use(swaggerPath, swaggerUi.serve);
261
+ this.app.get(swaggerPath, swaggerUi.setup(this.options.openapi));
262
+ }
263
+ if (redocPath) {
264
+ this.app.get(redocPath, redocExpress({
265
+ title: 'API Documentation',
266
+ specUrl: '/openapi.json',
267
+ }));
268
+ }
269
+ }
270
+ /**
271
+ * Setup docs UI after auto-generating the OpenAPI spec.
272
+ * Mounts Swagger UI at /docs and ReDoc at /redoc by default.
273
+ */
274
+ setupAutoDocumentation() {
275
+ if (!this.options.swagger && this.options.swagger !== false) {
276
+ this.options.swagger = true;
277
+ }
278
+ if (!this.options.redoc && this.options.redoc !== false) {
279
+ this.options.redoc = true;
280
+ }
281
+ this.mountDocUI();
282
+ }
283
+ /**
284
+ * Reserved method names that conflict with plat system features
285
+ */
286
+ static RESERVED_METHOD_NAMES = ['tools', 'routes', 'endpoints', 'help', 'openapi'];
287
+ matchesToolQuery(tool, query) {
288
+ const includeHidden = query.includeHidden === 'true' || query.includeHidden === true;
289
+ if (tool.hidden && !includeHidden)
290
+ return false;
291
+ const controller = typeof query.controller === 'string' ? query.controller : undefined;
292
+ if (controller && tool.controller !== controller)
293
+ return false;
294
+ const tag = typeof query.tag === 'string' ? query.tag : undefined;
295
+ if (tag && !(Array.isArray(tool.tags) && tool.tags.includes(tag)))
296
+ return false;
297
+ const safeOnly = query.safeOnly === 'true' || query.safeOnly === true;
298
+ if (safeOnly && tool.safe !== true)
299
+ return false;
300
+ const longRunning = query.longRunning === 'true' || query.longRunning === true;
301
+ if (longRunning && tool.longRunning !== true)
302
+ return false;
303
+ return true;
304
+ }
305
+ /**
306
+ * Extract the prefix from a method name (e.g., 'get' from 'getUser')
307
+ * Finds the longest known prefix or the first camelCase word
308
+ */
309
+ extractMethodPrefix(methodName) {
310
+ // Standard prefixes first (in descending length order for accurate matching)
311
+ const standardPrefixes = ['create', 'update', 'delete', 'list', 'find', 'send', 'get', 'do'];
312
+ for (const prefix of standardPrefixes) {
313
+ if (methodName.startsWith(prefix) && methodName.length > prefix.length) {
314
+ // Check that next char is uppercase (e.g., 'getUser' matches, 'get' doesn't)
315
+ const nextChar = methodName.charAt(prefix.length);
316
+ if (nextChar === nextChar.toUpperCase()) {
317
+ return prefix;
318
+ }
319
+ }
320
+ }
321
+ // If no standard prefix matches, extract first camelCase word
322
+ // Find the first uppercase letter (after position 0)
323
+ for (let i = 1; i < methodName.length; i++) {
324
+ const char = methodName.charAt(i);
325
+ if (char === char.toUpperCase() && char !== char.toLowerCase()) {
326
+ // Found the camelCase boundary
327
+ return methodName.substring(0, i);
328
+ }
329
+ }
330
+ // No camelCase boundary found - entire name is the prefix
331
+ return methodName.length > 0 ? methodName : null;
332
+ }
333
+ /**
334
+ * Validate that a method name follows plat style conventions
335
+ *
336
+ * Rules (ENFORCED):
337
+ * - Must start with a lowercase letter
338
+ * - Cannot contain underscores
339
+ * - Must be valid camelCase
340
+ * - Cannot be a reserved name (tools, routes, endpoints, help, openapi)
341
+ * - Cannot match a controller name
342
+ * - Must respect allowedMethodPrefixes/disAllowedMethodPrefixes config
343
+ *
344
+ * Recommendations (NOT enforced):
345
+ * - Use standard prefixes for CRUD: get, list, find, create, update, delete
346
+ * - Use send/do for external I/O operations
347
+ * - Use natural names for action commands (e.g., addToCart, checkout, importFile)
348
+ */
349
+ validateMethodName(methodName, controllerName) {
350
+ if (!methodName || methodName.length === 0) {
351
+ return; // Skip validation for empty names
352
+ }
353
+ const firstChar = methodName.charAt(0);
354
+ // Check for uppercase first letter
355
+ if (firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase()) {
356
+ throw new Error(`Method '${methodName}' in ${controllerName} violates plat naming convention: ` +
357
+ `method names must start with a lowercase letter. ` +
358
+ `Use '${firstChar.toLowerCase()}${methodName.slice(1)}' instead.`);
359
+ }
360
+ // Check for underscores
361
+ if (methodName.includes('_')) {
362
+ const camelCase = methodName.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
363
+ throw new Error(`Method '${methodName}' in ${controllerName} violates plat naming convention: ` +
364
+ `underscores are not allowed. Use camelCase instead. ` +
365
+ `Use '${camelCase}' instead.`);
366
+ }
367
+ // Check for reserved names
368
+ if (PLATServer.RESERVED_METHOD_NAMES.includes(methodName.toLowerCase())) {
369
+ throw new Error(`Method '${methodName}' in ${controllerName} uses a reserved plat system name. ` +
370
+ `Reserved names: ${PLATServer.RESERVED_METHOD_NAMES.join(', ')}. ` +
371
+ `Choose a different method name.`);
372
+ }
373
+ // Check allowedMethodPrefixes configuration
374
+ if (this.options.allowedMethodPrefixes && this.options.allowedMethodPrefixes !== '*') {
375
+ const prefix = this.extractMethodPrefix(methodName);
376
+ if (prefix && !this.options.allowedMethodPrefixes.includes(prefix)) {
377
+ throw new Error(`Method '${methodName}' in ${controllerName} uses disallowed prefix '${prefix}'. ` +
378
+ `Allowed prefixes: ${this.options.allowedMethodPrefixes.join(', ')}. ` +
379
+ `Rename the method to use an allowed prefix.`);
380
+ }
381
+ }
382
+ // Check disAllowedMethodPrefixes configuration
383
+ if (this.options.disAllowedMethodPrefixes && this.options.disAllowedMethodPrefixes.length > 0) {
384
+ const prefix = this.extractMethodPrefix(methodName);
385
+ if (prefix && this.options.disAllowedMethodPrefixes.includes(prefix)) {
386
+ throw new Error(`Method '${methodName}' in ${controllerName} uses disallowed prefix '${prefix}'. ` +
387
+ `Disallowed prefixes: ${this.options.disAllowedMethodPrefixes.join(', ')}. ` +
388
+ `Rename the method to use a different prefix.`);
389
+ }
390
+ }
391
+ }
392
+ /**
393
+ * Register a controller class
394
+ *
395
+ * Extracts all @GET, @POST, etc. decorated methods and sets up routing.
396
+ * In plat, routes are 100% flat: method names become routes at the root level.
397
+ * No path parameters in decorators - all routing is done by method names.
398
+ */
399
+ register(...ControllerClasses) {
400
+ for (const registered of this.core.registerControllers(...ControllerClasses)) {
401
+ const { operation: rpcOperation, route } = registered;
402
+ const { methodName, path: fullPath } = route;
403
+ const httpMethod = route.method.toLowerCase();
404
+ const routeMeta = rpcOperation.routeMeta;
405
+ const handler = async (req, res) => {
406
+ let input = {
407
+ ...req.params,
408
+ ...req.query,
409
+ ...(req.body && typeof req.body === 'object' ? req.body : {}),
410
+ };
411
+ // Normalize parameter aliases (query → q, format → fmt, page/pageSize → limit/offset)
412
+ // Also validates against disAllowedParams from config
413
+ input = normalizeParameters(input, this.options.paramCoercions, this.options.disAllowedParams);
414
+ const ctx = {
415
+ method: req.method,
416
+ url: req.path,
417
+ headers: req.headers,
418
+ opts: routeMeta?.opts,
419
+ request: req,
420
+ response: res,
421
+ };
422
+ const helpRequested = isHelpRequested(req.query.help, 'help' in req.query);
423
+ const wantsDeferred = !helpRequested && this.isDeferredExecutionRequested(req);
424
+ try {
425
+ if (wantsDeferred && this.options.calls?.controller && this.options.calls.path) {
426
+ const session = this.options.calls.controller.create({
427
+ operationId: methodName,
428
+ method: req.method,
429
+ path: fullPath,
430
+ });
431
+ const abortController = new AbortController();
432
+ this.options.calls.controller.setCancel(session.id, () => abortController.abort());
433
+ this.attachCallContext(ctx, session.id, 'deferred', abortController.signal, async (event, data) => {
434
+ this.options.calls?.controller?.appendEvent(session.id, event, this.serializeRpcValue(data));
435
+ });
436
+ res.status(202).json({
437
+ id: session.id,
438
+ status: session.status,
439
+ statusPath: `${this.options.calls.path}Status?id=${encodeURIComponent(session.id)}`,
440
+ eventsPath: `${this.options.calls.path}Events?id=${encodeURIComponent(session.id)}`,
441
+ resultPath: `${this.options.calls.path}Result?id=${encodeURIComponent(session.id)}`,
442
+ cancelPath: `${this.options.calls.path}Cancel`,
443
+ });
444
+ const deferredEnvelope = {
445
+ protocol: 'http',
446
+ method: req.method,
447
+ path: fullPath,
448
+ headers: req.headers,
449
+ input,
450
+ ctx,
451
+ operationId: methodName,
452
+ req,
453
+ res,
454
+ allowHelp: false,
455
+ helpRequested: false,
456
+ };
457
+ void (async () => {
458
+ try {
459
+ this.options.calls?.controller?.start(session.id);
460
+ const execution = await this.dispatchTransportCall(rpcOperation, deferredEnvelope);
461
+ this.options.calls?.controller?.complete(session.id, this.serializeRpcValue(execution.result), execution.statusCode);
462
+ }
463
+ catch (err) {
464
+ const statusCode = err instanceof HttpError ? err.statusCode : 500;
465
+ this.options.calls?.controller?.fail(session.id, {
466
+ message: err?.message ?? 'Internal server error',
467
+ statusCode,
468
+ data: err instanceof HttpError ? err.data : undefined,
469
+ });
470
+ }
471
+ })();
472
+ return;
473
+ }
474
+ const execution = await this.dispatchTransportCall(rpcOperation, {
475
+ protocol: 'http',
476
+ method: req.method,
477
+ path: fullPath,
478
+ headers: req.headers,
479
+ input,
480
+ ctx,
481
+ operationId: methodName,
482
+ req,
483
+ res,
484
+ allowHelp: true,
485
+ helpRequested,
486
+ });
487
+ this.applyResponseHeaders(res, ctx);
488
+ if (!res.headersSent) {
489
+ if (execution.kind === 'help') {
490
+ res.status(200).json(execution.result);
491
+ }
492
+ else if (execution.statusCode === 204 || execution.result === undefined) {
493
+ res.status(execution.statusCode === 200 ? 204 : execution.statusCode).end();
494
+ }
495
+ else {
496
+ res.status(execution.statusCode).json(execution.result);
497
+ }
498
+ }
499
+ }
500
+ catch (err) {
501
+ const isHttpError = err instanceof HttpError;
502
+ const statusCode = isHttpError ? err.statusCode : 500;
503
+ const message = isHttpError ? err.message : 'Internal server error';
504
+ this.logger.error(`[Error ${statusCode}] ${message}`, err);
505
+ this.applyRetryAfterHeaders(res, err);
506
+ // Call onError hook
507
+ if (this.options.onError) {
508
+ await this.options.onError(req, res, err, statusCode);
509
+ }
510
+ // Build error response based on exposure level
511
+ if (!res.headersSent) {
512
+ const errorResponse = this.buildErrorResponse(err, statusCode);
513
+ res.status(statusCode).json(errorResponse);
514
+ }
515
+ }
516
+ };
517
+ // Register route with Express (canonical route)
518
+ this.app[httpMethod](fullPath, handler);
519
+ for (const variant of registered.variants) {
520
+ this.app[variant.method.toLowerCase()](variant.path, handler);
521
+ }
522
+ }
523
+ return this;
524
+ }
525
+ /**
526
+ * Add Express middleware
527
+ */
528
+ use(...args) {
529
+ this.app.use(...args);
530
+ return this;
531
+ }
532
+ /**
533
+ * Create CORS middleware
534
+ */
535
+ createCORSMiddleware(corsConfig) {
536
+ const config = corsConfig === true
537
+ ? { origin: '*', credentials: false }
538
+ : corsConfig === false
539
+ ? { origin: '*', credentials: false }
540
+ : corsConfig;
541
+ return (req, res, next) => {
542
+ const origin = typeof config.origin === 'string'
543
+ ? config.origin
544
+ : Array.isArray(config.origin)
545
+ ? config.origin[0] ?? '*'
546
+ : '*';
547
+ res.setHeader('Access-Control-Allow-Origin', origin);
548
+ if (config.credentials) {
549
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
550
+ }
551
+ if (config.methods) {
552
+ res.setHeader('Access-Control-Allow-Methods', config.methods.join(', '));
553
+ }
554
+ if (config.headers) {
555
+ res.setHeader('Access-Control-Allow-Headers', config.headers.join(', '));
556
+ }
557
+ if (config.exposedHeaders) {
558
+ res.setHeader('Access-Control-Expose-Headers', config.exposedHeaders.join(', '));
559
+ }
560
+ if (config.maxAge) {
561
+ res.setHeader('Access-Control-Max-Age', String(config.maxAge));
562
+ }
563
+ if (req.method === 'OPTIONS') {
564
+ res.status(200).end();
565
+ return;
566
+ }
567
+ next();
568
+ };
569
+ }
570
+ /**
571
+ * Extract path parameters from a path string
572
+ * e.g., "/users/:id/posts/:postId" -> ["id", "postId"]
573
+ */
574
+ extractPathParams(path) {
575
+ const matches = path.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
576
+ if (!matches)
577
+ return [];
578
+ return matches.map(m => m.slice(1));
579
+ }
580
+ /**
581
+ * Apply basic JQ-like filtering to JSON
582
+ * Supports: .key, .key.nested, .[index], .*, .[]
583
+ */
584
+ applyJsonFilter(obj, filter) {
585
+ if (!filter || filter === '.')
586
+ return obj;
587
+ const parts = filter.split('.').filter(p => p);
588
+ let current = obj;
589
+ for (const part of parts) {
590
+ if (!current)
591
+ return undefined;
592
+ if (part === '*') {
593
+ // Return all values
594
+ if (Array.isArray(current))
595
+ return current;
596
+ if (typeof current === 'object')
597
+ return Object.values(current);
598
+ return undefined;
599
+ }
600
+ else if (part === '[]') {
601
+ // Return as array if object
602
+ return Array.isArray(current) ? current : [current];
603
+ }
604
+ else if (/^\[(\d+)\]$/.test(part)) {
605
+ // Array index access
606
+ const idx = parseInt(part.slice(1, -1), 10);
607
+ current = Array.isArray(current) ? current[idx] : undefined;
608
+ }
609
+ else {
610
+ // Property access
611
+ current = current[part];
612
+ }
613
+ }
614
+ return current;
615
+ }
616
+ /**
617
+ * Format a single route for display
618
+ * e.g., "GET /users/{id} - list user posts"
619
+ */
620
+ formatRoute(route) {
621
+ const params = this.extractPathParams(route.path);
622
+ const paramStr = params.length > 0 ? ` {${params.join(',')}}` : '';
623
+ const desc = route.methodName ? ` - ${route.methodName}` : '';
624
+ return `${route.method.padEnd(6)} ${route.path}${paramStr}${desc}`;
625
+ }
626
+ /**
627
+ * Filter routes based on query parameters
628
+ */
629
+ filterRoutes(routes, query) {
630
+ let filtered = routes;
631
+ // Filter by HTTP method
632
+ if (query.method) {
633
+ const methodFilter = String(query.method).toUpperCase();
634
+ filtered = filtered.filter(r => r.method === methodFilter);
635
+ }
636
+ // Filter by search term (in path or method name)
637
+ if (query.search || query.q) {
638
+ const searchTerm = String(query.search || query.q).toLowerCase();
639
+ filtered = filtered.filter(r => r.path.toLowerCase().includes(searchTerm) ||
640
+ r.methodName?.toLowerCase().includes(searchTerm));
641
+ }
642
+ // Filter by path prefix
643
+ if (query.path) {
644
+ const pathPrefix = String(query.path);
645
+ filtered = filtered.filter(r => r.path.startsWith(pathPrefix));
646
+ }
647
+ return filtered;
648
+ }
649
+ setupCallRoutes() {
650
+ const controller = this.options.calls?.controller;
651
+ const basePath = this.options.calls?.path;
652
+ if (!controller || !basePath)
653
+ return;
654
+ const statusPath = `${basePath}Status`;
655
+ const eventsPath = `${basePath}Events`;
656
+ const resultPath = `${basePath}Result`;
657
+ const cancelPath = `${basePath}Cancel`;
658
+ this.app.get(statusPath, (req, res) => {
659
+ const id = String(req.query.id ?? '');
660
+ if (!id) {
661
+ res.status(400).json({ error: 'Missing call id' });
662
+ return;
663
+ }
664
+ const session = controller.get(id);
665
+ if (!session) {
666
+ res.status(404).json({ error: 'Call session not found' });
667
+ return;
668
+ }
669
+ const { events: _events, ...summary } = session;
670
+ res.json(summary);
671
+ });
672
+ this.app.get(eventsPath, (req, res) => {
673
+ const id = String(req.query.id ?? '');
674
+ if (!id) {
675
+ res.status(400).json({ error: 'Missing call id' });
676
+ return;
677
+ }
678
+ const session = controller.get(id);
679
+ if (!session) {
680
+ res.status(404).json({ error: 'Call session not found' });
681
+ return;
682
+ }
683
+ const since = Number(req.query.since ?? 0);
684
+ const event = typeof req.query.event === 'string' ? req.query.event : undefined;
685
+ res.json({ events: controller.listEvents(id, Number.isFinite(since) ? since : 0, event) });
686
+ });
687
+ this.app.get(resultPath, (req, res) => {
688
+ const id = String(req.query.id ?? '');
689
+ if (!id) {
690
+ res.status(400).json({ error: 'Missing call id' });
691
+ return;
692
+ }
693
+ const session = controller.get(id);
694
+ if (!session) {
695
+ res.status(404).json({ error: 'Call session not found' });
696
+ return;
697
+ }
698
+ const { events: _events, ...summary } = session;
699
+ const statusCode = session.status === 'completed'
700
+ ? 200
701
+ : session.status === 'failed'
702
+ ? (session.error?.statusCode ?? 500)
703
+ : session.status === 'cancelled'
704
+ ? 409
705
+ : 202;
706
+ res.status(statusCode).json(summary);
707
+ });
708
+ this.app.post(cancelPath, (req, res) => {
709
+ const id = String(req.body?.id ?? req.query.id ?? '');
710
+ if (!id) {
711
+ res.status(400).json({ error: 'Missing call id' });
712
+ return;
713
+ }
714
+ const session = controller.get(id);
715
+ if (!session) {
716
+ res.status(404).json({ error: 'Call session not found' });
717
+ return;
718
+ }
719
+ res.json({ cancelled: controller.cancel(id) });
720
+ });
721
+ }
722
+ /**
723
+ * Build error response based on exposure level
724
+ */
725
+ buildErrorResponse(error, statusCode) {
726
+ const exposure = this.options.errorExposure || 'message';
727
+ if (exposure === 'none') {
728
+ return { error: 'Internal server error' };
729
+ }
730
+ if (exposure === 'message') {
731
+ return { error: error.message || 'Internal server error' };
732
+ }
733
+ // 'full' exposure
734
+ const response = {
735
+ error: error.message || 'Internal server error',
736
+ };
737
+ if (error instanceof HttpError && error.data) {
738
+ response.data = error.data;
739
+ }
740
+ if (error.stack) {
741
+ response.stack = error.stack.split('\n');
742
+ }
743
+ return response;
744
+ }
745
+ isDeferredExecutionRequested(req) {
746
+ const header = req.header('X-PLAT-Execution');
747
+ return header === 'deferred' || req.query.execution === 'deferred';
748
+ }
749
+ attachCallContext(ctx, sessionId, mode, signal, emit) {
750
+ const call = {
751
+ id: sessionId,
752
+ mode,
753
+ signal,
754
+ cancelled: () => signal.aborted,
755
+ emit,
756
+ progress: (data) => emit('progress', data),
757
+ log: (data) => emit('log', data),
758
+ chunk: (data) => emit('chunk', data),
759
+ };
760
+ ctx.call = call;
761
+ if (mode === 'rpc') {
762
+ ctx.rpc = call;
763
+ }
764
+ }
765
+ applyResponseHeaders(res, ctx) {
766
+ if (this.options.cache?.cacheHeader !== false) {
767
+ const cacheValue = ctx.cache ? (ctx.cache.hit ? 'true' : 'false') : 'false';
768
+ res.setHeader('X-Cache', cacheValue);
769
+ }
770
+ if (this.options.tokenLimit?.responseCostHeader !== false) {
771
+ const totalCost = ctx.tokenLimit
772
+ ? (ctx.tokenLimit.responseCosts || []).reduce((a, b) => a + b, 0)
773
+ : 0;
774
+ res.setHeader('X-Token-Cost', String(totalCost));
775
+ }
776
+ }
777
+ applyRetryAfterHeaders(res, err) {
778
+ if (!(err instanceof HttpError) || err.statusCode !== 429 || err.data?.retryAfterMs == null) {
779
+ return;
780
+ }
781
+ const rateLimitCfg = this.options.rateLimit?.retryAfterHeaders;
782
+ const tokenLimitCfg = this.options.tokenLimit?.retryAfterHeaders;
783
+ const cfg = rateLimitCfg || tokenLimitCfg;
784
+ if (!cfg)
785
+ return;
786
+ const showDelay = cfg === true || (typeof cfg === 'object' && cfg.delay !== false);
787
+ const showRetryAt = cfg === true || (typeof cfg === 'object' && cfg.retryAt !== false);
788
+ if (showDelay) {
789
+ res.setHeader('Retry-After', Math.ceil(err.data.retryAfterMs / 1000));
790
+ }
791
+ if (showRetryAt) {
792
+ res.setHeader('X-Retry-At', new Date(Date.now() + err.data.retryAfterMs).toISOString());
793
+ }
794
+ }
795
+ async executeOperation(args) {
796
+ const { methodName, controllerTag, fullPath, transportMethod, routeMeta, controllerMeta, boundMethod, input, ctx, req, res, allowHelp, helpRequested = false, } = args;
797
+ const tokenStartMs = Date.now();
798
+ let handlerWasCalled = false;
799
+ let rateLimitEntries = [];
800
+ let tokenLimitEntries = [];
801
+ try {
802
+ if (req && res && this.options.onRequest) {
803
+ await this.options.onRequest(req, res, fullPath, transportMethod);
804
+ }
805
+ const authMode = routeMeta?.auth ?? controllerMeta?.auth ?? this.options.defaultAuth ?? 'public';
806
+ if (authMode !== 'public' && this.options.auth) {
807
+ const authRequest = req ?? { headers: ctx.headers ?? {} };
808
+ const user = await this.options.auth.verify(authMode, authRequest, ctx);
809
+ ctx.auth = { mode: authMode, user };
810
+ }
811
+ else if (authMode !== 'public' && !this.options.auth) {
812
+ throw new HttpError(500, 'Authentication handler not configured');
813
+ }
814
+ const rateLimitMeta = routeMeta?.rateLimit ?? controllerMeta?.rateLimit;
815
+ if (rateLimitMeta && this.options.rateLimit?.controller) {
816
+ const result = await applyRateLimitCheck(rateLimitMeta, this.options.rateLimit.controller, this.options.rateLimit.configs || {}, methodName, controllerTag, ctx.auth?.user);
817
+ rateLimitEntries = result.entries;
818
+ ctx.rateLimit = { entries: result.entries, remainingBalances: result.remainingBalances };
819
+ }
820
+ if (allowHelp && helpRequested) {
821
+ const helpDoc = generateRouteHelp(methodName, fullPath, transportMethod, routeMeta, controllerTag);
822
+ return {
823
+ kind: 'help',
824
+ statusCode: 200,
825
+ result: {
826
+ help: helpDoc,
827
+ auth: ctx.auth,
828
+ },
829
+ };
830
+ }
831
+ const tokenLimitMeta = routeMeta?.tokenLimit ?? controllerMeta?.tokenLimit;
832
+ if (tokenLimitMeta && this.options.tokenLimit?.controller) {
833
+ const result = await applyTokenLimitCheck(tokenLimitMeta, this.options.tokenLimit.controller, this.options.tokenLimit.configs || {}, methodName, controllerTag, input, ctx, ctx.auth?.user);
834
+ tokenLimitEntries = result.entries;
835
+ ctx.tokenLimit = { entries: result.entries, remainingBalances: result.remainingBalances };
836
+ }
837
+ const cacheMeta = routeMeta?.cache ?? controllerMeta?.cache;
838
+ let cacheHit = false;
839
+ let cacheKey = null;
840
+ let cachedValue = undefined;
841
+ let cachedEntry = undefined;
842
+ if (cacheMeta && this.options.cache?.controller) {
843
+ const cacheResult = await applyCacheCheck(cacheMeta, this.options.cache.controller, input, transportMethod, methodName, controllerTag, ctx.auth?.user);
844
+ cacheKey = cacheResult.cacheKey;
845
+ cacheHit = cacheResult.hit;
846
+ cachedValue = cacheResult.cachedValue;
847
+ cachedEntry = cacheResult.entry;
848
+ ctx.cache = { key: cacheKey, hit: cacheHit, stored: false };
849
+ }
850
+ let result;
851
+ let statusCode = 200;
852
+ if (cacheHit) {
853
+ result = cachedValue;
854
+ }
855
+ else {
856
+ result = await boundMethod(input, ctx);
857
+ handlerWasCalled = true;
858
+ statusCode =
859
+ transportMethod === 'POST'
860
+ ? 201
861
+ : transportMethod === 'DELETE' && !result
862
+ ? 204
863
+ : 200;
864
+ if (cacheMeta && cacheKey && this.options.cache?.controller) {
865
+ await applyCacheStore(cacheKey, cachedEntry, this.options.cache.controller, result);
866
+ if (ctx.cache) {
867
+ ctx.cache.stored = true;
868
+ }
869
+ }
870
+ }
871
+ if (tokenLimitEntries.length > 0 && this.options.tokenLimit?.controller && handlerWasCalled) {
872
+ const timing = {
873
+ startMs: tokenStartMs,
874
+ endMs: Date.now(),
875
+ durationMs: Date.now() - tokenStartMs,
876
+ };
877
+ const responseCosts = await applyTokenLimitResponse(tokenLimitEntries, this.options.tokenLimit.controller, result, timing, input, statusCode);
878
+ if (ctx.tokenLimit) {
879
+ ctx.tokenLimit.timing = timing;
880
+ ctx.tokenLimit.responseCosts = responseCosts;
881
+ }
882
+ }
883
+ if (rateLimitEntries.length > 0 && this.options.rateLimit?.controller) {
884
+ await applyRateLimitRefund(rateLimitEntries, this.options.rateLimit.controller, statusCode);
885
+ }
886
+ if (req && res && this.options.onResponse) {
887
+ await this.options.onResponse(req, res, statusCode, result);
888
+ }
889
+ return {
890
+ kind: 'success',
891
+ result,
892
+ statusCode,
893
+ };
894
+ }
895
+ catch (err) {
896
+ const statusCode = err instanceof HttpError ? err.statusCode : 500;
897
+ if (tokenLimitEntries.length > 0 && this.options.tokenLimit?.controller && handlerWasCalled) {
898
+ const timing = {
899
+ startMs: tokenStartMs,
900
+ endMs: Date.now(),
901
+ durationMs: Date.now() - tokenStartMs,
902
+ };
903
+ const failureCosts = await applyTokenLimitFailure(tokenLimitEntries, this.options.tokenLimit.controller, err, timing, input, statusCode);
904
+ if (ctx.tokenLimit) {
905
+ ctx.tokenLimit.timing = timing;
906
+ ctx.tokenLimit.failureCosts = failureCosts;
907
+ }
908
+ }
909
+ if (rateLimitEntries.length > 0 && this.options.rateLimit?.controller) {
910
+ await applyRateLimitRefund(rateLimitEntries, this.options.rateLimit.controller, statusCode);
911
+ }
912
+ throw err;
913
+ }
914
+ }
915
+ resolveOperation(envelope) {
916
+ return this.operationRegistry.resolve(envelope);
917
+ }
918
+ async dispatchTransportCall(operation, envelope) {
919
+ return this.executeOperation({
920
+ methodName: operation.methodName,
921
+ controllerTag: operation.controllerTag,
922
+ fullPath: operation.path,
923
+ transportMethod: envelope.method,
924
+ routeMeta: operation.routeMeta,
925
+ controllerMeta: operation.controllerMeta,
926
+ boundMethod: operation.boundMethod,
927
+ input: envelope.input,
928
+ ctx: envelope.ctx,
929
+ req: envelope.req,
930
+ res: envelope.res,
931
+ allowHelp: envelope.allowHelp,
932
+ helpRequested: envelope.helpRequested,
933
+ });
934
+ }
935
+ createTransportRuntime() {
936
+ return {
937
+ logger: this.logger,
938
+ resolveOperation: (envelope) => this.resolveOperation(envelope),
939
+ dispatch: async (operation, envelope) => this.dispatchTransportCall(operation, envelope),
940
+ normalizeInput: (input) => normalizeParameters(input, this.options.paramCoercions, this.options.disAllowedParams),
941
+ serializeValue: (value) => this.serializeRpcValue(value),
942
+ createCallContext: ({ ctx, sessionId, mode, signal, emit }) => {
943
+ const abortSignal = signal ?? new AbortController().signal;
944
+ this.attachCallContext(ctx, sessionId, mode, abortSignal, emit);
945
+ return ctx.call;
946
+ },
947
+ createEnvelope: ({ protocol, operation, input, headers = {}, ctx, requestId, req, res, allowHelp = false, helpRequested = false }) => ({
948
+ protocol,
949
+ method: operation.method,
950
+ path: operation.path,
951
+ headers,
952
+ input,
953
+ ctx,
954
+ operationId: operation.methodName,
955
+ requestId,
956
+ req,
957
+ res,
958
+ allowHelp,
959
+ helpRequested,
960
+ }),
961
+ };
962
+ }
963
+ serializeRpcValue(value) {
964
+ if (value instanceof Date)
965
+ return value.toISOString();
966
+ if (Array.isArray(value))
967
+ return value.map((item) => this.serializeRpcValue(item));
968
+ if (value && typeof value === 'object') {
969
+ for (const [typeName, serializer] of Object.entries(this.options.serializers || {})) {
970
+ if (value.constructor?.name === typeName) {
971
+ return this.serializeRpcValue(serializer(value));
972
+ }
973
+ }
974
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.serializeRpcValue(item)]));
975
+ }
976
+ return value;
977
+ }
978
+ /**
979
+ * Get the underlying Express app
980
+ */
981
+ getApp() {
982
+ return this.app;
983
+ }
984
+ /**
985
+ * Listen on a port
986
+ * Convenience method that starts the server
987
+ */
988
+ /**
989
+ * Auto-generate an OpenAPI 3.1.0 spec from registered tool definitions.
990
+ * Called by listen() when no explicit openapi option was provided.
991
+ */
992
+ generateOpenAPISpec() {
993
+ const paths = {};
994
+ for (const [, tool] of this.tools) {
995
+ const method = tool.method.toLowerCase();
996
+ const path = tool.path;
997
+ const operation = {
998
+ operationId: tool.name,
999
+ summary: tool.summary || tool.description,
1000
+ tags: tool.tags,
1001
+ responses: {
1002
+ '200': {
1003
+ description: 'Successful response',
1004
+ ...(tool.response_schema ? {
1005
+ content: { 'application/json': { schema: tool.response_schema } },
1006
+ } : {}),
1007
+ },
1008
+ },
1009
+ };
1010
+ const inputSchema = tool.input_schema;
1011
+ if (inputSchema && Object.keys(inputSchema.properties ?? {}).length > 0) {
1012
+ if (method === 'get' || method === 'head' || method === 'delete') {
1013
+ operation.parameters = Object.entries(inputSchema.properties).map(([name, schema]) => ({
1014
+ name,
1015
+ in: 'query',
1016
+ required: (inputSchema.required ?? []).includes(name),
1017
+ schema,
1018
+ }));
1019
+ }
1020
+ else {
1021
+ operation.requestBody = {
1022
+ required: true,
1023
+ content: {
1024
+ 'application/json': { schema: inputSchema },
1025
+ },
1026
+ };
1027
+ }
1028
+ }
1029
+ paths[path] = { ...(paths[path] ?? {}), [method]: operation };
1030
+ }
1031
+ return {
1032
+ openapi: '3.1.0',
1033
+ info: {
1034
+ title: 'plat API',
1035
+ version: '0.1.0',
1036
+ },
1037
+ paths,
1038
+ };
1039
+ }
1040
+ listen(port, hostOrCallback, callbackIfHost) {
1041
+ // Auto-generate OpenAPI spec if not explicitly provided
1042
+ if (!this.options.openapi) {
1043
+ this.options.openapi = this.generateOpenAPISpec();
1044
+ this.setupAutoDocumentation();
1045
+ }
1046
+ const finalPort = port ?? this.options.port ?? 3000;
1047
+ const finalHost = this.options.host ?? 'localhost';
1048
+ const finalProtocol = this.options.protocol ?? 'http';
1049
+ let userCallback;
1050
+ if (typeof hostOrCallback === 'function') {
1051
+ userCallback = hostOrCallback;
1052
+ }
1053
+ else if (typeof hostOrCallback === 'string') {
1054
+ userCallback = callbackIfHost;
1055
+ }
1056
+ // Wrap the callback to print startup message and call user callback
1057
+ const wrappedCallback = () => {
1058
+ this.printStartupMessage(finalProtocol, finalHost, finalPort);
1059
+ if (userCallback) {
1060
+ userCallback();
1061
+ }
1062
+ };
1063
+ const server = createHttpServer(this.app);
1064
+ const hostContext = {
1065
+ kind: 'node-http',
1066
+ app: this.app,
1067
+ server,
1068
+ meta: {
1069
+ protocol: finalProtocol,
1070
+ host: finalHost,
1071
+ port: finalPort,
1072
+ },
1073
+ };
1074
+ const rpcOptions = {
1075
+ enabled: this.options.rpc ?? false,
1076
+ };
1077
+ const builtInPlugins = [
1078
+ createRpcProtocolPlugin(rpcOptions),
1079
+ createFileQueueProtocolPlugin({
1080
+ config: this.options.fileQueue || undefined,
1081
+ }),
1082
+ ];
1083
+ const transportRuntime = this.createTransportRuntime();
1084
+ const allPlugins = [...builtInPlugins, ...(this.options.protocolPlugins ?? [])];
1085
+ for (const plugin of allPlugins) {
1086
+ void plugin.setup?.(transportRuntime);
1087
+ }
1088
+ for (const plugin of allPlugins) {
1089
+ void plugin.attach?.(transportRuntime, hostContext);
1090
+ void plugin.start?.(transportRuntime);
1091
+ }
1092
+ server.listen(finalPort, finalHost, wrappedCallback);
1093
+ return this;
1094
+ }
1095
+ /**
1096
+ * Print startup message with routes and documentation info
1097
+ */
1098
+ printStartupMessage(protocol, host, port) {
1099
+ const url = `${protocol}://${host}:${port}`;
1100
+ this.logger.info(`✅ Server running at ${url}`);
1101
+ if (this.options.rpc !== false) {
1102
+ const rpcPath = typeof this.options.rpc === 'string' ? this.options.rpc : DEFAULT_RPC_PATH;
1103
+ const rpcProtocol = protocol === 'https' ? 'wss' : 'ws';
1104
+ this.logger.info(`🔌 RPC: ${rpcProtocol}://${host}:${port}${rpcPath}`);
1105
+ }
1106
+ if (this.options.calls?.path) {
1107
+ this.logger.info(`⏳ Calls: ${url}${this.options.calls.path}Status?id=...`);
1108
+ }
1109
+ if (this.options.fileQueue) {
1110
+ this.logger.info(`📁 Queue: ${this.options.fileQueue.inbox} -> ${this.options.fileQueue.outbox}`);
1111
+ }
1112
+ // Print documentation endpoints
1113
+ if (this.options.openapi) {
1114
+ const swaggerPath = this.options.swagger === true ? '/docs' : typeof this.options.swagger === 'string' ? this.options.swagger : null;
1115
+ const redocPath = this.options.redoc === true ? '/redoc' : typeof this.options.redoc === 'string' ? this.options.redoc : null;
1116
+ if (swaggerPath || redocPath) {
1117
+ this.logger.info('📚 Documentation:');
1118
+ if (swaggerPath) {
1119
+ this.logger.info(` Swagger: ${url}${swaggerPath}`);
1120
+ }
1121
+ if (redocPath) {
1122
+ this.logger.info(` ReDoc: ${url}${redocPath}`);
1123
+ }
1124
+ }
1125
+ }
1126
+ // Print API endpoints
1127
+ if (this.routes.length > 0) {
1128
+ this.logger.info('API Endpoints:');
1129
+ const maxPathLength = Math.max(...this.routes.map((r) => r.path.length));
1130
+ for (const route of this.routes) {
1131
+ this.logger.info(` ${route.method.padEnd(6)} ${route.path.padEnd(maxPathLength)}`);
1132
+ }
1133
+ }
1134
+ // Print curl example
1135
+ if (this.routes.length > 0) {
1136
+ const firstGetRoute = this.routes.find((r) => r.method === 'GET');
1137
+ if (firstGetRoute) {
1138
+ this.logger.info(`Try: curl ${url}${firstGetRoute.path}`);
1139
+ }
1140
+ }
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Create a new plat server
1145
+ *
1146
+ * @example
1147
+ * ```typescript
1148
+ * const server = createServer({
1149
+ * errorExposure: 'message',
1150
+ * cors: true,
1151
+ * onRequest: (req, res, path, method) => console.log(`${method} ${path}`)
1152
+ * })
1153
+ * server.register(ProductsApi)
1154
+ * server.register(OrdersApi)
1155
+ * server.listen(3000, () => console.log('Server running'))
1156
+ * ```
1157
+ */
1158
+ export function createServer(options, ...ControllerClasses) {
1159
+ return new PLATServer(options, ...ControllerClasses);
1160
+ }
1161
+ //# sourceMappingURL=server.js.map