webspresso 0.0.108 → 0.0.110

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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -3
  3. package/bin/commands/add-esbuild.js +7 -3
  4. package/bin/commands/add-tailwind.js +9 -5
  5. package/bin/commands/build.js +20 -4
  6. package/bin/commands/dev.js +57 -7
  7. package/bin/commands/doctor.js +42 -1
  8. package/bin/commands/favicon-generate.js +10 -3
  9. package/bin/commands/new.js +8 -3
  10. package/bin/commands/start.js +3 -1
  11. package/bin/utils/add-command.js +16 -0
  12. package/core/app.js +45 -3
  13. package/core/auth/authenticate.js +3 -3
  14. package/core/auth/hash.js +167 -43
  15. package/core/auth/index.js +13 -1
  16. package/core/auth/jwt.js +21 -2
  17. package/core/auth/manager.js +63 -14
  18. package/core/auth/middleware.js +4 -2
  19. package/core/auth/oauth.js +5 -1
  20. package/core/auth/tokens.js +7 -2
  21. package/core/auth/totp.js +42 -6
  22. package/core/auth/user-agent.js +2 -0
  23. package/core/client/adapter/datastar.js +1401 -0
  24. package/core/client/api.js +1337 -0
  25. package/core/client/controller.js +1706 -0
  26. package/core/client/debug.js +367 -0
  27. package/core/client/devtool.js +1134 -0
  28. package/core/client/forms.js +1164 -0
  29. package/core/client/fragments.js +894 -0
  30. package/core/client/index.d.ts +59 -0
  31. package/core/client/index.js +391 -0
  32. package/core/client/lite.js +175 -0
  33. package/core/client/server-fragments.js +868 -0
  34. package/core/client/storage.js +339 -0
  35. package/core/client/utils.js +281 -0
  36. package/core/compression/middleware.js +277 -26
  37. package/core/context/app-context.js +25 -3
  38. package/core/context/request.js +32 -4
  39. package/core/errors/middleware.js +14 -0
  40. package/core/events/dispatcher.js +33 -0
  41. package/core/forms/flash.js +136 -102
  42. package/core/health/middleware.js +36 -6
  43. package/core/http/pipeline.js +45 -6
  44. package/core/index.js +25 -7
  45. package/core/kernel/view.js +41 -10
  46. package/core/metrics/middleware.js +30 -6
  47. package/core/metrics/registry.js +13 -5
  48. package/core/orm/cache/fingerprint.js +32 -11
  49. package/core/orm/cache/layer.js +19 -2
  50. package/core/orm/hidden.js +145 -0
  51. package/core/orm/model.js +48 -2
  52. package/core/orm/query-builder.js +343 -6
  53. package/core/orm/query-filters.js +14 -2
  54. package/core/orm/repository.js +64 -13
  55. package/core/orm/schema-helpers.js +9 -6
  56. package/core/orm/transaction.js +50 -6
  57. package/core/orm/types.js +4 -0
  58. package/core/queue/adapters/database.js +184 -2
  59. package/core/queue/adapters/memory.js +153 -5
  60. package/core/queue/adapters/redis.js +59 -4
  61. package/core/queue/job.js +14 -1
  62. package/core/queue/manager.js +115 -0
  63. package/core/resilience/circuit-breaker.js +27 -8
  64. package/core/services/builtins/exchange.js +174 -7
  65. package/core/services/builtins/file-manager.js +36 -0
  66. package/core/services/builtins/mail.js +27 -0
  67. package/core/services/builtins/queue.js +144 -0
  68. package/core/services/builtins/security.js +118 -0
  69. package/core/services/builtins/webhook.js +110 -0
  70. package/core/services/executor.js +38 -16
  71. package/core/services/index.js +4 -1
  72. package/core/sse/stream.js +28 -5
  73. package/core/utils/timeout.js +8 -1
  74. package/core/validation/compiler.js +10 -7
  75. package/index.d.ts +1320 -74
  76. package/index.js +128 -8
  77. package/package.json +75 -27
  78. package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
  79. package/plugins/admin-panel/index.js +19 -8
  80. package/plugins/csrf/index.js +3 -3
  81. package/plugins/data-exchange/export.js +214 -0
  82. package/plugins/data-exchange/import.js +48 -5
  83. package/plugins/data-exchange/index.js +70 -28
  84. package/plugins/data-exchange/preview.js +266 -0
  85. package/plugins/data-exchange/template.js +242 -0
  86. package/plugins/email/admin-component.js +783 -141
  87. package/plugins/email/api-handlers.js +51 -1
  88. package/plugins/email/index.js +5 -0
  89. package/plugins/email/service.js +48 -0
  90. package/plugins/file-manager/admin-component.js +1546 -70
  91. package/plugins/file-manager/api-handlers.js +166 -1
  92. package/plugins/file-manager/core.js +598 -8
  93. package/plugins/file-manager/index.js +8 -0
  94. package/plugins/index.js +7 -0
  95. package/plugins/mcp/auth.js +3 -3
  96. package/plugins/oauth/index.js +36 -3
  97. package/plugins/queue/admin-component.js +1092 -0
  98. package/plugins/queue/api-handlers.js +248 -0
  99. package/plugins/queue/index.js +69 -0
  100. package/plugins/rest-resources/index.js +7 -2
  101. package/plugins/security/admin-component.js +689 -0
  102. package/plugins/security/api-handlers.js +169 -0
  103. package/plugins/security/audit.js +153 -0
  104. package/plugins/security/bruteforce.js +244 -0
  105. package/plugins/security/firewall.js +459 -0
  106. package/plugins/security/headers.js +214 -0
  107. package/plugins/security/honeypot.js +234 -0
  108. package/plugins/security/incident-recorder.js +137 -0
  109. package/plugins/security/index.js +211 -0
  110. package/plugins/seo-checker/analyzer.js +20 -7
  111. package/plugins/upload/index.js +36 -0
  112. package/plugins/webhooks/admin-component.js +702 -175
  113. package/plugins/webhooks/api-handlers.js +75 -1
  114. package/plugins/webhooks/dispatcher.js +290 -11
  115. package/plugins/webhooks/index.js +37 -2
  116. package/plugins/webhooks/sender.js +56 -0
  117. package/plugins/xlsx/engine.js +19 -4
  118. package/src/api/api-loader.js +19 -1
  119. package/src/bundle/index.js +443 -17
  120. package/src/client-runtime/bootstrap-swup.js +9 -0
  121. package/src/client-runtime/mount.js +15 -20
  122. package/src/client-runtime/resolve.js +3 -2
  123. package/src/dev/api-typegen.js +1300 -0
  124. package/src/dev/banner.js +10 -1
  125. package/src/dev/colors.js +1 -1
  126. package/src/dev/index.js +7 -1
  127. package/src/dev/typegen.js +173 -17
  128. package/src/dev-reload/index.js +1 -1
  129. package/src/discovery/file-route-parser.js +17 -3
  130. package/src/discovery/scan-routes.js +4 -1
  131. package/src/file-router.js +253 -69
  132. package/src/helpers.js +46 -26
  133. package/src/njk-frontmatter.js +30 -4
  134. package/src/routing/cascading.js +26 -8
  135. package/src/routing/mount-discovered-routes.js +76 -0
  136. package/src/server.js +231 -70
  137. package/views/partials/webspresso-client-runtime.njk +1 -9
  138. package/.agents/ADMIN.md +0 -426
  139. package/.agents/AGENTS.md +0 -96
  140. package/.agents/ARCHITECTURE.md +0 -163
  141. package/.agents/AUTH.md +0 -581
  142. package/.agents/CACHE.md +0 -148
  143. package/.agents/CLI.md +0 -649
  144. package/.agents/COMPRESSION.md +0 -59
  145. package/.agents/CONTEXT.md +0 -152
  146. package/.agents/CONVENTIONS.md +0 -50
  147. package/.agents/EARLY_HINTS.md +0 -120
  148. package/.agents/EMAIL.md +0 -223
  149. package/.agents/ERRORS.md +0 -260
  150. package/.agents/EVENTS.md +0 -179
  151. package/.agents/FLAGS.md +0 -118
  152. package/.agents/HEALTH.md +0 -118
  153. package/.agents/LIMITS.md +0 -109
  154. package/.agents/LOGGING.md +0 -93
  155. package/.agents/MCP.md +0 -103
  156. package/.agents/METRICS.md +0 -127
  157. package/.agents/MIGRATION_JOBS.md +0 -95
  158. package/.agents/ORM.md +0 -700
  159. package/.agents/PLUGINS.md +0 -729
  160. package/.agents/QUEUE.md +0 -290
  161. package/.agents/RATE_LIMIT.md +0 -147
  162. package/.agents/REALTIME.md +0 -213
  163. package/.agents/RESILIENCE.md +0 -235
  164. package/.agents/ROUTING.md +0 -605
  165. package/.agents/SCALE_OUT.md +0 -109
  166. package/.agents/SECRETS.md +0 -117
  167. package/.agents/SERVICES.md +0 -341
  168. package/.agents/SHUTDOWN.md +0 -74
  169. package/.agents/STREAMING.md +0 -108
  170. package/.agents/WORKFLOW.md +0 -27
  171. package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
  172. package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
  173. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
  174. package/.agents/skills/webspresso-framework/SKILL.md +0 -89
  175. package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
  176. package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
  177. package/.agents/skills/webspresso-services/SKILL.md +0 -134
  178. package/.cursorrules +0 -14
  179. package/AGENTS.md +0 -203
  180. package/CLAUDE.md +0 -54
  181. package/core/kernel/run-demo.js +0 -58
  182. package/plugins/admin-panel/styles.js +0 -9
  183. package/plugins/data-exchange/export-xlsx.js +0 -119
  184. package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
@@ -0,0 +1,1300 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso API Type Manifest & Static HTML Form Contract Checker
5
+ *
6
+ * Derives end-to-end TypeScript definitions (`.webspresso/api.d.ts`) from existing
7
+ * `defineApi({ schema, response, handler })` route files without duplicating DTOs
8
+ * or importing server handlers into browser bundles.
9
+ *
10
+ * Also provides static HTML/Nunjucks `<form>` field verification against route schemas
11
+ * for `webspresso doctor` and `webspresso build`.
12
+ *
13
+ * @module src/dev/api-typegen
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const Module = require('module');
19
+ const { scanRoutes } = require('../discovery/scan-routes');
20
+ const {
21
+ parseFileRoute,
22
+ isClientBrowserFile,
23
+ isPrivateOrIgnored,
24
+ } = require('../discovery/file-route-parser');
25
+ const { z, zdb } = require('../../core/validation');
26
+
27
+ let tsCompiler = null;
28
+ try {
29
+ tsCompiler = require('typescript');
30
+ } catch {
31
+ tsCompiler = null;
32
+ }
33
+
34
+ /**
35
+ * Recursively scan a directory for API route files (including `.js` and `.ts`),
36
+ * excluding `.client.*` and `.d.ts` browser files.
37
+ *
38
+ * @param {string} dirPath
39
+ * @param {string} [baseDir=dirPath]
40
+ * @returns {Array<{ absolutePath: string, relativePath: string }>}
41
+ */
42
+ function scanApiFilesIncludingTs(dirPath, baseDir = dirPath) {
43
+ const results = [];
44
+ if (!dirPath || !fs.existsSync(dirPath)) return results;
45
+
46
+ try {
47
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ const name = entry.name;
50
+ if (isPrivateOrIgnored(name) || isClientBrowserFile(name)) continue;
51
+
52
+ const fullPath = path.join(dirPath, name);
53
+ const relPath = path.relative(baseDir, fullPath);
54
+
55
+ if (entry.isDirectory()) {
56
+ results.push(...scanApiFilesIncludingTs(fullPath, baseDir));
57
+ } else if (entry.isFile()) {
58
+ const ext = path.extname(name);
59
+ if (ext === '.js' || ext === '.ts') {
60
+ results.push({ absolutePath: fullPath, relativePath: relPath });
61
+ }
62
+ }
63
+ }
64
+ } catch {
65
+ // Ignore unreadable directories
66
+ }
67
+ return results;
68
+ }
69
+
70
+ /**
71
+ * Load an API route module from `.js` or `.ts` without leaving stale cache entries.
72
+ *
73
+ * @param {string} filePath
74
+ * @returns {{ mod: any, source: string }}
75
+ */
76
+ function loadApiRouteModule(filePath) {
77
+ const fullPath = path.resolve(filePath);
78
+ const source = fs.readFileSync(fullPath, 'utf8');
79
+ const ext = path.extname(fullPath);
80
+
81
+ if (ext === '.js') {
82
+ try {
83
+ delete require.cache[fullPath];
84
+ const loaded = require(fullPath);
85
+ return { mod: loaded && loaded.default ? loaded.default : loaded, source };
86
+ } catch {
87
+ // Fall through to TypeScript / ESM transpilation fallback
88
+ }
89
+ }
90
+
91
+ if (tsCompiler) {
92
+ try {
93
+ const transpiled = tsCompiler.transpileModule(source, {
94
+ compilerOptions: {
95
+ module: tsCompiler.ModuleKind.CommonJS,
96
+ target: tsCompiler.ScriptTarget.ES2022,
97
+ esModuleInterop: true,
98
+ },
99
+ fileName: fullPath,
100
+ });
101
+ const tempMod = new Module(fullPath, module);
102
+ tempMod.filename = fullPath;
103
+ tempMod.paths = Module._nodeModulePaths(path.dirname(fullPath));
104
+ tempMod._compile(transpiled.outputText, fullPath);
105
+ const exp = tempMod.exports;
106
+ return { mod: exp && exp.default ? exp.default : exp, source };
107
+ } catch {
108
+ // Fall through
109
+ }
110
+ }
111
+
112
+ return { mod: null, source };
113
+ }
114
+
115
+ /**
116
+ * Normalize a schema entry (which may be a ZodType or a plain shape object `{ key: z.string() }`)
117
+ * into a Zod schema instance.
118
+ *
119
+ * @param {any} rawSchema
120
+ * @returns {import('zod').ZodTypeAny | null}
121
+ */
122
+ function normalizeZodSchema(rawSchema) {
123
+ if (!rawSchema) return null;
124
+ if (typeof rawSchema.parse === 'function' && rawSchema._def) {
125
+ return rawSchema;
126
+ }
127
+ if (typeof rawSchema === 'object' && !Array.isArray(rawSchema)) {
128
+ const entries = Object.entries(rawSchema);
129
+ if (entries.length === 0) return z.object({});
130
+ const isStatusMap = entries.every(([key]) => /^[1-5]\d\d$/.test(String(key)));
131
+ if (isStatusMap) {
132
+ return null;
133
+ }
134
+ const isShape = entries.every(([, val]) => val && typeof val === 'object' && ('_def' in val || typeof val.parse === 'function'));
135
+ if (isShape) {
136
+ return z.object(rawSchema);
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Extract route parameter names from an Express route pattern (e.g. `/api/notes/:id` -> `['id']`).
144
+ *
145
+ * @param {string} routePath
146
+ * @returns {string[]}
147
+ */
148
+ function extractUrlParamNames(routePath) {
149
+ const names = [];
150
+ if (!routePath || typeof routePath !== 'string') return names;
151
+ const regex = /:([a-zA-Z0-9_]+)/g;
152
+ let match;
153
+ while ((match = regex.exec(routePath)) !== null) {
154
+ names.push(match[1]);
155
+ }
156
+ return names;
157
+ }
158
+
159
+ /**
160
+ * Inspect a Zod schema to determine if any field accepts `File` / `Blob` (`zdb.file()`)
161
+ * and extract static top-level field metadata for form contract checking and client coercion.
162
+ *
163
+ * @param {import('zod').ZodTypeAny | null} zSchema
164
+ * @returns {{ acceptsFormData: boolean, fields: Record<string, { type: string, required: boolean, isFile: boolean, isArray: boolean }> | null, isStrictObject: boolean }}
165
+ */
166
+ function inspectBodySchemaFields(zSchema) {
167
+ if (!zSchema || !zSchema._def) {
168
+ return { acceptsFormData: false, fields: null, isStrictObject: false };
169
+ }
170
+
171
+ let current = zSchema;
172
+ while (current && current._def && (current._def.typeName === 'ZodEffects' || current._def.typeName === 'ZodOptional' || current._def.typeName === 'ZodDefault')) {
173
+ current = current._def.schema || current._def.innerType;
174
+ }
175
+
176
+ if (!current || !current._def || current._def.typeName !== 'ZodObject') {
177
+ return { acceptsFormData: false, fields: null, isStrictObject: false };
178
+ }
179
+
180
+ const shape = typeof current.shape === 'function' ? current.shape() : current.shape;
181
+ if (!shape || typeof shape !== 'object') {
182
+ return { acceptsFormData: false, fields: {}, isStrictObject: true };
183
+ }
184
+
185
+ let acceptsFormData = false;
186
+ const fields = {};
187
+
188
+ for (const [key, fieldSchema] of Object.entries(shape)) {
189
+ let inner = fieldSchema;
190
+ let required = true;
191
+ let isArray = false;
192
+
193
+ while (inner && inner._def) {
194
+ const tName = inner._def.typeName;
195
+ if (tName === 'ZodOptional' || tName === 'ZodDefault') {
196
+ required = false;
197
+ inner = inner._def.innerType;
198
+ } else if (tName === 'ZodNullable') {
199
+ inner = inner._def.innerType;
200
+ } else if (tName === 'ZodEffects') {
201
+ inner = inner._def.schema;
202
+ } else if (tName === 'ZodArray') {
203
+ isArray = true;
204
+ inner = inner._def.type;
205
+ } else {
206
+ break;
207
+ }
208
+ }
209
+
210
+ const isFile = Boolean(
211
+ (fieldSchema && fieldSchema._zdbMeta && fieldSchema._zdbMeta.columnType === 'file') ||
212
+ (inner && inner._zdbMeta && inner._zdbMeta.columnType === 'file')
213
+ );
214
+ if (isFile) acceptsFormData = true;
215
+
216
+ const baseTypeName = inner && inner._def ? inner._def.typeName : 'ZodUnknown';
217
+ let scalarKind = 'string';
218
+ if (isFile) scalarKind = 'file';
219
+ else if (baseTypeName === 'ZodNumber') scalarKind = 'number';
220
+ else if (baseTypeName === 'ZodBoolean') scalarKind = 'boolean';
221
+ else if (baseTypeName === 'ZodDate') scalarKind = 'date';
222
+
223
+ fields[key] = {
224
+ type: scalarKind,
225
+ required,
226
+ isFile,
227
+ isArray,
228
+ };
229
+ }
230
+
231
+ return {
232
+ acceptsFormData,
233
+ fields,
234
+ isStrictObject: true,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Infer a TypeScript wire type from a handler function's source code / AST when no explicit
240
+ * `response` Zod schema is provided.
241
+ *
242
+ * Understands:
243
+ * - Streaming helpers: `res.sse()`, `res.streamText()`, `res.streamJson()`, `ctx.sse()`, etc.
244
+ * - `apiResult(data, meta)` / `ctx.result(data, meta)` / `ctx.apiResult(data, meta)` / `res.apiResult(data, meta)`
245
+ * - `res.status(204).end()` / `res.sendStatus(204)` -> `void`
246
+ * - `res.json(data)` / `res.status(200).json(data)` / `return data`
247
+ * - Object literals, shorthand identifiers resolved from local `const`/`let` declarations and `req.input.body.*`
248
+ *
249
+ * @param {Function|null} handlerFn
250
+ * @param {string} fileSource
251
+ * @param {{ bodySchema: any, querySchema: any, paramsSchema: any }} schemas
252
+ * @param {Function} zodToTsTypeFn
253
+ * @returns {{ responseType: string, isStreaming: boolean }}
254
+ */
255
+ function inferHandlerWireResponse(handlerFn, fileSource, schemas, zodToTsTypeFn) {
256
+ const src = String(
257
+ (typeof handlerFn === 'function' ? Function.prototype.toString.call(handlerFn) : '') ||
258
+ fileSource ||
259
+ ''
260
+ );
261
+
262
+ if (!src.trim()) {
263
+ return { responseType: 'unknown', isStreaming: false };
264
+ }
265
+
266
+ // 1. Detect streaming / raw endpoints
267
+ if (/\b(?:res|ctx)\s*\.\s*(?:sse|streamText|streamJson)\s*\(/.test(src)) {
268
+ return { responseType: 'never', isStreaming: true };
269
+ }
270
+
271
+ // 2. Build local variable type table from `req.input.body.<prop>` and literal assignments
272
+ const bodyFieldTypes = {};
273
+ if (schemas && schemas.bodySchema) {
274
+ let current = schemas.bodySchema;
275
+ while (current && current._def && (current._def.typeName === 'ZodEffects' || current._def.typeName === 'ZodOptional' || current._def.typeName === 'ZodDefault')) {
276
+ current = current._def.schema || current._def.innerType;
277
+ }
278
+ if (current && current._def && current._def.typeName === 'ZodObject') {
279
+ const shape = typeof current.shape === 'function' ? current.shape() : current.shape;
280
+ if (shape) {
281
+ for (const [k, v] of Object.entries(shape)) {
282
+ bodyFieldTypes[k] = zodToTsTypeFn(v, 2, 'wire');
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ const localVarTypes = new Map();
289
+ const varDeclRegex = /\b(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*([^;\n]+);?/g;
290
+ let declMatch;
291
+ while ((declMatch = varDeclRegex.exec(src)) !== null) {
292
+ const varName = declMatch[1];
293
+ const rhs = declMatch[2].trim();
294
+ const inferred = inferSimpleExpressionType(rhs, localVarTypes, bodyFieldTypes);
295
+ if (inferred !== 'unknown') {
296
+ localVarTypes.set(varName, inferred);
297
+ }
298
+ }
299
+
300
+ // 3. Collect return expressions and res.json / res.apiResult / ctx.result payloads
301
+ const candidateExprs = [];
302
+
303
+ // Match `apiResult(expr, ...)` / `ctx.result(expr, ...)` / `ctx.apiResult(expr, ...)` / `res.apiResult(expr, ...)`
304
+ const apiResultCalls = extractBalancedCallFirstArg(src, [
305
+ 'res.apiResult',
306
+ 'ctx.apiResult',
307
+ 'ctx.result',
308
+ 'apiResult',
309
+ ]);
310
+ for (const argExpr of apiResultCalls) {
311
+ candidateExprs.push(argExpr);
312
+ }
313
+
314
+ // Match `res.status(...).json(expr)` or `res.json(expr)`
315
+ const jsonCalls = extractBalancedCallFirstArg(src, ['json']);
316
+ for (const argExpr of jsonCalls) {
317
+ candidateExprs.push(argExpr);
318
+ }
319
+
320
+ // Match direct `return ...` statements that aren't `res.*` or `ctx.*` calls already captured
321
+ const returnLiterals = extractReturnExpressions(src);
322
+ for (const retExpr of returnLiterals) {
323
+ if (
324
+ !/^(?:await\s+)?(?:res|ctx)\s*\.\s*(?:status\s*\([^)]*\)\s*\.\s*)?(?:json|apiResult|result|end|sendStatus)\b/.test(retExpr) &&
325
+ !/^(?:await\s+)?apiResult\s*\(/.test(retExpr)
326
+ ) {
327
+ candidateExprs.push(retExpr);
328
+ }
329
+ }
330
+
331
+ if (candidateExprs.length === 0) {
332
+ if (/\bres\s*\.\s*(?:status\s*\(\s*204\s*\)\s*\.\s*end|sendStatus\s*\(\s*204\s*\))/.test(src)) {
333
+ return { responseType: 'void', isStreaming: false };
334
+ }
335
+ return { responseType: 'unknown', isStreaming: false };
336
+ }
337
+
338
+ const inferredTypes = [];
339
+ for (const expr of candidateExprs) {
340
+ const trimmed = expr.trim();
341
+ if (!trimmed || trimmed === 'undefined') {
342
+ inferredTypes.push('void');
343
+ continue;
344
+ }
345
+ // If expr is itself a call to apiResult / ctx.result, unwrap first arg
346
+ const nestedUnwrapped = extractBalancedCallFirstArg(trimmed, [
347
+ 'res.apiResult',
348
+ 'ctx.apiResult',
349
+ 'ctx.result',
350
+ 'apiResult',
351
+ ]);
352
+ const targetExpr = nestedUnwrapped.length > 0 ? nestedUnwrapped[0] : trimmed;
353
+ const t = inferExpressionWireType(targetExpr, localVarTypes, bodyFieldTypes);
354
+ if (t && !inferredTypes.includes(t)) {
355
+ inferredTypes.push(t);
356
+ }
357
+ }
358
+
359
+ if (inferredTypes.length === 0) {
360
+ return { responseType: 'unknown', isStreaming: false };
361
+ }
362
+ const nonUnknown = inferredTypes.filter((t) => t !== 'unknown');
363
+ const finalList = nonUnknown.length > 0 ? nonUnknown : inferredTypes;
364
+ return {
365
+ responseType: finalList.join(' | '),
366
+ isStreaming: false,
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Extract the first argument string of balanced calls to any of `fnNames`.
372
+ *
373
+ * @param {string} src
374
+ * @param {string[]} fnNames
375
+ * @returns {string[]}
376
+ */
377
+ function extractBalancedCallFirstArg(src, fnNames) {
378
+ const results = [];
379
+ for (const fnName of fnNames) {
380
+ const escaped = fnName.replace(/\./g, '\\s*\\.\\s*');
381
+ const regex = new RegExp(`(?:^|[^a-zA-Z0-9_$])${escaped}\\s*\\(`, 'g');
382
+ let match;
383
+ while ((match = regex.exec(src)) !== null) {
384
+ const openParenIdx = match.index + match[0].length - 1;
385
+ const arg = readFirstCallArgument(src, openParenIdx);
386
+ if (arg !== null) {
387
+ results.push(arg.trim());
388
+ }
389
+ }
390
+ }
391
+ return results;
392
+ }
393
+
394
+ /**
395
+ * Given the index of `(` in `src`, reads the first top-level argument up to `,` or matching `)`.
396
+ *
397
+ * @param {string} src
398
+ * @param {number} openParenIdx
399
+ * @returns {string|null}
400
+ */
401
+ function readFirstCallArgument(src, openParenIdx) {
402
+ let depthParen = 0;
403
+ let depthBrace = 0;
404
+ let depthBracket = 0;
405
+ let inString = null;
406
+ const start = openParenIdx + 1;
407
+
408
+ for (let i = start; i < src.length; i++) {
409
+ const ch = src[i];
410
+ const prev = i > 0 ? src[i - 1] : '';
411
+
412
+ if (inString) {
413
+ if (ch === inString && prev !== '\\') {
414
+ inString = null;
415
+ }
416
+ continue;
417
+ }
418
+
419
+ if (ch === "'" || ch === '"' || ch === '`') {
420
+ inString = ch;
421
+ continue;
422
+ }
423
+
424
+ if (ch === '(') depthParen++;
425
+ else if (ch === ')') {
426
+ if (depthParen === 0 && depthBrace === 0 && depthBracket === 0) {
427
+ return src.slice(start, i);
428
+ }
429
+ depthParen--;
430
+ } else if (ch === '{') depthBrace++;
431
+ else if (ch === '}') depthBrace--;
432
+ else if (ch === '[') depthBracket++;
433
+ else if (ch === ']') depthBracket--;
434
+ else if (ch === ',' && depthParen === 0 && depthBrace === 0 && depthBracket === 0) {
435
+ return src.slice(start, i);
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ /**
442
+ * Extract return statement expressions from function source.
443
+ *
444
+ * @param {string} src
445
+ * @returns {string[]}
446
+ */
447
+ function extractReturnExpressions(src) {
448
+ const results = [];
449
+ const regex = /\breturn\b\s*/g;
450
+ let match;
451
+ while ((match = regex.exec(src)) !== null) {
452
+ const start = match.index + match[0].length;
453
+ let depthParen = 0;
454
+ let depthBrace = 0;
455
+ let depthBracket = 0;
456
+ let inString = null;
457
+ let end = start;
458
+
459
+ for (let i = start; i < src.length; i++) {
460
+ const ch = src[i];
461
+ const prev = i > 0 ? src[i - 1] : '';
462
+ if (inString) {
463
+ if (ch === inString && prev !== '\\') inString = null;
464
+ continue;
465
+ }
466
+ if (ch === "'" || ch === '"' || ch === '`') {
467
+ inString = ch;
468
+ continue;
469
+ }
470
+ if (ch === '(') depthParen++;
471
+ else if (ch === ')') {
472
+ if (depthParen === 0 && depthBrace === 0 && depthBracket === 0) {
473
+ end = i;
474
+ break;
475
+ }
476
+ depthParen--;
477
+ } else if (ch === '{') depthBrace++;
478
+ else if (ch === '}') {
479
+ if (depthBrace === 0 && depthParen === 0 && depthBracket === 0) {
480
+ end = i;
481
+ break;
482
+ }
483
+ depthBrace--;
484
+ } else if (ch === '[') depthBracket++;
485
+ else if (ch === ']') depthBracket--;
486
+ else if ((ch === ';' || ch === '\n') && depthParen === 0 && depthBrace === 0 && depthBracket === 0) {
487
+ end = i;
488
+ break;
489
+ }
490
+ end = i + 1;
491
+ }
492
+
493
+ const expr = src.slice(start, end).trim();
494
+ if (expr) results.push(expr);
495
+ }
496
+ return results;
497
+ }
498
+
499
+ /**
500
+ * Infer a TypeScript type for simple expressions (numbers, strings, booleans, Dates, arrays, local vars).
501
+ */
502
+ function inferSimpleExpressionType(expr, localVarTypes, bodyFieldTypes) {
503
+ if (!expr) return 'unknown';
504
+ const clean = expr.replace(/^await\s+/, '').trim();
505
+
506
+ if (clean === 'true' || clean === 'false' || /^!\s*/.test(clean) || /^Boolean\s*\(/.test(clean)) {
507
+ return 'boolean';
508
+ }
509
+ if (/^-?\d+(?:\.\d+)?$/.test(clean) || /^Number\s*\(/.test(clean) || /^parseInt\s*\(/.test(clean) || /^parseFloat\s*\(/.test(clean) || /^Math\./.test(clean)) {
510
+ return 'number';
511
+ }
512
+ if (/^(?:'[^']*'|"[^"]*"|`[^`]*`)$/.test(clean) || /^String\s*\(/.test(clean) || /\.toISOString\s*\(\)/.test(clean)) {
513
+ return 'string';
514
+ }
515
+ if (/^new\s+Date\s*\(/.test(clean)) {
516
+ return 'string'; // Date serializes to ISO string on HTTP JSON wire
517
+ }
518
+ if (clean === 'null') return 'null';
519
+ if (clean === 'undefined') return 'undefined';
520
+
521
+ // Arithmetic addition/subtraction/multiplication where at least one side is numeric or counter
522
+ if (/[+\-*/]\s*\d+|\b(?:count|total|amount|page|limit|length|size|index)\b\s*[+\-*/]/.test(clean)) {
523
+ return 'number';
524
+ }
525
+ if (/\.length\b/.test(clean)) {
526
+ return 'number';
527
+ }
528
+
529
+ // `req.input.body.xyz` or `ctx.input.body.xyz`
530
+ const bodyPropMatch = clean.match(/\b(?:req|ctx)\.input\.body\.([a-zA-Z_$][a-zA-Z0-9_$]*)$/);
531
+ if (bodyPropMatch && bodyFieldTypes && bodyFieldTypes[bodyPropMatch[1]]) {
532
+ return bodyFieldTypes[bodyPropMatch[1]];
533
+ }
534
+
535
+ if (localVarTypes && localVarTypes.has(clean)) {
536
+ return localVarTypes.get(clean);
537
+ }
538
+
539
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(clean)) {
540
+ if (/^(?:count|total|amount|page|limit|offset|totalPages|version|index)$/.test(clean)) {
541
+ return 'number';
542
+ }
543
+ if (/^(?:ok|success|deleted|updated|created|active|enabled|hasMore)$/.test(clean)) {
544
+ return 'boolean';
545
+ }
546
+ if (/^(?:id|slug|title|name|message|status|query|filter|cursor|createdAt|updatedAt)$/.test(clean)) {
547
+ return 'string';
548
+ }
549
+ }
550
+
551
+ if (/\.id\b$/.test(clean)) {
552
+ return 'number | string';
553
+ }
554
+
555
+ return 'unknown';
556
+ }
557
+
558
+ /**
559
+ * Infer wire TypeScript type for an arbitrary returned expression (including object literals).
560
+ */
561
+ function inferExpressionWireType(expr, localVarTypes, bodyFieldTypes) {
562
+ const clean = expr.replace(/^await\s+/, '').trim();
563
+ if (!clean) return 'void';
564
+
565
+ if (clean.startsWith('{') && clean.endsWith('}')) {
566
+ const inner = clean.slice(1, -1).trim();
567
+ if (!inner) return 'Record<string, never>';
568
+ const props = splitTopLevelCommas(inner);
569
+ const propLines = [];
570
+
571
+ for (const rawProp of props) {
572
+ const prop = rawProp.trim();
573
+ if (!prop || prop.startsWith('...')) continue;
574
+ const colonIdx = findTopLevelColon(prop);
575
+ if (colonIdx === -1) {
576
+ // Shorthand property `{ count }`
577
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(prop)) {
578
+ const valType = inferSimpleExpressionType(prop, localVarTypes, bodyFieldTypes);
579
+ if (valType !== 'undefined' && valType !== 'void') {
580
+ propLines.push(`${prop}: ${valType};`);
581
+ }
582
+ }
583
+ } else {
584
+ const keyPart = prop.slice(0, colonIdx).trim();
585
+ const valPart = prop.slice(colonIdx + 1).trim();
586
+ const valType = valPart.startsWith('{') && valPart.endsWith('}')
587
+ ? inferExpressionWireType(valPart, localVarTypes, bodyFieldTypes)
588
+ : inferSimpleExpressionType(valPart, localVarTypes, bodyFieldTypes);
589
+ // Omit undefined properties on the JSON wire
590
+ if (valType === 'undefined' || valType === 'void') continue;
591
+ propLines.push(`${keyPart}: ${valType};`);
592
+ }
593
+ }
594
+
595
+ if (propLines.length === 0) return 'Record<string, never>';
596
+ return `{ ${propLines.join(' ')} }`;
597
+ }
598
+
599
+ if (clean.startsWith('[') && clean.endsWith(']')) {
600
+ return 'Array<unknown>';
601
+ }
602
+
603
+ return inferSimpleExpressionType(clean, localVarTypes, bodyFieldTypes);
604
+ }
605
+
606
+ function splitTopLevelCommas(str) {
607
+ const parts = [];
608
+ let depthParen = 0;
609
+ let depthBrace = 0;
610
+ let depthBracket = 0;
611
+ let inString = null;
612
+ let lastIdx = 0;
613
+
614
+ for (let i = 0; i < str.length; i++) {
615
+ const ch = str[i];
616
+ const prev = i > 0 ? str[i - 1] : '';
617
+ if (inString) {
618
+ if (ch === inString && prev !== '\\') inString = null;
619
+ continue;
620
+ }
621
+ if (ch === "'" || ch === '"' || ch === '`') {
622
+ inString = ch;
623
+ continue;
624
+ }
625
+ if (ch === '(') depthParen++;
626
+ else if (ch === ')') depthParen--;
627
+ else if (ch === '{') depthBrace++;
628
+ else if (ch === '}') depthBrace--;
629
+ else if (ch === '[') depthBracket++;
630
+ else if (ch === ']') depthBracket--;
631
+ else if (ch === ',' && depthParen === 0 && depthBrace === 0 && depthBracket === 0) {
632
+ parts.push(str.slice(lastIdx, i));
633
+ lastIdx = i + 1;
634
+ }
635
+ }
636
+ parts.push(str.slice(lastIdx));
637
+ return parts;
638
+ }
639
+
640
+ function findTopLevelColon(str) {
641
+ let depthParen = 0;
642
+ let depthBrace = 0;
643
+ let depthBracket = 0;
644
+ let inString = null;
645
+
646
+ for (let i = 0; i < str.length; i++) {
647
+ const ch = str[i];
648
+ const prev = i > 0 ? str[i - 1] : '';
649
+ if (inString) {
650
+ if (ch === inString && prev !== '\\') inString = null;
651
+ continue;
652
+ }
653
+ if (ch === "'" || ch === '"' || ch === '`') {
654
+ inString = ch;
655
+ continue;
656
+ }
657
+ if (ch === '(') depthParen++;
658
+ else if (ch === ')') depthParen--;
659
+ else if (ch === '{') depthBrace++;
660
+ else if (ch === '}') depthBrace--;
661
+ else if (ch === '[') depthBracket++;
662
+ else if (ch === ']') depthBracket--;
663
+ else if (ch === ':' && depthParen === 0 && depthBrace === 0 && depthBracket === 0) {
664
+ return i;
665
+ }
666
+ }
667
+ return -1;
668
+ }
669
+
670
+ /**
671
+ * Collect all API route contracts from the project (`pages/api`, `src/pages/api`, `src/api`, and modules).
672
+ *
673
+ * @param {string} rootDir
674
+ * @param {Object} [options]
675
+ * @param {string} [options.pagesDir]
676
+ * @param {Function} options.zodToTsType
677
+ * @returns {Array<Object>}
678
+ */
679
+ function collectApiRouteContracts(rootDir, options = {}) {
680
+ const zodToTsTypeFn = options.zodToTsType;
681
+ const pagesRelDir =
682
+ options.pagesDir ||
683
+ (fs.existsSync(path.join(rootDir, 'src/pages')) ? 'src/pages' : 'pages');
684
+
685
+ const candidateDirs = [
686
+ { dir: path.resolve(rootDir, pagesRelDir, 'api'), prefix: '/api' },
687
+ { dir: path.resolve(rootDir, 'src/api'), prefix: '/api' },
688
+ ];
689
+
690
+ // Also include standard scanRoutes API descriptors (e.g. modular routes)
691
+ const seenFiles = new Set();
692
+ const rawRouteEntries = [];
693
+
694
+ for (const { dir, prefix } of candidateDirs) {
695
+ if (!fs.existsSync(dir)) continue;
696
+ const files = scanApiFilesIncludingTs(dir, dir);
697
+ for (const { absolutePath, relativePath } of files) {
698
+ if (seenFiles.has(absolutePath)) continue;
699
+ const parsed = parseFileRoute(relativePath, { type: 'api', prefix });
700
+ if (parsed && parsed.isValid) {
701
+ seenFiles.add(absolutePath);
702
+ rawRouteEntries.push({
703
+ method: (parsed.method || 'GET').toUpperCase(),
704
+ path: parsed.path,
705
+ file: absolutePath,
706
+ sourceRel: path.relative(rootDir, absolutePath).replace(/\\/g, '/'),
707
+ });
708
+ }
709
+ }
710
+ }
711
+
712
+ try {
713
+ const pagesDir = path.resolve(rootDir, pagesRelDir);
714
+ const apiRel = path.join(pagesRelDir, 'api');
715
+ const scanned = scanRoutes({
716
+ rootDir,
717
+ pages: fs.existsSync(pagesDir) ? { dir: pagesRelDir } : false,
718
+ api: fs.existsSync(path.resolve(rootDir, apiRel)) ? { dir: apiRel } : false,
719
+ });
720
+ for (const d of scanned) {
721
+ if (d.type !== 'api' || !d.file || seenFiles.has(d.file) || isClientBrowserFile(d.file)) {
722
+ continue;
723
+ }
724
+ seenFiles.add(d.file);
725
+ rawRouteEntries.push({
726
+ method: (d.method || 'GET').toUpperCase(),
727
+ path: d.path || d.pattern,
728
+ file: d.file,
729
+ sourceRel: path.relative(rootDir, d.file).replace(/\\/g, '/'),
730
+ });
731
+ }
732
+ } catch {
733
+ // Ignore scan errors
734
+ }
735
+
736
+ rawRouteEntries.sort((a, b) => {
737
+ const cmp = a.path.localeCompare(b.path);
738
+ return cmp !== 0 ? cmp : a.method.localeCompare(b.method);
739
+ });
740
+
741
+ const contracts = [];
742
+
743
+ for (const entry of rawRouteEntries) {
744
+ const { mod, source } = loadApiRouteModule(entry.file);
745
+ const def = mod && typeof mod === 'object' ? mod : {};
746
+
747
+ let rawSchema = def.schema;
748
+ if (typeof rawSchema === 'function') {
749
+ try {
750
+ rawSchema = rawSchema({ z, zdb });
751
+ } catch {
752
+ rawSchema = null;
753
+ }
754
+ }
755
+
756
+ const bodySchema = rawSchema && rawSchema.body ? normalizeZodSchema(rawSchema.body) : null;
757
+ const querySchema = rawSchema && rawSchema.query ? normalizeZodSchema(rawSchema.query) : null;
758
+ const paramsSchema = rawSchema && rawSchema.params ? normalizeZodSchema(rawSchema.params) : null;
759
+
760
+ const urlParamNames = extractUrlParamNames(entry.path);
761
+ let defaultParamsType = 'Record<string, never>';
762
+ if (urlParamNames.length > 0) {
763
+ defaultParamsType = `{ ${urlParamNames.map((p) => `${p}: string | number`).join('; ')} }`;
764
+ }
765
+
766
+ const inputBodyType = bodySchema
767
+ ? zodToTsTypeFn(bodySchema, 4, 'input')
768
+ : 'undefined';
769
+ const parsedBodyType = bodySchema
770
+ ? zodToTsTypeFn(bodySchema, 4, 'output')
771
+ : 'undefined';
772
+
773
+ const inputQueryType = querySchema
774
+ ? zodToTsTypeFn(querySchema, 4, 'input')
775
+ : 'Record<string, string | number | boolean | undefined>';
776
+ const parsedQueryType = querySchema
777
+ ? zodToTsTypeFn(querySchema, 4, 'output')
778
+ : 'Record<string, string | undefined>';
779
+
780
+ const inputParamsType = paramsSchema
781
+ ? zodToTsTypeFn(paramsSchema, 4, 'input')
782
+ : defaultParamsType;
783
+ const parsedParamsType = paramsSchema
784
+ ? zodToTsTypeFn(paramsSchema, 4, 'output')
785
+ : defaultParamsType;
786
+
787
+ const bodyInspection = inspectBodySchemaFields(bodySchema);
788
+ const queryInspection = inspectBodySchemaFields(querySchema);
789
+
790
+ // Determine response type & streaming status
791
+ let responseType = 'unknown';
792
+ let isStreaming = false;
793
+
794
+ const explicitResponse =
795
+ def.response ||
796
+ (rawSchema && rawSchema.response ? rawSchema.response : null);
797
+
798
+ if (explicitResponse) {
799
+ const directZod = normalizeZodSchema(explicitResponse);
800
+ if (directZod) {
801
+ responseType = zodToTsTypeFn(directZod, 3, 'wire');
802
+ } else if (typeof explicitResponse === 'object') {
803
+ if (
804
+ (explicitResponse[204] || explicitResponse['204']) &&
805
+ !explicitResponse[200] &&
806
+ !explicitResponse['200'] &&
807
+ !explicitResponse[201] &&
808
+ !explicitResponse['201']
809
+ ) {
810
+ responseType = 'void';
811
+ } else {
812
+ const statusSchemas = [
813
+ explicitResponse[200] || explicitResponse['200'],
814
+ explicitResponse[201] || explicitResponse['201'],
815
+ ]
816
+ .map((s) => normalizeZodSchema(s))
817
+ .filter(Boolean);
818
+ if (statusSchemas.length > 0) {
819
+ responseType = statusSchemas
820
+ .map((s) => zodToTsTypeFn(s, 3, 'wire'))
821
+ .join(' | ');
822
+ }
823
+ }
824
+ }
825
+ }
826
+
827
+ if (responseType === 'unknown') {
828
+ const inferred = inferHandlerWireResponse(
829
+ def.handler || (typeof mod === 'function' ? mod : null),
830
+ source,
831
+ { bodySchema, querySchema, paramsSchema },
832
+ zodToTsTypeFn
833
+ );
834
+ responseType = inferred.responseType;
835
+ isStreaming = inferred.isStreaming;
836
+ }
837
+
838
+ contracts.push({
839
+ method: entry.method,
840
+ path: entry.path,
841
+ file: entry.file,
842
+ sourceRel: entry.sourceRel,
843
+ hasBodySchema: Boolean(bodySchema),
844
+ hasQuerySchema: Boolean(querySchema),
845
+ hasParamsSchema: Boolean(paramsSchema),
846
+ urlParamNames,
847
+ inputBodyType,
848
+ parsedBodyType,
849
+ inputQueryType,
850
+ parsedQueryType,
851
+ inputParamsType,
852
+ parsedParamsType,
853
+ responseType,
854
+ isStreaming,
855
+ acceptsFormData: bodyInspection.acceptsFormData,
856
+ bodyFields: bodyInspection.fields,
857
+ isStrictBodyObject: bodyInspection.isStrictObject,
858
+ queryFields: queryInspection.fields,
859
+ isStrictQueryObject: queryInspection.isStrictObject,
860
+ });
861
+ }
862
+
863
+ return contracts;
864
+ }
865
+
866
+ /**
867
+ * Generate the `.webspresso/api.d.ts` TypeScript manifest content.
868
+ *
869
+ * @param {Array<Object>} contracts
870
+ * @returns {string}
871
+ */
872
+ function generateApiDeclarationCode(contracts = []) {
873
+ const timestamp = new Date().toISOString();
874
+
875
+ let code = `/**
876
+ * Webspresso Typed API & Form Contract Manifest
877
+ * Auto-generated on ${timestamp}
878
+ * DO NOT EDIT DIRECTLY. Regenerated automatically during dev and build.
879
+ */
880
+
881
+ export type HttpWirePrimitive = string | number | boolean | null;
882
+
883
+ /**
884
+ * Models HTTP JSON wire serialization:
885
+ * - Date & BigInt serialize to string
886
+ * - Unwraps Webspresso apiResult({ data, invalidate }) envelopes
887
+ * - Strips undefined-only properties
888
+ */
889
+ export type WireSerialize<T> =
890
+ T extends void | undefined ? void :
891
+ T extends Date | bigint ? string :
892
+ T extends HttpWirePrimitive ? T :
893
+ T extends { __webspressoApiResult: true; data: infer D } ? WireSerialize<D> :
894
+ T extends Array<infer U> ? Array<WireSerialize<U>> :
895
+ T extends object ? {
896
+ [K in keyof T as [T[K]] extends [undefined | void] ? never : K]: WireSerialize<T[K]>;
897
+ } :
898
+ unknown;
899
+
900
+ export type ConcurrencyPolicy = 'latest' | 'drop' | 'queue' | 'parallel';
901
+
902
+ export interface ApiCallOptions<TQuery = Record<string, unknown>, TParams = Record<string, string | number>> {
903
+ query?: TQuery;
904
+ params?: TParams;
905
+ headers?: Record<string, string>;
906
+ signal?: AbortSignal;
907
+ key?: string;
908
+ concurrency?: ConcurrencyPolicy;
909
+ formData?: boolean;
910
+ }
911
+
912
+ /* ============================================================================
913
+ * Discovered API Route Contracts (Input vs Server Parsed vs HTTP Wire Output)
914
+ * ========================================================================= */
915
+
916
+ export interface WebspressoApiRouteMap {
917
+ `;
918
+
919
+ // Group contracts by method -> path
920
+ const byMethod = {
921
+ GET: [],
922
+ POST: [],
923
+ PUT: [],
924
+ PATCH: [],
925
+ DELETE: [],
926
+ };
927
+
928
+ for (const c of contracts) {
929
+ const m = c.method.toUpperCase();
930
+ if (!byMethod[m]) byMethod[m] = [];
931
+ byMethod[m].push(c);
932
+ }
933
+
934
+ for (const [method, list] of Object.entries(byMethod)) {
935
+ if (list.length === 0) {
936
+ code += ` ${method}: Record<string, never>;\n`;
937
+ continue;
938
+ }
939
+ code += ` ${method}: {\n`;
940
+ for (const c of list) {
941
+ code += ` /** Source: ${c.sourceRel} */\n`;
942
+ code += ` '${c.path}': {\n`;
943
+ code += ` input: {\n`;
944
+ code += ` body: ${c.inputBodyType};\n`;
945
+ code += ` query: ${c.inputQueryType};\n`;
946
+ code += ` params: ${c.inputParamsType};\n`;
947
+ code += ` };\n`;
948
+ code += ` parsed: {\n`;
949
+ code += ` body: ${c.parsedBodyType};\n`;
950
+ code += ` query: ${c.parsedQueryType};\n`;
951
+ code += ` params: ${c.parsedParamsType};\n`;
952
+ code += ` };\n`;
953
+ code += ` response: ${c.isStreaming ? 'never' : `WireSerialize<${c.responseType}>`};\n`;
954
+ code += ` acceptsFormData: ${c.acceptsFormData ? 'true' : 'false'};\n`;
955
+ code += ` isStreaming: ${c.isStreaming ? 'true' : 'false'};\n`;
956
+ code += ` };\n`;
957
+ }
958
+ code += ` };\n`;
959
+ }
960
+
961
+ code += `}
962
+
963
+ declare module 'webspresso' {
964
+ interface AppApiRouteMap extends WebspressoApiRouteMap {}
965
+ }
966
+
967
+ export type ApiMethod = keyof WebspressoApiRouteMap;
968
+
969
+ export type NonStreamingPaths<M extends ApiMethod> = {
970
+ [P in keyof WebspressoApiRouteMap[M]]: WebspressoApiRouteMap[M][P] extends { isStreaming: true } ? never : P;
971
+ }[keyof WebspressoApiRouteMap[M]] & string;
972
+
973
+ export type StreamingOrAnyPaths<M extends ApiMethod> = (keyof WebspressoApiRouteMap[M] & string) | (string & {});
974
+
975
+ export type RouteInputBody<M extends ApiMethod, P extends keyof WebspressoApiRouteMap[M]> =
976
+ WebspressoApiRouteMap[M][P] extends { input: { body: infer B } } ? B : unknown;
977
+
978
+ export type RouteInputQuery<M extends ApiMethod, P extends keyof WebspressoApiRouteMap[M]> =
979
+ WebspressoApiRouteMap[M][P] extends { input: { query: infer Q } } ? Q : Record<string, unknown>;
980
+
981
+ export type RouteInputParams<M extends ApiMethod, P extends keyof WebspressoApiRouteMap[M]> =
982
+ WebspressoApiRouteMap[M][P] extends { input: { params: infer R } } ? R : Record<string, string | number>;
983
+
984
+ export type RouteWireResponse<M extends ApiMethod, P extends keyof WebspressoApiRouteMap[M]> =
985
+ WebspressoApiRouteMap[M][P] extends { response: infer Res } ? Res : unknown;
986
+
987
+ export type RouteBodyArg<M extends ApiMethod, P extends keyof WebspressoApiRouteMap[M]> =
988
+ WebspressoApiRouteMap[M][P] extends { acceptsFormData: true }
989
+ ? RouteInputBody<M, P> | FormData
990
+ : RouteInputBody<M, P>;
991
+
992
+ export interface TypedApiClient {
993
+ get<P extends NonStreamingPaths<'GET'>>(
994
+ url: P,
995
+ options?: ApiCallOptions<RouteInputQuery<'GET', P>, RouteInputParams<'GET', P>>
996
+ ): Promise<RouteWireResponse<'GET', P>>;
997
+
998
+ post<P extends NonStreamingPaths<'POST'>>(
999
+ url: P,
1000
+ ...args: [RouteInputBody<'POST', P>] extends [undefined]
1001
+ ? [body?: undefined, options?: ApiCallOptions<RouteInputQuery<'POST', P>, RouteInputParams<'POST', P>>]
1002
+ : [body: RouteBodyArg<'POST', P>, options?: ApiCallOptions<RouteInputQuery<'POST', P>, RouteInputParams<'POST', P>>]
1003
+ ): Promise<RouteWireResponse<'POST', P>>;
1004
+
1005
+ put<P extends NonStreamingPaths<'PUT'>>(
1006
+ url: P,
1007
+ ...args: [RouteInputBody<'PUT', P>] extends [undefined]
1008
+ ? [body?: undefined, options?: ApiCallOptions<RouteInputQuery<'PUT', P>, RouteInputParams<'PUT', P>>]
1009
+ : [body: RouteBodyArg<'PUT', P>, options?: ApiCallOptions<RouteInputQuery<'PUT', P>, RouteInputParams<'PUT', P>>]
1010
+ ): Promise<RouteWireResponse<'PUT', P>>;
1011
+
1012
+ patch<P extends NonStreamingPaths<'PATCH'>>(
1013
+ url: P,
1014
+ ...args: [RouteInputBody<'PATCH', P>] extends [undefined]
1015
+ ? [body?: undefined, options?: ApiCallOptions<RouteInputQuery<'PATCH', P>, RouteInputParams<'PATCH', P>>]
1016
+ : [body: RouteBodyArg<'PATCH', P>, options?: ApiCallOptions<RouteInputQuery<'PATCH', P>, RouteInputParams<'PATCH', P>>]
1017
+ ): Promise<RouteWireResponse<'PATCH', P>>;
1018
+
1019
+ delete<P extends NonStreamingPaths<'DELETE'>>(
1020
+ url: P,
1021
+ ...args: [RouteInputBody<'DELETE', P>] extends [undefined]
1022
+ ? [body?: undefined, options?: ApiCallOptions<RouteInputQuery<'DELETE', P>, RouteInputParams<'DELETE', P>>]
1023
+ : [body: RouteBodyArg<'DELETE', P>, options?: ApiCallOptions<RouteInputQuery<'DELETE', P>, RouteInputParams<'DELETE', P>>]
1024
+ ): Promise<RouteWireResponse<'DELETE', P>>;
1025
+
1026
+ /**
1027
+ * Honest escape hatch for arbitrary non-JSON or custom endpoints.
1028
+ */
1029
+ raw(url: StreamingOrAnyPaths<ApiMethod>, options?: RequestInit & ApiCallOptions): Promise<Response>;
1030
+
1031
+ /**
1032
+ * Honest escape hatch for streaming (SSE / NDJSON / chunked text) endpoints.
1033
+ */
1034
+ stream(url: StreamingOrAnyPaths<ApiMethod>, options?: ApiCallOptions): Promise<ReadableStream<Uint8Array> | Response>;
1035
+ }
1036
+
1037
+ export interface TypedFormHandle<
1038
+ M extends ApiMethod = 'POST',
1039
+ P extends keyof WebspressoApiRouteMap[M] = keyof WebspressoApiRouteMap[M]
1040
+ > {
1041
+ readonly name: string;
1042
+ readonly el: HTMLFormElement | null;
1043
+ readonly pending: boolean;
1044
+ readonly dirty: boolean;
1045
+ readonly formError: string;
1046
+ readonly fieldErrors: Partial<Record<keyof RouteInputBody<M, P> & string, string>> & Record<string, string>;
1047
+ values(): RouteInputBody<M, P>;
1048
+ reset(): void;
1049
+ clear(): void;
1050
+ setErrors(fieldErrors?: Partial<Record<keyof RouteInputBody<M, P> & string, string>>, formError?: string): void;
1051
+ clearErrors(): void;
1052
+ submit(submitter?: HTMLElement | null): Promise<{
1053
+ ok: boolean;
1054
+ data?: RouteWireResponse<M, P>;
1055
+ refreshOk?: boolean;
1056
+ refreshError?: Error | null;
1057
+ error?: Error;
1058
+ }>;
1059
+ onSuccess(callback: (data: RouteWireResponse<M, P>, meta: { refreshOk: boolean; refreshError: Error | null }) => void): () => void;
1060
+ }
1061
+ `;
1062
+
1063
+ return code;
1064
+ }
1065
+
1066
+ /**
1067
+ * Scan HTML / Nunjucks templates and compare static `<form action="/api/...">` field names
1068
+ * against the discovered API route schemas.
1069
+ *
1070
+ * - Reports an error if a static field name does not exist in the target route's strict object schema.
1071
+ * - Reports a warning (not false certainty) when dynamic Nunjucks expressions are used in `name="..."` or `action="..."`.
1072
+ *
1073
+ * @param {string} rootDir
1074
+ * @param {Object} [options]
1075
+ * @param {Array<Object>} [options.contracts]
1076
+ * @param {Function} [options.zodToTsType]
1077
+ * @returns {{ errors: Array<Object>, warnings: Array<Object>, checkedFormsCount: number }}
1078
+ */
1079
+ function checkTemplateFormContracts(rootDir, options = {}) {
1080
+ const contracts =
1081
+ options.contracts ||
1082
+ collectApiRouteContracts(rootDir, {
1083
+ pagesDir: options.pagesDir,
1084
+ zodToTsType: options.zodToTsType || (() => 'unknown'),
1085
+ });
1086
+
1087
+ const contractMap = new Map();
1088
+ for (const c of contracts) {
1089
+ contractMap.set(`${c.method.toUpperCase()} ${c.path}`, c);
1090
+ }
1091
+
1092
+ const templateDirs = [
1093
+ path.resolve(rootDir, options.pagesDir || 'pages'),
1094
+ path.resolve(rootDir, 'src/pages'),
1095
+ path.resolve(rootDir, 'views'),
1096
+ path.resolve(rootDir, 'src/views'),
1097
+ ];
1098
+
1099
+ const seenTemplates = new Set();
1100
+ const templateFiles = [];
1101
+
1102
+ function walkTemplates(dir) {
1103
+ if (!fs.existsSync(dir)) return;
1104
+ let entries = [];
1105
+ try {
1106
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1107
+ } catch {
1108
+ return;
1109
+ }
1110
+ for (const entry of entries) {
1111
+ if (entry.name.startsWith('.')) continue;
1112
+ const full = path.join(dir, entry.name);
1113
+ if (entry.isDirectory()) {
1114
+ walkTemplates(full);
1115
+ } else if (entry.isFile() && (entry.name.endsWith('.njk') || entry.name.endsWith('.html'))) {
1116
+ if (!seenTemplates.has(full)) {
1117
+ seenTemplates.add(full);
1118
+ templateFiles.push(full);
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ for (const d of templateDirs) {
1125
+ walkTemplates(d);
1126
+ }
1127
+
1128
+ const errors = [];
1129
+ const warnings = [];
1130
+ let checkedFormsCount = 0;
1131
+
1132
+ const formRegex = /<form\b([^>]*)>([\s\S]*?)<\/form>/gi;
1133
+ const controlRegex = /<(?:input|select|textarea|button)\b([^>]*)>/gi;
1134
+
1135
+ for (const tplFile of templateFiles) {
1136
+ const relFile = path.relative(rootDir, tplFile).replace(/\\/g, '/');
1137
+ let content = '';
1138
+ try {
1139
+ content = fs.readFileSync(tplFile, 'utf8');
1140
+ } catch {
1141
+ continue;
1142
+ }
1143
+
1144
+ let formMatch;
1145
+ while ((formMatch = formRegex.exec(content)) !== null) {
1146
+ const formAttrs = formMatch[1] || '';
1147
+ const formInner = formMatch[2] || '';
1148
+
1149
+ const actionMatch = formAttrs.match(/\baction\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
1150
+ if (!actionMatch) continue;
1151
+ const actionRaw = (actionMatch[1] ?? actionMatch[2] ?? '').trim();
1152
+ if (!actionRaw) continue;
1153
+
1154
+ const methodMatch = formAttrs.match(/\bmethod\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
1155
+ const method = ((methodMatch ? (methodMatch[1] ?? methodMatch[2]) : 'GET') || 'GET').trim().toUpperCase();
1156
+
1157
+ // Dynamic action URL expression
1158
+ if (actionRaw.includes('{{') || actionRaw.includes('{%')) {
1159
+ // Attempt to normalize dynamic parameter segments like `/api/notes/{{ note.id }}` -> `/api/notes/:param`
1160
+ const normalizedPattern = actionRaw
1161
+ .split('?')[0]
1162
+ .replace(/\{\{[^}]+\}\}/g, ':param');
1163
+ const matchedDynamic = contracts.find(
1164
+ (c) =>
1165
+ c.method === method &&
1166
+ c.path.replace(/:[a-zA-Z0-9_]+/g, ':param') === normalizedPattern
1167
+ );
1168
+ if (!matchedDynamic) {
1169
+ warnings.push({
1170
+ code: 'DYNAMIC_FORM_ACTION',
1171
+ file: relFile,
1172
+ action: actionRaw,
1173
+ method,
1174
+ message: `Form in "${relFile}" uses dynamic action "${actionRaw}" (${method}); static route contract cannot be resolved with certainty.`,
1175
+ });
1176
+ continue;
1177
+ }
1178
+ verifyFormControlsAgainstContract(
1179
+ matchedDynamic,
1180
+ formInner,
1181
+ relFile,
1182
+ actionRaw,
1183
+ method,
1184
+ controlRegex,
1185
+ errors,
1186
+ warnings
1187
+ );
1188
+ checkedFormsCount++;
1189
+ continue;
1190
+ }
1191
+
1192
+ const cleanActionPath = actionRaw.split('?')[0];
1193
+ if (!cleanActionPath.startsWith('/api/')) continue;
1194
+
1195
+ checkedFormsCount++;
1196
+
1197
+ // Find exact or parameterized route match
1198
+ let targetContract = contractMap.get(`${method} ${cleanActionPath}`);
1199
+ if (!targetContract) {
1200
+ targetContract = contracts.find((c) => {
1201
+ if (c.method !== method) return false;
1202
+ const regexStr = '^' + c.path.replace(/:[a-zA-Z0-9_]+/g, '[^/]+') + '$';
1203
+ return new RegExp(regexStr).test(cleanActionPath);
1204
+ });
1205
+ }
1206
+
1207
+ if (!targetContract) {
1208
+ errors.push({
1209
+ code: 'MISSING_FORM_ACTION_ROUTE',
1210
+ file: relFile,
1211
+ action: cleanActionPath,
1212
+ method,
1213
+ message: `Form in "${relFile}" targets "${method} ${cleanActionPath}", which does not match any discovered API route.`,
1214
+ });
1215
+ continue;
1216
+ }
1217
+
1218
+ verifyFormControlsAgainstContract(
1219
+ targetContract,
1220
+ formInner,
1221
+ relFile,
1222
+ cleanActionPath,
1223
+ method,
1224
+ controlRegex,
1225
+ errors,
1226
+ warnings
1227
+ );
1228
+ }
1229
+ }
1230
+
1231
+ return {
1232
+ errors,
1233
+ warnings,
1234
+ checkedFormsCount,
1235
+ };
1236
+ }
1237
+
1238
+ function verifyFormControlsAgainstContract(
1239
+ contract,
1240
+ formInner,
1241
+ relFile,
1242
+ action,
1243
+ method,
1244
+ controlRegex,
1245
+ errors,
1246
+ warnings
1247
+ ) {
1248
+ const targetFields = method === 'GET' ? contract.queryFields : contract.bodyFields;
1249
+ const isStrict = method === 'GET' ? contract.isStrictQueryObject : contract.isStrictBodyObject;
1250
+ if (!isStrict || !targetFields) return;
1251
+
1252
+ controlRegex.lastIndex = 0;
1253
+ let ctrlMatch;
1254
+ while ((ctrlMatch = controlRegex.exec(formInner)) !== null) {
1255
+ const attrs = ctrlMatch[1] || '';
1256
+ const nameMatch = attrs.match(/\bname\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
1257
+ if (!nameMatch) continue;
1258
+ const rawName = (nameMatch[1] ?? nameMatch[2] ?? '').trim();
1259
+ if (!rawName) continue;
1260
+
1261
+ // Ignore CSRF fields
1262
+ if (rawName === '_csrf' || rawName === 'csrfToken') continue;
1263
+
1264
+ if (rawName.includes('{{') || rawName.includes('{%')) {
1265
+ warnings.push({
1266
+ code: 'DYNAMIC_FORM_FIELD_NAME',
1267
+ file: relFile,
1268
+ action,
1269
+ method,
1270
+ field: rawName,
1271
+ message: `Form in "${relFile}" (${method} ${action}) has dynamic field name "${rawName}"; skipping static schema check to avoid false certainty.`,
1272
+ });
1273
+ continue;
1274
+ }
1275
+
1276
+ const normalizedName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
1277
+ if (!Object.prototype.hasOwnProperty.call(targetFields, normalizedName)) {
1278
+ errors.push({
1279
+ code: 'UNKNOWN_FORM_FIELD',
1280
+ file: relFile,
1281
+ action,
1282
+ method,
1283
+ field: normalizedName,
1284
+ allowedFields: Object.keys(targetFields),
1285
+ message: `Form in "${relFile}" (${method} ${action}) defines field name="${rawName}", which is not declared in ${contract.sourceRel} schema (${Object.keys(targetFields).join(', ') || 'none'}).`,
1286
+ });
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ module.exports = {
1292
+ scanApiFilesIncludingTs,
1293
+ loadApiRouteModule,
1294
+ normalizeZodSchema,
1295
+ inspectBodySchemaFields,
1296
+ inferHandlerWireResponse,
1297
+ collectApiRouteContracts,
1298
+ generateApiDeclarationCode,
1299
+ checkTemplateFormContracts,
1300
+ };