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.
- package/LICENSE +21 -0
- package/README.md +3 -3
- package/bin/commands/add-esbuild.js +7 -3
- package/bin/commands/add-tailwind.js +9 -5
- package/bin/commands/build.js +20 -4
- package/bin/commands/dev.js +57 -7
- package/bin/commands/doctor.js +42 -1
- package/bin/commands/favicon-generate.js +10 -3
- package/bin/commands/new.js +8 -3
- package/bin/commands/start.js +3 -1
- package/bin/utils/add-command.js +16 -0
- package/core/app.js +45 -3
- package/core/auth/authenticate.js +3 -3
- package/core/auth/hash.js +167 -43
- package/core/auth/index.js +13 -1
- package/core/auth/jwt.js +21 -2
- package/core/auth/manager.js +63 -14
- package/core/auth/middleware.js +4 -2
- package/core/auth/oauth.js +5 -1
- package/core/auth/tokens.js +7 -2
- package/core/auth/totp.js +42 -6
- package/core/auth/user-agent.js +2 -0
- package/core/client/adapter/datastar.js +1401 -0
- package/core/client/api.js +1337 -0
- package/core/client/controller.js +1706 -0
- package/core/client/debug.js +367 -0
- package/core/client/devtool.js +1134 -0
- package/core/client/forms.js +1164 -0
- package/core/client/fragments.js +894 -0
- package/core/client/index.d.ts +59 -0
- package/core/client/index.js +391 -0
- package/core/client/lite.js +175 -0
- package/core/client/server-fragments.js +868 -0
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +281 -0
- package/core/compression/middleware.js +277 -26
- package/core/context/app-context.js +25 -3
- package/core/context/request.js +32 -4
- package/core/errors/middleware.js +14 -0
- package/core/events/dispatcher.js +33 -0
- package/core/forms/flash.js +136 -102
- package/core/health/middleware.js +36 -6
- package/core/http/pipeline.js +45 -6
- package/core/index.js +25 -7
- package/core/kernel/view.js +41 -10
- package/core/metrics/middleware.js +30 -6
- package/core/metrics/registry.js +13 -5
- package/core/orm/cache/fingerprint.js +32 -11
- package/core/orm/cache/layer.js +19 -2
- package/core/orm/hidden.js +145 -0
- package/core/orm/model.js +48 -2
- package/core/orm/query-builder.js +343 -6
- package/core/orm/query-filters.js +14 -2
- package/core/orm/repository.js +64 -13
- package/core/orm/schema-helpers.js +9 -6
- package/core/orm/transaction.js +50 -6
- package/core/orm/types.js +4 -0
- package/core/queue/adapters/database.js +184 -2
- package/core/queue/adapters/memory.js +153 -5
- package/core/queue/adapters/redis.js +59 -4
- package/core/queue/job.js +14 -1
- package/core/queue/manager.js +115 -0
- package/core/resilience/circuit-breaker.js +27 -8
- package/core/services/builtins/exchange.js +174 -7
- package/core/services/builtins/file-manager.js +36 -0
- package/core/services/builtins/mail.js +27 -0
- package/core/services/builtins/queue.js +144 -0
- package/core/services/builtins/security.js +118 -0
- package/core/services/builtins/webhook.js +110 -0
- package/core/services/executor.js +38 -16
- package/core/services/index.js +4 -1
- package/core/sse/stream.js +28 -5
- package/core/utils/timeout.js +8 -1
- package/core/validation/compiler.js +10 -7
- package/index.d.ts +1320 -74
- package/index.js +128 -8
- package/package.json +75 -27
- package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
- package/plugins/admin-panel/index.js +19 -8
- package/plugins/csrf/index.js +3 -3
- package/plugins/data-exchange/export.js +214 -0
- package/plugins/data-exchange/import.js +48 -5
- package/plugins/data-exchange/index.js +70 -28
- package/plugins/data-exchange/preview.js +266 -0
- package/plugins/data-exchange/template.js +242 -0
- package/plugins/email/admin-component.js +783 -141
- package/plugins/email/api-handlers.js +51 -1
- package/plugins/email/index.js +5 -0
- package/plugins/email/service.js +48 -0
- package/plugins/file-manager/admin-component.js +1546 -70
- package/plugins/file-manager/api-handlers.js +166 -1
- package/plugins/file-manager/core.js +598 -8
- package/plugins/file-manager/index.js +8 -0
- package/plugins/index.js +7 -0
- package/plugins/mcp/auth.js +3 -3
- package/plugins/oauth/index.js +36 -3
- package/plugins/queue/admin-component.js +1092 -0
- package/plugins/queue/api-handlers.js +248 -0
- package/plugins/queue/index.js +69 -0
- package/plugins/rest-resources/index.js +7 -2
- package/plugins/security/admin-component.js +689 -0
- package/plugins/security/api-handlers.js +169 -0
- package/plugins/security/audit.js +153 -0
- package/plugins/security/bruteforce.js +244 -0
- package/plugins/security/firewall.js +459 -0
- package/plugins/security/headers.js +214 -0
- package/plugins/security/honeypot.js +234 -0
- package/plugins/security/incident-recorder.js +137 -0
- package/plugins/security/index.js +211 -0
- package/plugins/seo-checker/analyzer.js +20 -7
- package/plugins/upload/index.js +36 -0
- package/plugins/webhooks/admin-component.js +702 -175
- package/plugins/webhooks/api-handlers.js +75 -1
- package/plugins/webhooks/dispatcher.js +290 -11
- package/plugins/webhooks/index.js +37 -2
- package/plugins/webhooks/sender.js +56 -0
- package/plugins/xlsx/engine.js +19 -4
- package/src/api/api-loader.js +19 -1
- package/src/bundle/index.js +443 -17
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +15 -20
- package/src/client-runtime/resolve.js +3 -2
- package/src/dev/api-typegen.js +1300 -0
- package/src/dev/banner.js +10 -1
- package/src/dev/colors.js +1 -1
- package/src/dev/index.js +7 -1
- package/src/dev/typegen.js +173 -17
- package/src/dev-reload/index.js +1 -1
- package/src/discovery/file-route-parser.js +17 -3
- package/src/discovery/scan-routes.js +4 -1
- package/src/file-router.js +253 -69
- package/src/helpers.js +46 -26
- package/src/njk-frontmatter.js +30 -4
- package/src/routing/cascading.js +26 -8
- package/src/routing/mount-discovered-routes.js +76 -0
- package/src/server.js +231 -70
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/.agents/ADMIN.md +0 -426
- package/.agents/AGENTS.md +0 -96
- package/.agents/ARCHITECTURE.md +0 -163
- package/.agents/AUTH.md +0 -581
- package/.agents/CACHE.md +0 -148
- package/.agents/CLI.md +0 -649
- package/.agents/COMPRESSION.md +0 -59
- package/.agents/CONTEXT.md +0 -152
- package/.agents/CONVENTIONS.md +0 -50
- package/.agents/EARLY_HINTS.md +0 -120
- package/.agents/EMAIL.md +0 -223
- package/.agents/ERRORS.md +0 -260
- package/.agents/EVENTS.md +0 -179
- package/.agents/FLAGS.md +0 -118
- package/.agents/HEALTH.md +0 -118
- package/.agents/LIMITS.md +0 -109
- package/.agents/LOGGING.md +0 -93
- package/.agents/MCP.md +0 -103
- package/.agents/METRICS.md +0 -127
- package/.agents/MIGRATION_JOBS.md +0 -95
- package/.agents/ORM.md +0 -700
- package/.agents/PLUGINS.md +0 -729
- package/.agents/QUEUE.md +0 -290
- package/.agents/RATE_LIMIT.md +0 -147
- package/.agents/REALTIME.md +0 -213
- package/.agents/RESILIENCE.md +0 -235
- package/.agents/ROUTING.md +0 -605
- package/.agents/SCALE_OUT.md +0 -109
- package/.agents/SECRETS.md +0 -117
- package/.agents/SERVICES.md +0 -341
- package/.agents/SHUTDOWN.md +0 -74
- package/.agents/STREAMING.md +0 -108
- package/.agents/WORKFLOW.md +0 -27
- package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
- package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
- package/.agents/skills/webspresso-framework/SKILL.md +0 -89
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
- package/.agents/skills/webspresso-services/SKILL.md +0 -134
- package/.cursorrules +0 -14
- package/AGENTS.md +0 -203
- package/CLAUDE.md +0 -54
- package/core/kernel/run-demo.js +0 -58
- package/plugins/admin-panel/styles.js +0 -9
- package/plugins/data-exchange/export-xlsx.js +0 -119
- 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
|
+
};
|