@kernlang/express 3.1.6 → 3.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/express-middleware.d.ts +6 -0
- package/dist/express-middleware.js +77 -0
- package/dist/express-middleware.js.map +1 -0
- package/dist/express-portable.d.ts +5 -0
- package/dist/express-portable.js +161 -0
- package/dist/express-portable.js.map +1 -0
- package/dist/express-prisma.d.ts +17 -0
- package/dist/express-prisma.js +174 -0
- package/dist/express-prisma.js.map +1 -0
- package/dist/express-route.d.ts +3 -0
- package/dist/express-route.js +269 -0
- package/dist/express-route.js.map +1 -0
- package/dist/express-stream.d.ts +5 -0
- package/dist/express-stream.js +191 -0
- package/dist/express-stream.js.map +1 -0
- package/dist/express-types.d.ts +42 -0
- package/dist/express-types.js +22 -0
- package/dist/express-types.js.map +1 -0
- package/dist/express-utils.d.ts +18 -0
- package/dist/express-utils.js +166 -0
- package/dist/express-utils.js.map +1 -0
- package/dist/transpiler-express.d.ts +2 -2
- package/dist/transpiler-express.js +270 -968
- package/dist/transpiler-express.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,954 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const hasStream = !!streamNode;
|
|
9
|
-
const hasSpawn = !!spawnNode;
|
|
10
|
-
const hasTimer = !!timerNode;
|
|
11
|
-
return {
|
|
12
|
-
hasStream,
|
|
13
|
-
hasSpawn,
|
|
14
|
-
hasTimer,
|
|
15
|
-
streamNode,
|
|
16
|
-
spawnNode,
|
|
17
|
-
timerNode,
|
|
18
|
-
needsAbortController: hasStream || hasSpawn || hasTimer,
|
|
19
|
-
needsChildProcess: hasSpawn,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
// ── SSE stream code generator ────────────────────────────────────────────
|
|
23
|
-
function generateStreamSetup(indent) {
|
|
24
|
-
return [
|
|
25
|
-
`${indent}res.writeHead(200, {`,
|
|
26
|
-
`${indent} 'Content-Type': 'text/event-stream',`,
|
|
27
|
-
`${indent} 'Cache-Control': 'no-cache',`,
|
|
28
|
-
`${indent} 'Connection': 'keep-alive',`,
|
|
29
|
-
`${indent}});`,
|
|
30
|
-
`${indent}res.flushHeaders();`,
|
|
31
|
-
`${indent}`,
|
|
32
|
-
`${indent}const emit = (data: unknown, event?: string) => {`,
|
|
33
|
-
`${indent} if (res.writableEnded) return;`,
|
|
34
|
-
`${indent} if (event) res.write(\`event: \${event}\\n\`);`,
|
|
35
|
-
`${indent} res.write(\`data: \${JSON.stringify(data)}\\n\\n\`);`,
|
|
36
|
-
`${indent}};`,
|
|
37
|
-
`${indent}`,
|
|
38
|
-
`${indent}// SSE heartbeat — keeps proxies/browsers from killing the connection`,
|
|
39
|
-
`${indent}const heartbeat = setInterval(() => {`,
|
|
40
|
-
`${indent} if (res.writableEnded) { clearInterval(heartbeat); return; }`,
|
|
41
|
-
`${indent} res.write(': keep-alive\\n\\n');`,
|
|
42
|
-
`${indent}}, 15000);`,
|
|
43
|
-
];
|
|
44
|
-
}
|
|
45
|
-
function generateStreamWrap(handlerLines, hasSpawn, indent) {
|
|
46
|
-
const lines = [];
|
|
47
|
-
// Await the async IIFE so Express doesn't return before stream completes
|
|
48
|
-
lines.push(`${indent}await (async () => {`);
|
|
49
|
-
lines.push(`${indent} try {`);
|
|
50
|
-
if (hasSpawn) {
|
|
51
|
-
// Wrap spawn in a Promise so we await child completion before closing stream
|
|
52
|
-
lines.push(`${indent} await new Promise<void>((resolveStream, rejectStream) => {`);
|
|
53
|
-
lines.push(...handlerLines.map(l => `${indent} ${l}`));
|
|
54
|
-
// The spawn's on('close') handler should call resolveStream()
|
|
55
|
-
lines.push(`${indent} });`);
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
lines.push(...handlerLines.map(l => `${indent} ${l}`));
|
|
59
|
-
}
|
|
60
|
-
lines.push(`${indent} } catch (err) {`);
|
|
61
|
-
lines.push(`${indent} emit({ type: 'error', error: err instanceof Error ? err.message : String(err) });`);
|
|
62
|
-
lines.push(`${indent} } finally {`);
|
|
63
|
-
lines.push(`${indent} clearInterval(heartbeat);`);
|
|
64
|
-
lines.push(`${indent} if (!res.writableEnded) {`);
|
|
65
|
-
lines.push(`${indent} res.write(\`data: \${JSON.stringify('[DONE]')}\\n\\n\`);`);
|
|
66
|
-
lines.push(`${indent} res.end();`);
|
|
67
|
-
lines.push(`${indent} }`);
|
|
68
|
-
lines.push(`${indent} }`);
|
|
69
|
-
lines.push(`${indent}})();`);
|
|
70
|
-
return lines;
|
|
71
|
-
}
|
|
72
|
-
// ── Spawn code generator ─────────────────────────────────────────────────
|
|
73
|
-
function generateSpawnCode(spawnNode, indent) {
|
|
74
|
-
const p = getProps(spawnNode);
|
|
75
|
-
const binary = String(p.binary || 'echo');
|
|
76
|
-
const args = p.args;
|
|
77
|
-
const timeoutSec = Number(p.timeout) || 0;
|
|
78
|
-
const lines = [];
|
|
79
|
-
// Validate: binary must be static (security: no dynamic binary)
|
|
80
|
-
if (binary.includes('{{') || binary.includes('req.')) {
|
|
81
|
-
lines.push(`${indent}// ERROR: Dynamic binary is not allowed for security. Use a static binary name.`);
|
|
82
|
-
lines.push(`${indent}res.status(500).json({ error: 'Dynamic binary not allowed' });`);
|
|
83
|
-
return lines;
|
|
84
|
-
}
|
|
85
|
-
const argsExpr = args || '[]';
|
|
86
|
-
lines.push(`${indent}const child = spawn('${escapeSingleQuotes(binary)}', ${argsExpr}, {`);
|
|
87
|
-
lines.push(`${indent} stdio: ['pipe', 'pipe', 'pipe'],`);
|
|
88
|
-
lines.push(`${indent} shell: false,`);
|
|
89
|
-
// Env vars
|
|
90
|
-
const envNodes = getChildren(spawnNode, 'env');
|
|
91
|
-
if (envNodes.length > 0) {
|
|
92
|
-
const envPairs = envNodes.map(e => {
|
|
93
|
-
const ep = getProps(e);
|
|
94
|
-
const entries = Object.entries(ep).filter(([k]) => k !== 'styles' && k !== 'pseudoStyles' && k !== 'themeRefs');
|
|
95
|
-
return entries.map(([k, v]) => `${k}: '${String(v)}'`).join(', ');
|
|
96
|
-
}).join(', ');
|
|
97
|
-
lines.push(`${indent} env: { ...process.env, ${envPairs} },`);
|
|
98
|
-
}
|
|
99
|
-
lines.push(`${indent}});`);
|
|
100
|
-
// stdin handling — only end if no stdin prop
|
|
101
|
-
if (!p.stdin) {
|
|
102
|
-
lines.push(`${indent}child.stdin.end();`);
|
|
103
|
-
}
|
|
104
|
-
lines.push(`${indent}let errorText = '';`);
|
|
105
|
-
// Timeout with SIGTERM → SIGKILL escalation
|
|
106
|
-
lines.push(`${indent}let childExited = false;`);
|
|
107
|
-
lines.push(`${indent}child.on('exit', () => { childExited = true; });`);
|
|
108
|
-
if (timeoutSec > 0) {
|
|
109
|
-
lines.push(`${indent}const spawnTimer = setTimeout(() => {`);
|
|
110
|
-
lines.push(`${indent} child.kill('SIGTERM');`);
|
|
111
|
-
lines.push(`${indent} setTimeout(() => { if (!childExited) child.kill('SIGKILL'); }, 3000);`);
|
|
112
|
-
lines.push(`${indent}}, ${timeoutSec * 1000});`);
|
|
113
|
-
}
|
|
114
|
-
// Abort on request close — SIGTERM then force SIGKILL + resolve after 5s
|
|
115
|
-
lines.push(`${indent}ac.signal.addEventListener('abort', () => {`);
|
|
116
|
-
lines.push(`${indent} if (!childExited) {`);
|
|
117
|
-
lines.push(`${indent} child.kill('SIGTERM');`);
|
|
118
|
-
lines.push(`${indent} setTimeout(() => {`);
|
|
119
|
-
lines.push(`${indent} if (!childExited) child.kill('SIGKILL');`);
|
|
120
|
-
lines.push(`${indent} if (typeof resolveStream === 'function') resolveStream();`);
|
|
121
|
-
lines.push(`${indent} }, 5000);`);
|
|
122
|
-
lines.push(`${indent} }`);
|
|
123
|
-
lines.push(`${indent}});`);
|
|
124
|
-
// Event handlers from child nodes
|
|
125
|
-
const onNodes = getChildren(spawnNode, 'on');
|
|
126
|
-
let hasCloseHandler = false;
|
|
127
|
-
for (const onNode of onNodes) {
|
|
128
|
-
const onProps = getProps(onNode);
|
|
129
|
-
const event = String(onProps.name || onProps.event || '');
|
|
130
|
-
const handlerChild = getFirstChild(onNode, 'handler');
|
|
131
|
-
const code = handlerChild ? String(getProps(handlerChild).code || '') : '';
|
|
132
|
-
if (event === 'stdout') {
|
|
133
|
-
lines.push(`${indent}child.stdout.on('data', (chunk: Buffer) => {`);
|
|
134
|
-
lines.push(...code.split('\n').map(l => `${indent} ${l.trim()}`));
|
|
135
|
-
lines.push(`${indent}});`);
|
|
136
|
-
}
|
|
137
|
-
else if (event === 'stderr') {
|
|
138
|
-
lines.push(`${indent}child.stderr.on('data', (chunk: Buffer) => {`);
|
|
139
|
-
lines.push(...code.split('\n').map(l => `${indent} ${l.trim()}`));
|
|
140
|
-
lines.push(`${indent}});`);
|
|
141
|
-
}
|
|
142
|
-
else if (event === 'close') {
|
|
143
|
-
hasCloseHandler = true;
|
|
144
|
-
lines.push(`${indent}child.on('close', (code: number | null) => {`);
|
|
145
|
-
if (timeoutSec > 0)
|
|
146
|
-
lines.push(`${indent} clearTimeout(spawnTimer);`);
|
|
147
|
-
lines.push(...code.split('\n').map(l => `${indent} ${l.trim()}`));
|
|
148
|
-
// Resolve the stream promise so finally block runs AFTER child exits
|
|
149
|
-
lines.push(`${indent} if (typeof resolveStream === 'function') resolveStream();`);
|
|
150
|
-
lines.push(`${indent}});`);
|
|
151
|
-
}
|
|
152
|
-
else if (event === 'timeout') {
|
|
153
|
-
// Handled via the timer killed branch
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
// Default close handler if none specified — ensures stream promise resolves
|
|
157
|
-
if (!hasCloseHandler) {
|
|
158
|
-
lines.push(`${indent}child.on('close', (code: number | null) => {`);
|
|
159
|
-
if (timeoutSec > 0)
|
|
160
|
-
lines.push(`${indent} clearTimeout(spawnTimer);`);
|
|
161
|
-
lines.push(`${indent} if (typeof resolveStream === 'function') resolveStream();`);
|
|
162
|
-
lines.push(`${indent}});`);
|
|
163
|
-
}
|
|
164
|
-
// Catch spawn errors (binary not found)
|
|
165
|
-
lines.push(`${indent}child.on('error', (err: Error) => {`);
|
|
166
|
-
lines.push(`${indent} emit({ type: 'error', error: err.message });`);
|
|
167
|
-
lines.push(`${indent} if (typeof resolveStream === 'function') resolveStream();`);
|
|
168
|
-
lines.push(`${indent}});`);
|
|
169
|
-
return lines;
|
|
170
|
-
}
|
|
171
|
-
// ── Timer code generator ─────────────────────────────────────────────────
|
|
172
|
-
function generateTimerCode(timerNode, handlerCode, indent) {
|
|
173
|
-
const p = getProps(timerNode);
|
|
174
|
-
const timeoutSec = Number(Object.values(p).find(v => typeof v === 'string' && !isNaN(Number(v))) || p.timeout || 15);
|
|
175
|
-
const handlerChild = getFirstChild(timerNode, 'handler');
|
|
176
|
-
const timerHandlerCode = handlerChild ? String(getProps(handlerChild).code || '') : '';
|
|
177
|
-
const onTimeoutNode = (timerNode.children || []).find(c => c.type === 'on' && (getProps(c).name === 'timeout' || getProps(c).event === 'timeout'));
|
|
178
|
-
const timeoutHandler = onTimeoutNode ? getFirstChild(onTimeoutNode, 'handler') : undefined;
|
|
179
|
-
const timeoutCode = timeoutHandler ? String(getProps(timeoutHandler).code || '') : `res.status(408).json({ error: 'Request timed out' });`;
|
|
180
|
-
const lines = [];
|
|
181
|
-
lines.push(`${indent}const timeoutMs = ${timeoutSec * 1000};`);
|
|
182
|
-
lines.push(`${indent}const timer = setTimeout(() => {`);
|
|
183
|
-
lines.push(`${indent} ac.abort();`);
|
|
184
|
-
lines.push(...timeoutCode.split('\n').map(l => `${indent} ${l.trim()}`));
|
|
185
|
-
lines.push(`${indent}}, timeoutMs);`);
|
|
186
|
-
lines.push(`${indent}`);
|
|
187
|
-
lines.push(`${indent}try {`);
|
|
188
|
-
// Timer handler code (the work to do)
|
|
189
|
-
if (timerHandlerCode) {
|
|
190
|
-
lines.push(...timerHandlerCode.split('\n').map(l => `${indent} ${l.trim()}`));
|
|
191
|
-
}
|
|
192
|
-
// Original route handler code
|
|
193
|
-
if (handlerCode) {
|
|
194
|
-
lines.push(...handlerCode.split('\n').map(l => `${indent} ${l.trim()}`));
|
|
195
|
-
}
|
|
196
|
-
lines.push(`${indent}} catch (err) {`);
|
|
197
|
-
lines.push(`${indent} if (!ac.signal.aborted) {`);
|
|
198
|
-
lines.push(`${indent} clearTimeout(timer);`);
|
|
199
|
-
lines.push(`${indent} throw err;`);
|
|
200
|
-
lines.push(`${indent} }`);
|
|
201
|
-
lines.push(`${indent}} finally {`);
|
|
202
|
-
lines.push(`${indent} clearTimeout(timer);`);
|
|
203
|
-
lines.push(`${indent}}`);
|
|
204
|
-
return lines;
|
|
205
|
-
}
|
|
206
|
-
// ── Portable respond node → Express ──────────────────────────────────────
|
|
207
|
-
function generateRespondExpress(respondNode, indent) {
|
|
208
|
-
const p = getProps(respondNode);
|
|
209
|
-
const status = typeof p.status === 'number' ? p.status : undefined;
|
|
210
|
-
const json = p.json;
|
|
211
|
-
const error = p.error;
|
|
212
|
-
const text = p.text;
|
|
213
|
-
const redirect = p.redirect;
|
|
214
|
-
if (redirect) {
|
|
215
|
-
return [`${indent}res.redirect('${escapeSingleQuotes(String(redirect))}');`];
|
|
216
|
-
}
|
|
217
|
-
if (error) {
|
|
218
|
-
return [`${indent}res.status(${status || 500}).json({ error: '${escapeSingleQuotes(String(error))}' });`];
|
|
219
|
-
}
|
|
220
|
-
if (json) {
|
|
221
|
-
if (!status || status === 200) {
|
|
222
|
-
return [`${indent}res.json(${json});`];
|
|
223
|
-
}
|
|
224
|
-
return [`${indent}res.status(${status}).json(${json});`];
|
|
225
|
-
}
|
|
226
|
-
if (text) {
|
|
227
|
-
if (!status || status === 200) {
|
|
228
|
-
return [`${indent}res.send(${text});`];
|
|
229
|
-
}
|
|
230
|
-
return [`${indent}res.status(${status}).send(${text});`];
|
|
231
|
-
}
|
|
232
|
-
if (status === 204) {
|
|
233
|
-
return [`${indent}res.status(204).send();`];
|
|
234
|
-
}
|
|
235
|
-
if (status) {
|
|
236
|
-
return [`${indent}res.status(${status}).send();`];
|
|
237
|
-
}
|
|
238
|
-
return [`${indent}res.status(200).send();`];
|
|
239
|
-
}
|
|
240
|
-
function pascalCase(value) {
|
|
241
|
-
const camel = camelKey(value);
|
|
242
|
-
return camel ? camel.charAt(0).toUpperCase() + camel.slice(1) : 'Generated';
|
|
243
|
-
}
|
|
244
|
-
function slugify(value) {
|
|
245
|
-
return value
|
|
246
|
-
.toLowerCase()
|
|
247
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
248
|
-
.replace(/^-+|-+$/g, '') || 'generated';
|
|
249
|
-
}
|
|
250
|
-
function escapeSingleQuotes(value) {
|
|
251
|
-
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
252
|
-
}
|
|
253
|
-
function indentBlock(code, indent) {
|
|
254
|
-
return code.split('\n').map(line => `${indent}${line}`);
|
|
255
|
-
}
|
|
256
|
-
function splitTopLevel(value) {
|
|
257
|
-
const parts = [];
|
|
258
|
-
let current = '';
|
|
259
|
-
let braceDepth = 0;
|
|
260
|
-
let bracketDepth = 0;
|
|
261
|
-
let parenDepth = 0;
|
|
262
|
-
let inQuote = false;
|
|
263
|
-
for (let i = 0; i < value.length; i++) {
|
|
264
|
-
const ch = value[i];
|
|
265
|
-
if (ch === '"' && value[i - 1] !== '\\') {
|
|
266
|
-
inQuote = !inQuote;
|
|
267
|
-
current += ch;
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
if (!inQuote) {
|
|
271
|
-
if (ch === '{')
|
|
272
|
-
braceDepth++;
|
|
273
|
-
if (ch === '}')
|
|
274
|
-
braceDepth--;
|
|
275
|
-
if (ch === '[')
|
|
276
|
-
bracketDepth++;
|
|
277
|
-
if (ch === ']')
|
|
278
|
-
bracketDepth--;
|
|
279
|
-
if (ch === '(')
|
|
280
|
-
parenDepth++;
|
|
281
|
-
if (ch === ')')
|
|
282
|
-
parenDepth--;
|
|
283
|
-
if (ch === ',' && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
|
|
284
|
-
if (current.trim())
|
|
285
|
-
parts.push(current.trim());
|
|
286
|
-
current = '';
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
current += ch;
|
|
291
|
-
}
|
|
292
|
-
if (current.trim())
|
|
293
|
-
parts.push(current.trim());
|
|
294
|
-
return parts;
|
|
295
|
-
}
|
|
296
|
-
function extractRequiredKeys(schemaType) {
|
|
297
|
-
const trimmed = schemaType.trim();
|
|
298
|
-
if (!trimmed.startsWith('{') || !trimmed.endsWith('}'))
|
|
299
|
-
return [];
|
|
300
|
-
const keys = [];
|
|
301
|
-
const inner = trimmed.slice(1, -1);
|
|
302
|
-
for (const part of splitTopLevel(inner)) {
|
|
303
|
-
const colonIdx = part.indexOf(':');
|
|
304
|
-
if (colonIdx === -1)
|
|
305
|
-
continue;
|
|
306
|
-
const rawKey = part.slice(0, colonIdx).trim();
|
|
307
|
-
if (!rawKey || rawKey.endsWith('?'))
|
|
308
|
-
continue;
|
|
309
|
-
keys.push(rawKey.replace(/^['"]|['"]$/g, ''));
|
|
310
|
-
}
|
|
311
|
-
return keys;
|
|
312
|
-
}
|
|
313
|
-
function derivePathParams(path) {
|
|
314
|
-
const matches = path.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g);
|
|
315
|
-
return [...matches].map(match => match[1]);
|
|
316
|
-
}
|
|
317
|
-
function buildPathParamsType(path) {
|
|
318
|
-
const params = derivePathParams(path);
|
|
319
|
-
if (params.length === 0)
|
|
320
|
-
return undefined;
|
|
321
|
-
return `{ ${params.map(param => `${param}: string`).join('; ')} }`;
|
|
322
|
-
}
|
|
323
|
-
function findServerNode(root) {
|
|
324
|
-
if (root.type === 'server')
|
|
325
|
-
return root;
|
|
326
|
-
for (const child of root.children || []) {
|
|
327
|
-
const found = findServerNode(child);
|
|
328
|
-
if (found)
|
|
329
|
-
return found;
|
|
330
|
-
}
|
|
331
|
-
return undefined;
|
|
332
|
-
}
|
|
333
|
-
function routeFileBase(method, path, index) {
|
|
334
|
-
const base = slugify(`${method}-${path.replace(/[:/]/g, '-')}`);
|
|
335
|
-
return base === 'generated' ? `route-${index}` : base;
|
|
336
|
-
}
|
|
337
|
-
function routeRegisterName(method, path) {
|
|
338
|
-
return `register${pascalCase(`${method} ${path}`)}Route`;
|
|
339
|
-
}
|
|
340
|
-
function middlewareExportName(node) {
|
|
341
|
-
const props = getProps(node);
|
|
342
|
-
const handlerName = typeof props.handler === 'string' ? props.handler : undefined;
|
|
343
|
-
if (handlerName)
|
|
344
|
-
return handlerName;
|
|
345
|
-
const name = typeof props.name === 'string' ? props.name : 'middleware';
|
|
346
|
-
return camelKey(name) || 'middlewareHandler';
|
|
347
|
-
}
|
|
348
|
-
function buildSchema(node) {
|
|
349
|
-
if (!node)
|
|
350
|
-
return {};
|
|
351
|
-
const props = getProps(node);
|
|
352
|
-
const schema = {};
|
|
353
|
-
if (typeof props.body === 'string')
|
|
354
|
-
schema.body = props.body;
|
|
355
|
-
if (typeof props.params === 'string')
|
|
356
|
-
schema.params = props.params;
|
|
357
|
-
if (typeof props.query === 'string')
|
|
358
|
-
schema.query = props.query;
|
|
359
|
-
if (typeof props.response === 'string')
|
|
360
|
-
schema.response = props.response;
|
|
361
|
-
return schema;
|
|
362
|
-
}
|
|
363
|
-
function buildMiddlewareArtifact(node, exportName) {
|
|
364
|
-
const handlerNode = getFirstChild(node, 'handler');
|
|
365
|
-
const handlerProps = handlerNode ? getProps(handlerNode) : {};
|
|
366
|
-
const handlerCode = typeof handlerProps.code === 'string'
|
|
367
|
-
? String(handlerProps.code)
|
|
368
|
-
: '';
|
|
369
|
-
const lines = [];
|
|
370
|
-
lines.push(`import type { NextFunction, Request, Response } from 'express';`);
|
|
371
|
-
lines.push('');
|
|
372
|
-
lines.push(`export function ${exportName}(req: Request, res: Response, next: NextFunction): void {`);
|
|
373
|
-
if (handlerCode) {
|
|
374
|
-
lines.push(...indentBlock(handlerCode, ' '));
|
|
375
|
-
}
|
|
376
|
-
else {
|
|
377
|
-
lines.push(' next();');
|
|
378
|
-
}
|
|
379
|
-
lines.push('}');
|
|
380
|
-
const name = String(getProps(node).name || exportName);
|
|
381
|
-
return {
|
|
382
|
-
path: `middleware/${slugify(name)}.ts`,
|
|
383
|
-
content: lines.join('\n'),
|
|
384
|
-
type: 'middleware',
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
function ensureCustomMiddlewareArtifact(node, middlewareArtifacts) {
|
|
388
|
-
const name = String(getProps(node).name || 'middleware');
|
|
389
|
-
const fileBase = slugify(name);
|
|
390
|
-
const existing = middlewareArtifacts.get(fileBase);
|
|
391
|
-
if (existing)
|
|
392
|
-
return existing;
|
|
393
|
-
const exportName = middlewareExportName(node);
|
|
394
|
-
const artifact = buildMiddlewareArtifact(node, exportName);
|
|
395
|
-
const created = { artifact, exportName, fileBase };
|
|
396
|
-
middlewareArtifacts.set(fileBase, created);
|
|
397
|
-
return created;
|
|
398
|
-
}
|
|
399
|
-
function resolveMiddlewareUsage(node, middlewareArtifacts, importPrefix, securityLevel) {
|
|
400
|
-
const props = getProps(node);
|
|
401
|
-
const name = String(props.name || 'middleware');
|
|
402
|
-
if (name === 'cors') {
|
|
403
|
-
return { importLine: `import cors from 'cors';`, invocation: 'cors()' };
|
|
404
|
-
}
|
|
405
|
-
if (name === 'json') {
|
|
406
|
-
const invocation = securityLevel === 'relaxed' ? 'express.json()' : `express.json({ limit: '1mb' })`;
|
|
407
|
-
return { invocation };
|
|
408
|
-
}
|
|
409
|
-
const artifact = ensureCustomMiddlewareArtifact(node, middlewareArtifacts);
|
|
410
|
-
return {
|
|
411
|
-
importLine: `import { ${artifact.exportName} } from '${importPrefix}middleware/${artifact.fileBase}.js';`,
|
|
412
|
-
invocation: artifact.exportName,
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
// ── Portable request reference rewriting ──────────────────────────────────
|
|
416
|
-
function rewriteExpressExpr(expr, path) {
|
|
417
|
-
const pathParams = derivePathParams(path);
|
|
418
|
-
let result = expr;
|
|
419
|
-
// params.X → req.params.X
|
|
420
|
-
result = result.replace(/\bparams\.([A-Za-z_]\w*)/g, 'req.params.$1');
|
|
421
|
-
// body.X → req.body.X
|
|
422
|
-
result = result.replace(/\bbody\.([A-Za-z_]\w*)/g, 'req.body.$1');
|
|
423
|
-
// query.X → req.query.X
|
|
424
|
-
result = result.replace(/\bquery\.([A-Za-z_]\w*)/g, 'req.query.$1');
|
|
425
|
-
// headers.X → req.headers['X']
|
|
426
|
-
result = result.replace(/\bheaders\.([A-Za-z_][\w-]*)/g, (_m, key) => `req.headers['${key}']`);
|
|
427
|
-
// effectName.result → effectName (effect variables hold the result directly)
|
|
428
|
-
result = result.replace(/\b([A-Za-z_]\w*)\.result\b/g, '$1');
|
|
429
|
-
return result;
|
|
430
|
-
}
|
|
431
|
-
// ── Portable handler generation (derive → guard → handler → respond) ─────
|
|
432
|
-
function extractExprCode(prop) {
|
|
433
|
-
if (typeof prop === 'object' && prop !== null && prop.__expr)
|
|
434
|
-
return prop.code;
|
|
435
|
-
return typeof prop === 'string' ? prop : '';
|
|
436
|
-
}
|
|
437
|
-
function generatePortableChildExpress(child, indent, path) {
|
|
438
|
-
const lines = [];
|
|
439
|
-
const p = getProps(child);
|
|
440
|
-
switch (child.type) {
|
|
441
|
-
case 'derive': {
|
|
442
|
-
const name = String(p.name || '');
|
|
443
|
-
const exprCode = extractExprCode(p.expr);
|
|
444
|
-
if (name && exprCode) {
|
|
445
|
-
lines.push(`${indent}const ${name} = ${rewriteExpressExpr(exprCode, path)};`);
|
|
446
|
-
}
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
case 'guard': {
|
|
450
|
-
const name = String(p.name || '');
|
|
451
|
-
const exprCode = extractExprCode(p.expr);
|
|
452
|
-
const elseStatus = p.else ? parseInt(String(p.else), 10) : 404;
|
|
453
|
-
const elseMessage = typeof p.message === 'string' ? p.message : (name ? `${name} guard failed` : 'Guard failed');
|
|
454
|
-
if (exprCode) {
|
|
455
|
-
lines.push(`${indent}if (!(${rewriteExpressExpr(exprCode, path)})) {`);
|
|
456
|
-
lines.push(`${indent} return res.status(${elseStatus}).json({ error: '${escapeSingleQuotes(elseMessage)}' });`);
|
|
457
|
-
lines.push(`${indent}}`);
|
|
458
|
-
}
|
|
459
|
-
break;
|
|
460
|
-
}
|
|
461
|
-
case 'handler': {
|
|
462
|
-
const code = String(p.code || '');
|
|
463
|
-
if (code)
|
|
464
|
-
lines.push(...indentBlock(code, indent));
|
|
465
|
-
break;
|
|
466
|
-
}
|
|
467
|
-
case 'respond': {
|
|
468
|
-
// Clone props to avoid mutating shared AST, then rewrite portable refs
|
|
469
|
-
const clonedRespond = { ...child, props: { ...child.props } };
|
|
470
|
-
if (clonedRespond.props.json)
|
|
471
|
-
clonedRespond.props.json = rewriteExpressExpr(String(clonedRespond.props.json), path);
|
|
472
|
-
if (clonedRespond.props.text)
|
|
473
|
-
clonedRespond.props.text = rewriteExpressExpr(String(clonedRespond.props.text), path);
|
|
474
|
-
lines.push(...generateRespondExpress(clonedRespond, indent));
|
|
475
|
-
break;
|
|
476
|
-
}
|
|
477
|
-
case 'branch': {
|
|
478
|
-
const on = rewriteExpressExpr(String(p.on || ''), path);
|
|
479
|
-
const paths = getChildren(child, 'path');
|
|
480
|
-
for (let i = 0; i < paths.length; i++) {
|
|
481
|
-
const pathNode = paths[i];
|
|
482
|
-
const pp = getProps(pathNode);
|
|
483
|
-
const value = String(pp.value || '');
|
|
484
|
-
const keyword = i === 0 ? 'if' : 'else if';
|
|
485
|
-
lines.push(`${indent}${keyword} (${on} === '${escapeSingleQuotes(value)}') {`);
|
|
486
|
-
// Recurse into path children
|
|
487
|
-
for (const pathChild of pathNode.children || []) {
|
|
488
|
-
lines.push(...generatePortableChildExpress(pathChild, indent + ' ', path));
|
|
489
|
-
}
|
|
490
|
-
lines.push(`${indent}}`);
|
|
491
|
-
}
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
case 'each': {
|
|
495
|
-
const name = String(p.name || 'item');
|
|
496
|
-
const collection = rewriteExpressExpr(extractExprCode(p.in) || String(p.in || ''), path);
|
|
497
|
-
const index = p.index ? String(p.index) : undefined;
|
|
498
|
-
if (index) {
|
|
499
|
-
lines.push(`${indent}for (const [${index}, ${name}] of (${collection}).entries()) {`);
|
|
500
|
-
}
|
|
501
|
-
else {
|
|
502
|
-
lines.push(`${indent}for (const ${name} of ${collection}) {`);
|
|
503
|
-
}
|
|
504
|
-
for (const eachChild of child.children || []) {
|
|
505
|
-
lines.push(...generatePortableChildExpress(eachChild, indent + ' ', path));
|
|
506
|
-
}
|
|
507
|
-
lines.push(`${indent}}`);
|
|
508
|
-
break;
|
|
509
|
-
}
|
|
510
|
-
case 'collect': {
|
|
511
|
-
const name = String(p.name || '');
|
|
512
|
-
const from = rewriteExpressExpr(String(p.from || ''), path);
|
|
513
|
-
const where = p.where ? extractExprCode(p.where) : undefined;
|
|
514
|
-
const limit = p.limit ? String(p.limit) : undefined;
|
|
515
|
-
const order = p.order ? rewriteExpressExpr(extractExprCode(p.order) || String(p.order), path) : undefined;
|
|
516
|
-
let chain = from;
|
|
517
|
-
if (where)
|
|
518
|
-
chain += `.filter(item => ${rewriteExpressExpr(where, path)})`;
|
|
519
|
-
if (order)
|
|
520
|
-
chain += `.sort((a, b) => ${order})`;
|
|
521
|
-
if (limit)
|
|
522
|
-
chain += `.slice(0, ${limit})`;
|
|
523
|
-
if (name)
|
|
524
|
-
lines.push(`${indent}const ${name} = ${chain};`);
|
|
525
|
-
break;
|
|
526
|
-
}
|
|
527
|
-
case 'effect': {
|
|
528
|
-
const effectName = String(p.name || 'effect');
|
|
529
|
-
const triggerNode = getFirstChild(child, 'trigger');
|
|
530
|
-
const recoverNode = getFirstChild(child, 'recover');
|
|
531
|
-
const triggerProps = triggerNode ? getProps(triggerNode) : {};
|
|
532
|
-
const triggerExpr = extractExprCode(triggerProps.expr) || String(triggerProps.query || triggerProps.url || triggerProps.call || '');
|
|
533
|
-
const retryCount = recoverNode ? parseInt(String(getProps(recoverNode).retry || '0'), 10) : 0;
|
|
534
|
-
const fallback = recoverNode ? String(getProps(recoverNode).fallback || 'null') : 'null';
|
|
535
|
-
if (retryCount > 0) {
|
|
536
|
-
lines.push(`${indent}let ${effectName} = ${fallback};`);
|
|
537
|
-
lines.push(`${indent}for (let _attempt = 0; _attempt < ${retryCount}; _attempt++) {`);
|
|
538
|
-
lines.push(`${indent} try {`);
|
|
539
|
-
lines.push(`${indent} ${effectName} = ${rewriteExpressExpr(triggerExpr, path)};`);
|
|
540
|
-
lines.push(`${indent} break;`);
|
|
541
|
-
lines.push(`${indent} } catch (_err) {`);
|
|
542
|
-
lines.push(`${indent} if (_attempt === ${retryCount - 1}) ${effectName} = ${fallback};`);
|
|
543
|
-
lines.push(`${indent} }`);
|
|
544
|
-
lines.push(`${indent}}`);
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
lines.push(`${indent}let ${effectName} = ${fallback};`);
|
|
548
|
-
lines.push(`${indent}try {`);
|
|
549
|
-
lines.push(`${indent} ${effectName} = ${rewriteExpressExpr(triggerExpr, path)};`);
|
|
550
|
-
lines.push(`${indent}} catch (_err) {`);
|
|
551
|
-
lines.push(`${indent} ${effectName} = ${fallback};`);
|
|
552
|
-
lines.push(`${indent}}`);
|
|
553
|
-
}
|
|
554
|
-
break;
|
|
555
|
-
}
|
|
556
|
-
default:
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
|
-
return lines;
|
|
560
|
-
}
|
|
561
|
-
function generatePortableHandlerExpress(routeNode, indent, path) {
|
|
562
|
-
const lines = [];
|
|
563
|
-
const children = routeNode.children || [];
|
|
564
|
-
// Walk all route children in document order — portable nodes are emitted inline
|
|
565
|
-
const PORTABLE_TYPES = new Set(['derive', 'guard', 'handler', 'respond', 'branch', 'each', 'collect', 'effect']);
|
|
566
|
-
for (const child of children) {
|
|
567
|
-
if (PORTABLE_TYPES.has(child.type)) {
|
|
568
|
-
lines.push(...generatePortableChildExpress(child, indent, path));
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
return lines;
|
|
572
|
-
}
|
|
573
|
-
function buildRouteArtifact(routeNode, routeIndex, middlewareArtifacts, sourceMap) {
|
|
574
|
-
const props = getProps(routeNode);
|
|
575
|
-
const method = String(props.method || 'get').toLowerCase();
|
|
576
|
-
const normalizedMethod = HTTP_METHODS.has(method) ? method : 'get';
|
|
577
|
-
const path = String(props.path || '/');
|
|
578
|
-
const fileBase = routeFileBase(normalizedMethod, path, routeIndex);
|
|
579
|
-
const registerName = routeRegisterName(normalizedMethod, path);
|
|
580
|
-
const schema = buildSchema(getFirstChild(routeNode, 'schema'));
|
|
581
|
-
const caps = analyzeRouteCapabilities(routeNode);
|
|
582
|
-
// Portable route children: derive, guard, respond, branch, each, collect
|
|
583
|
-
const deriveNodes = getChildren(routeNode, 'derive');
|
|
584
|
-
const guardNodes = getChildren(routeNode, 'guard');
|
|
585
|
-
const respondNode = getFirstChild(routeNode, 'respond');
|
|
586
|
-
const branchNodes = getChildren(routeNode, 'branch');
|
|
587
|
-
const eachNodes = getChildren(routeNode, 'each');
|
|
588
|
-
const collectNodes = getChildren(routeNode, 'collect');
|
|
589
|
-
const effectNodes = getChildren(routeNode, 'effect');
|
|
590
|
-
const hasPortableNodes = deriveNodes.length > 0 || guardNodes.length > 0 || !!respondNode
|
|
591
|
-
|| branchNodes.length > 0 || eachNodes.length > 0 || collectNodes.length > 0
|
|
592
|
-
|| effectNodes.length > 0;
|
|
593
|
-
// Get handler code — priority: stream handler > timer handler > route handler > portable > 501
|
|
594
|
-
const handlerNode = caps.hasStream
|
|
595
|
-
? getFirstChild(caps.streamNode, 'handler')
|
|
596
|
-
: caps.hasTimer
|
|
597
|
-
? null // timer owns its own handler, don't look at route level
|
|
598
|
-
: getFirstChild(routeNode, 'handler');
|
|
599
|
-
const routeHandlerNode = getFirstChild(routeNode, 'handler');
|
|
600
|
-
const handlerProps = handlerNode ? getProps(handlerNode) : {};
|
|
601
|
-
const routeHandlerCode = routeHandlerNode ? String(getProps(routeHandlerNode).code || '') : '';
|
|
602
|
-
const handlerCode = typeof handlerProps.code === 'string'
|
|
603
|
-
? String(handlerProps.code)
|
|
604
|
-
: caps.hasStream || caps.hasTimer || hasPortableNodes ? '' : `res.status(501).json({ error: 'Route handler not implemented' });`;
|
|
605
|
-
const routeMiddleware = getChildren(routeNode, 'middleware');
|
|
606
|
-
const routeImports = new Set();
|
|
607
|
-
const middlewareInvocations = [];
|
|
608
|
-
let needsExpressDefaultImport = false;
|
|
609
|
-
for (const middlewareNode of routeMiddleware) {
|
|
610
|
-
// Handle v3 bare-word middleware list: middleware names=["rateLimit","cors"]
|
|
611
|
-
const mwProps = getProps(middlewareNode);
|
|
612
|
-
const mwNames = mwProps.names;
|
|
613
|
-
if (mwNames && Array.isArray(mwNames)) {
|
|
614
|
-
for (const mwName of mwNames) {
|
|
615
|
-
const syntheticNode = { type: 'middleware', props: { name: mwName }, children: [] };
|
|
616
|
-
const mwUsage = resolveMiddlewareUsage(syntheticNode, middlewareArtifacts, '../');
|
|
617
|
-
if (mwUsage.importLine)
|
|
618
|
-
routeImports.add(mwUsage.importLine);
|
|
619
|
-
if (mwUsage.invocation === 'express.json()')
|
|
620
|
-
needsExpressDefaultImport = true;
|
|
621
|
-
middlewareInvocations.push(mwUsage.invocation);
|
|
622
|
-
}
|
|
623
|
-
continue;
|
|
624
|
-
}
|
|
625
|
-
const usage = resolveMiddlewareUsage(middlewareNode, middlewareArtifacts, '../');
|
|
626
|
-
if (usage.importLine)
|
|
627
|
-
routeImports.add(usage.importLine);
|
|
628
|
-
if (usage.invocation === 'express.json()')
|
|
629
|
-
needsExpressDefaultImport = true;
|
|
630
|
-
middlewareInvocations.push(usage.invocation);
|
|
631
|
-
}
|
|
632
|
-
// v3 route children: auth, validate
|
|
633
|
-
const authNode = getFirstChild(routeNode, 'auth');
|
|
634
|
-
if (authNode) {
|
|
635
|
-
const authMode = String(getProps(authNode).mode || 'required');
|
|
636
|
-
middlewareInvocations.unshift(authMode === 'optional' ? 'authOptional' : 'authRequired');
|
|
637
|
-
}
|
|
638
|
-
const validateNode = getFirstChild(routeNode, 'validate');
|
|
639
|
-
if (validateNode) {
|
|
640
|
-
const validateSchema = String(getProps(validateNode).schema || '');
|
|
641
|
-
if (validateSchema) {
|
|
642
|
-
middlewareInvocations.push(`validate(${validateSchema})`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
// v3 route children: params (query params with types and defaults)
|
|
646
|
-
const paramsNodes = getChildren(routeNode, 'params');
|
|
647
|
-
const queryParams = [];
|
|
648
|
-
for (const paramNode of paramsNodes) {
|
|
649
|
-
const items = getProps(paramNode).items;
|
|
650
|
-
if (items)
|
|
651
|
-
queryParams.push(...items);
|
|
652
|
-
}
|
|
653
|
-
// v3 route children: error (HTTP error contract)
|
|
654
|
-
const errorNodes = getChildren(routeNode, 'error').filter(n => typeof getProps(n).status === 'number');
|
|
655
|
-
const errorResponses = errorNodes.map(n => ({
|
|
656
|
-
status: getProps(n).status,
|
|
657
|
-
message: String(getProps(n).message || 'Error'),
|
|
658
|
-
}));
|
|
659
|
-
const paramsType = schema.params || buildPathParamsType(path) || 'Record<string, never>';
|
|
660
|
-
const queryType = schema.query || 'Record<string, never>';
|
|
661
|
-
const bodyType = schema.body || 'Record<string, never>';
|
|
662
|
-
const responseType = schema.response || 'unknown';
|
|
663
|
-
const requestType = `Request<RouteParams, ResponseBody, RequestBody, RequestQuery>`;
|
|
664
|
-
const validationLines = [];
|
|
665
|
-
const requiredParams = schema.params ? extractRequiredKeys(schema.params) : derivePathParams(path);
|
|
666
|
-
const requiredBody = schema.body ? extractRequiredKeys(schema.body) : [];
|
|
667
|
-
const requiredQuery = schema.query ? extractRequiredKeys(schema.query) : [];
|
|
668
|
-
if (requiredParams.length > 0) {
|
|
669
|
-
validationLines.push(`assertRequiredFields('params', req.params, [${requiredParams.map(key => `'${escapeSingleQuotes(key)}'`).join(', ')}]);`);
|
|
670
|
-
}
|
|
671
|
-
if (requiredBody.length > 0) {
|
|
672
|
-
validationLines.push(`assertRequiredFields('body', req.body, [${requiredBody.map(key => `'${escapeSingleQuotes(key)}'`).join(', ')}]);`);
|
|
673
|
-
}
|
|
674
|
-
if (requiredQuery.length > 0) {
|
|
675
|
-
validationLines.push(`assertRequiredFields('query', req.query, [${requiredQuery.map(key => `'${escapeSingleQuotes(key)}'`).join(', ')}]);`);
|
|
676
|
-
}
|
|
677
|
-
const lines = [];
|
|
678
|
-
if (needsExpressDefaultImport) {
|
|
679
|
-
lines.push(`import express, { type Express, type NextFunction, type Request, type Response } from 'express';`);
|
|
680
|
-
}
|
|
681
|
-
else {
|
|
682
|
-
lines.push(`import { type Express, type NextFunction, type Request, type Response } from 'express';`);
|
|
683
|
-
}
|
|
684
|
-
if (caps.needsChildProcess) {
|
|
685
|
-
lines.push(`import { spawn } from 'node:child_process';`);
|
|
686
|
-
}
|
|
687
|
-
for (const routeImport of [...routeImports].sort()) {
|
|
688
|
-
lines.push(routeImport);
|
|
689
|
-
}
|
|
690
|
-
lines.push('');
|
|
691
|
-
lines.push(`type RouteParams = ${paramsType};`);
|
|
692
|
-
lines.push(`type RequestQuery = ${queryType};`);
|
|
693
|
-
lines.push(`type RequestBody = ${bodyType};`);
|
|
694
|
-
lines.push(`type ResponseBody = ${responseType};`);
|
|
695
|
-
if (validationLines.length > 0) {
|
|
696
|
-
lines.push('');
|
|
697
|
-
lines.push(`function assertRequiredFields(label: string, value: unknown, requiredKeys: string[]): void {`);
|
|
698
|
-
lines.push(` if (typeof value !== 'object' || value === null) {`);
|
|
699
|
-
lines.push(` throw new Error(\`Invalid \${label}: expected object payload\`);`);
|
|
700
|
-
lines.push(' }');
|
|
701
|
-
lines.push(` for (const key of requiredKeys) {`);
|
|
702
|
-
lines.push(` if (!(key in value)) {`);
|
|
703
|
-
lines.push(` throw new Error(\`Invalid \${label}: missing \${key}\`);`);
|
|
704
|
-
lines.push(' }');
|
|
705
|
-
lines.push(' }');
|
|
706
|
-
lines.push('}');
|
|
707
|
-
}
|
|
708
|
-
lines.push('');
|
|
709
|
-
lines.push(`export function ${registerName}(app: Express): void {`);
|
|
710
|
-
lines.push(` app.${normalizedMethod}('${escapeSingleQuotes(path)}', ${middlewareInvocations.length > 0 ? `${middlewareInvocations.join(', ')}, ` : ''}async (req: ${requestType}, res: Response, next: NextFunction) => {`);
|
|
711
|
-
// Schema validation — always runs first, before stream/timer
|
|
712
|
-
if (validationLines.length > 0) {
|
|
713
|
-
lines.push(' try {');
|
|
714
|
-
for (const validationLine of validationLines) {
|
|
715
|
-
lines.push(` ${validationLine}`);
|
|
716
|
-
}
|
|
717
|
-
lines.push(' } catch (err) {');
|
|
718
|
-
lines.push(' return res.status(400).json({ error: err instanceof Error ? err.message : String(err) } as any);');
|
|
719
|
-
lines.push(' }');
|
|
720
|
-
lines.push('');
|
|
721
|
-
}
|
|
722
|
-
// v3 query params — extract with safe type coercion and defaults
|
|
723
|
-
if (queryParams.length > 0) {
|
|
724
|
-
for (const qp of queryParams) {
|
|
725
|
-
if (qp.default !== undefined) {
|
|
726
|
-
if (qp.type === 'number') {
|
|
727
|
-
lines.push(` const ${qp.name} = req.query.${qp.name} !== undefined ? Number(req.query.${qp.name}) : ${qp.default};`);
|
|
728
|
-
}
|
|
729
|
-
else if (qp.type === 'boolean') {
|
|
730
|
-
lines.push(` const ${qp.name} = req.query.${qp.name} !== undefined ? req.query.${qp.name} === 'true' : ${qp.default};`);
|
|
731
|
-
}
|
|
732
|
-
else {
|
|
733
|
-
lines.push(` const ${qp.name} = typeof req.query.${qp.name} === 'string' ? req.query.${qp.name} : ${qp.default};`);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
else {
|
|
737
|
-
if (qp.type === 'number') {
|
|
738
|
-
lines.push(` const ${qp.name} = req.query.${qp.name} !== undefined ? Number(req.query.${qp.name}) : undefined;`);
|
|
739
|
-
}
|
|
740
|
-
else if (qp.type === 'boolean') {
|
|
741
|
-
lines.push(` const ${qp.name} = req.query.${qp.name} !== undefined ? req.query.${qp.name} === 'true' : undefined;`);
|
|
742
|
-
}
|
|
743
|
-
else {
|
|
744
|
-
lines.push(` const ${qp.name} = typeof req.query.${qp.name} === 'string' ? req.query.${qp.name} as string : undefined;`);
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
lines.push('');
|
|
749
|
-
}
|
|
750
|
-
// v3 error responses — JSDoc contract
|
|
751
|
-
if (errorResponses.length > 0) {
|
|
752
|
-
lines.push(' // Error contract:');
|
|
753
|
-
for (const er of errorResponses) {
|
|
754
|
-
lines.push(` // ${er.status} — ${er.message}`);
|
|
755
|
-
}
|
|
756
|
-
lines.push('');
|
|
757
|
-
}
|
|
758
|
-
// Request-scoped AbortController (if any async capability)
|
|
759
|
-
if (caps.needsAbortController) {
|
|
760
|
-
lines.push(' const ac = new AbortController();');
|
|
761
|
-
lines.push(" req.on('close', () => ac.abort());");
|
|
762
|
-
lines.push('');
|
|
763
|
-
}
|
|
764
|
-
if (caps.hasStream) {
|
|
765
|
-
// SSE route — validate first, then stream
|
|
766
|
-
lines.push(...generateStreamSetup(' '));
|
|
767
|
-
lines.push('');
|
|
768
|
-
const streamHandlerLines = handlerCode.split('\n').map(l => l.trim()).filter(Boolean);
|
|
769
|
-
// If spawn inside stream, generate spawn code
|
|
770
|
-
if (caps.hasSpawn && caps.spawnNode) {
|
|
771
|
-
const spawnLines = generateSpawnCode(caps.spawnNode, '');
|
|
772
|
-
streamHandlerLines.push(...spawnLines);
|
|
773
|
-
}
|
|
774
|
-
lines.push(...generateStreamWrap(streamHandlerLines, caps.hasSpawn, ' '));
|
|
775
|
-
}
|
|
776
|
-
else if (caps.hasTimer && caps.timerNode) {
|
|
777
|
-
// Timer route — wrap handler in timeout
|
|
778
|
-
lines.push(...generateTimerCode(caps.timerNode, routeHandlerCode, ' '));
|
|
779
|
-
}
|
|
780
|
-
else {
|
|
781
|
-
// Standard route — try/catch → next(error)
|
|
782
|
-
lines.push(' try {');
|
|
783
|
-
// Phase 1-3: Portable handler — derive → guard → handler → respond
|
|
784
|
-
if (hasPortableNodes) {
|
|
785
|
-
lines.push(...generatePortableHandlerExpress(routeNode, ' ', path));
|
|
786
|
-
}
|
|
787
|
-
else {
|
|
788
|
-
lines.push(...indentBlock(handlerCode, ' '));
|
|
789
|
-
}
|
|
790
|
-
lines.push(' } catch (error) {');
|
|
791
|
-
lines.push(' next(error);');
|
|
792
|
-
lines.push(' }');
|
|
793
|
-
}
|
|
794
|
-
lines.push(' });');
|
|
795
|
-
lines.push('}');
|
|
796
|
-
sourceMap.push({
|
|
797
|
-
irLine: routeNode.loc?.line || 0,
|
|
798
|
-
irCol: routeNode.loc?.col || 1,
|
|
799
|
-
outLine: 1,
|
|
800
|
-
outCol: 1,
|
|
801
|
-
});
|
|
802
|
-
return {
|
|
803
|
-
registerName,
|
|
804
|
-
fileBase,
|
|
805
|
-
artifact: {
|
|
806
|
-
path: `routes/${fileBase}.ts`,
|
|
807
|
-
content: lines.join('\n'),
|
|
808
|
-
type: 'route',
|
|
809
|
-
},
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
// ── Core node artifact mapping ────────────────────────────────────────────
|
|
813
|
-
/** Map core node type → output directory + artifact type. */
|
|
814
|
-
function coreNodeMeta(type) {
|
|
815
|
-
switch (type) {
|
|
816
|
-
case 'interface': return { dir: 'models', artifactType: 'model' };
|
|
817
|
-
case 'model': return { dir: 'models', artifactType: 'model' };
|
|
818
|
-
case 'repository': return { dir: 'models', artifactType: 'repository' };
|
|
819
|
-
case 'cache': return { dir: 'lib', artifactType: 'lib' };
|
|
820
|
-
case 'dependency': return { dir: 'lib', artifactType: 'lib' };
|
|
821
|
-
case 'service': return { dir: 'services', artifactType: 'service' };
|
|
822
|
-
case 'type': return { dir: 'types', artifactType: 'types' };
|
|
823
|
-
case 'config': return { dir: 'config', artifactType: 'config' };
|
|
824
|
-
case 'error': return { dir: 'errors', artifactType: 'error' };
|
|
825
|
-
default: return { dir: 'lib', artifactType: 'lib' };
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
const TOP_LEVEL_CORE = new Set([
|
|
829
|
-
'type', 'interface', 'service', 'fn', 'machine', 'error',
|
|
830
|
-
'module', 'config', 'store', 'event', 'const',
|
|
831
|
-
// Data layer
|
|
832
|
-
'model', 'repository', 'cache', 'dependency',
|
|
833
|
-
]);
|
|
834
|
-
// ── Prisma Schema Artifact ───────────────────────────────────────────────
|
|
835
|
-
/** Map KERN column type to Prisma schema type. Strips @db.* decorators for non-PostgreSQL providers. */
|
|
836
|
-
function mapColumnToPrisma(kernType, provider) {
|
|
837
|
-
const mapped = mapSemanticType(kernType, 'prisma');
|
|
838
|
-
if (provider !== 'postgresql') {
|
|
839
|
-
return mapped.replace(/ @db\.\w+/g, '');
|
|
840
|
-
}
|
|
841
|
-
return mapped;
|
|
842
|
-
}
|
|
843
|
-
/**
|
|
844
|
-
* Build a complete schema.prisma file from model IR nodes.
|
|
845
|
-
* This runs ONLY in Express — not in the shared codegen path.
|
|
846
|
-
*/
|
|
847
|
-
function formatPrismaDefault(value) {
|
|
848
|
-
const trimmed = value.trim();
|
|
849
|
-
if (/^-?\d+(?:\.\d+)?$/.test(trimmed))
|
|
850
|
-
return trimmed;
|
|
851
|
-
if (trimmed === 'true' || trimmed === 'false')
|
|
852
|
-
return trimmed;
|
|
853
|
-
if (trimmed === 'uuid4()' || trimmed === 'uuid4')
|
|
854
|
-
return 'uuid()';
|
|
855
|
-
if (trimmed === 'now()' || trimmed === 'now')
|
|
856
|
-
return 'now()';
|
|
857
|
-
if (trimmed === 'autoincrement()' || trimmed === 'autoincrement')
|
|
858
|
-
return 'autoincrement()';
|
|
859
|
-
if (/^[A-Za-z_]\w*\([^)]*\)$/.test(trimmed))
|
|
860
|
-
return trimmed;
|
|
861
|
-
return `"${trimmed}"`;
|
|
862
|
-
}
|
|
863
|
-
export function buildPrismaArtifact(modelNodes, config) {
|
|
864
|
-
if (modelNodes.length === 0)
|
|
865
|
-
return null;
|
|
866
|
-
const provider = config?.express?.prisma?.provider ?? 'postgresql';
|
|
867
|
-
const lines = [
|
|
868
|
-
'generator client {',
|
|
869
|
-
' provider = "prisma-client-js"',
|
|
870
|
-
'}',
|
|
871
|
-
'',
|
|
872
|
-
'datasource db {',
|
|
873
|
-
` provider = "${provider}"`,
|
|
874
|
-
' url = env("DATABASE_URL")',
|
|
875
|
-
'}',
|
|
876
|
-
'',
|
|
877
|
-
];
|
|
878
|
-
for (const node of modelNodes) {
|
|
879
|
-
const props = propsOf(node);
|
|
880
|
-
const name = props.name || 'UnknownModel';
|
|
881
|
-
const table = props.table;
|
|
882
|
-
const columns = getChildren(node, 'column');
|
|
883
|
-
const relations = getChildren(node, 'relation');
|
|
884
|
-
lines.push(`model ${name} {`);
|
|
885
|
-
for (const col of columns) {
|
|
886
|
-
const cp = propsOf(col);
|
|
887
|
-
const colName = cp.name || 'column';
|
|
888
|
-
const rawType = mapColumnToPrisma(cp.type || 'String', provider);
|
|
889
|
-
// Split off Prisma decorators embedded in the type (e.g., 'String @db.Uuid')
|
|
890
|
-
const [prismaType, ...typeDecorators] = rawType.split(' ');
|
|
891
|
-
const decorators = [...typeDecorators];
|
|
892
|
-
const isPrimary = cp.primary === 'true' || cp.primary === true;
|
|
893
|
-
const isUnique = cp.unique === 'true' || cp.unique === true;
|
|
894
|
-
const isNullable = cp.nullable === 'true' || cp.nullable === true;
|
|
895
|
-
const defaultVal = cp.default;
|
|
896
|
-
if (isPrimary)
|
|
897
|
-
decorators.push('@id');
|
|
898
|
-
if (isUnique)
|
|
899
|
-
decorators.push('@unique');
|
|
900
|
-
if (defaultVal !== undefined)
|
|
901
|
-
decorators.push(`@default(${formatPrismaDefault(defaultVal)})`);
|
|
902
|
-
const nullMark = isNullable ? '?' : '';
|
|
903
|
-
const decoStr = decorators.length > 0 ? ' ' + decorators.join(' ') : '';
|
|
904
|
-
lines.push(` ${colName} ${prismaType}${nullMark}${decoStr}`);
|
|
905
|
-
}
|
|
906
|
-
for (const rel of relations) {
|
|
907
|
-
const rp = propsOf(rel);
|
|
908
|
-
const relName = rp.name || 'relation';
|
|
909
|
-
const target = rp.target || rp.model || 'Unknown';
|
|
910
|
-
const kind = rp.kind || 'one-to-many';
|
|
911
|
-
const fk = rp.foreignKey;
|
|
912
|
-
if (kind === 'one-to-many' || kind === 'many-to-many') {
|
|
913
|
-
lines.push(` ${relName} ${target}[]`);
|
|
914
|
-
}
|
|
915
|
-
else {
|
|
916
|
-
const relDeco = fk ? ` @relation(fields: [${fk}], references: [id])` : '';
|
|
917
|
-
lines.push(` ${relName} ${target}?${relDeco}`);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
if (table) {
|
|
921
|
-
lines.push('');
|
|
922
|
-
lines.push(` @@map("${table}")`);
|
|
923
|
-
}
|
|
924
|
-
lines.push('}');
|
|
925
|
-
lines.push('');
|
|
926
|
-
}
|
|
927
|
-
return { path: 'prisma/schema.prisma', content: lines.join('\n'), type: 'prisma' };
|
|
928
|
-
}
|
|
929
|
-
function buildCoreArtifact(node) {
|
|
930
|
-
const name = String((node.props || {}).name || node.type);
|
|
931
|
-
const fileBase = slugify(name);
|
|
932
|
-
const { dir, artifactType } = coreNodeMeta(node.type);
|
|
933
|
-
const tsLines = generateCoreNode(node);
|
|
934
|
-
const content = tsLines.join('\n');
|
|
935
|
-
// Extract export names for the import line
|
|
936
|
-
const exportNames = [];
|
|
937
|
-
for (const line of tsLines) {
|
|
938
|
-
const m = line.match(/^export (?:type |interface |function |const |class |enum |abstract class )(\w+)/);
|
|
939
|
-
if (m)
|
|
940
|
-
exportNames.push(m[1]);
|
|
941
|
-
}
|
|
942
|
-
return {
|
|
943
|
-
importPath: `./${dir}/${fileBase}.js`,
|
|
944
|
-
exportNames,
|
|
945
|
-
artifact: {
|
|
946
|
-
path: `${dir}/${fileBase}.ts`,
|
|
947
|
-
content,
|
|
948
|
-
type: artifactType,
|
|
949
|
-
},
|
|
950
|
-
};
|
|
951
|
-
}
|
|
1
|
+
import { accountNode, buildDiagnostics, countTokens, getChildren, getFirstChild, getProps, serializeIR, } from '@kernlang/core';
|
|
2
|
+
import { resolveMiddlewareUsage } from './express-middleware.js';
|
|
3
|
+
import { buildCoreArtifact, buildPrismaArtifact, TOP_LEVEL_CORE } from './express-prisma.js';
|
|
4
|
+
import { buildRouteArtifact } from './express-route.js';
|
|
5
|
+
import { escapeSingleQuotes, findServerNode } from './express-utils.js';
|
|
6
|
+
// Re-export buildPrismaArtifact for external consumers
|
|
7
|
+
export { buildPrismaArtifact } from './express-prisma.js';
|
|
952
8
|
export function transpileExpress(root, _config) {
|
|
953
9
|
const sourceMap = [];
|
|
954
10
|
const accounted = new Map();
|
|
@@ -967,7 +23,7 @@ export function transpileExpress(root, _config) {
|
|
|
967
23
|
for (const rn of routeNodes)
|
|
968
24
|
accountNode(accounted, rn, 'consumed', 'route artifact', true);
|
|
969
25
|
const isStrict = !_config || _config.express.security === 'strict';
|
|
970
|
-
const hasJsonMiddleware = serverMiddlewares.some(m => String(getProps(m).name || '') === 'json');
|
|
26
|
+
const hasJsonMiddleware = serverMiddlewares.some((m) => String(getProps(m).name || '') === 'json');
|
|
971
27
|
const serverImports = new Set();
|
|
972
28
|
const serverMiddlewareInvocations = [];
|
|
973
29
|
const dependencyComments = [];
|
|
@@ -991,26 +47,96 @@ export function transpileExpress(root, _config) {
|
|
|
991
47
|
// Collect top-level core language nodes (type, interface, service, config, etc.)
|
|
992
48
|
// Core nodes may live as siblings of server under the parse root, or as server children.
|
|
993
49
|
const rootChildren = root.children || [];
|
|
994
|
-
const serverChildren = serverNode !== root ?
|
|
50
|
+
const serverChildren = serverNode !== root ? serverNode.children || [] : [];
|
|
995
51
|
const coreNodes = [
|
|
996
|
-
...rootChildren.filter(c => TOP_LEVEL_CORE.has(c.type)),
|
|
997
|
-
...serverChildren.filter(c => TOP_LEVEL_CORE.has(c.type)),
|
|
52
|
+
...rootChildren.filter((c) => TOP_LEVEL_CORE.has(c.type)),
|
|
53
|
+
...serverChildren.filter((c) => TOP_LEVEL_CORE.has(c.type)),
|
|
998
54
|
];
|
|
999
55
|
// If the root itself is a core node (parser wraps first top-level node as root), include it
|
|
1000
56
|
if (TOP_LEVEL_CORE.has(root.type) && root !== serverNode) {
|
|
1001
57
|
coreNodes.unshift(root);
|
|
1002
58
|
}
|
|
1003
|
-
const coreArtifactRefs = coreNodes.map(n => buildCoreArtifact(n));
|
|
59
|
+
const coreArtifactRefs = coreNodes.map((n) => buildCoreArtifact(n));
|
|
1004
60
|
for (const cn of coreNodes)
|
|
1005
61
|
accountNode(accounted, cn, 'expressed', 'core artifact', true);
|
|
1006
62
|
const websocketNodes = getChildren(serverNode, 'websocket');
|
|
1007
63
|
for (const ws of websocketNodes)
|
|
1008
64
|
accountNode(accounted, ws, 'consumed', 'websocket handler', true);
|
|
1009
|
-
const routeArtifacts = routeNodes.map((routeNode, index) => buildRouteArtifact(routeNode, index, middlewareArtifacts, sourceMap));
|
|
65
|
+
const routeArtifacts = routeNodes.map((routeNode, index) => buildRouteArtifact(routeNode, index, middlewareArtifacts, sourceMap, isStrict ? 'strict' : 'relaxed'));
|
|
66
|
+
const hasHealthRoute = routeNodes.some((routeNode) => {
|
|
67
|
+
const props = getProps(routeNode);
|
|
68
|
+
return String(props.path || '/') === '/health' && String(props.method || 'get').toLowerCase() === 'get';
|
|
69
|
+
});
|
|
70
|
+
// Auth middleware: generate real JWT implementation when any route uses auth
|
|
71
|
+
const hasAuth = routeNodes.some((r) => getFirstChild(r, 'auth'));
|
|
72
|
+
if (hasAuth && !middlewareArtifacts.has('auth')) {
|
|
73
|
+
const authArtifact = {
|
|
74
|
+
path: 'middleware/auth.ts',
|
|
75
|
+
content: [
|
|
76
|
+
`import type { NextFunction, Request, Response } from 'express';`,
|
|
77
|
+
`import jwt from 'jsonwebtoken';`,
|
|
78
|
+
``,
|
|
79
|
+
...(isStrict
|
|
80
|
+
? [
|
|
81
|
+
`const JWT_SECRET = process.env.JWT_SECRET;`,
|
|
82
|
+
``,
|
|
83
|
+
`if (!JWT_SECRET) {`,
|
|
84
|
+
` throw new Error('JWT_SECRET environment variable is required in strict mode');`,
|
|
85
|
+
`}`,
|
|
86
|
+
]
|
|
87
|
+
: [`const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';`]),
|
|
88
|
+
`const JWT_ALGORITHM = process.env.JWT_ALGORITHM || 'HS256';`,
|
|
89
|
+
``,
|
|
90
|
+
`export interface AuthUser {`,
|
|
91
|
+
` id: string;`,
|
|
92
|
+
` [key: string]: unknown;`,
|
|
93
|
+
`}`,
|
|
94
|
+
``,
|
|
95
|
+
`declare global {`,
|
|
96
|
+
` namespace Express {`,
|
|
97
|
+
` interface Request {`,
|
|
98
|
+
` user?: AuthUser;`,
|
|
99
|
+
` }`,
|
|
100
|
+
` }`,
|
|
101
|
+
`}`,
|
|
102
|
+
``,
|
|
103
|
+
`export function authRequired(req: Request, res: Response, next: NextFunction): void {`,
|
|
104
|
+
` const header = req.headers.authorization;`,
|
|
105
|
+
` if (!header?.startsWith('Bearer ')) {`,
|
|
106
|
+
` res.status(401).json({ error: 'Missing or invalid Authorization header' });`,
|
|
107
|
+
` return;`,
|
|
108
|
+
` }`,
|
|
109
|
+
` try {`,
|
|
110
|
+
` const payload = jwt.verify(header.slice(7), JWT_SECRET, { algorithms: [JWT_ALGORITHM] }) as AuthUser;`,
|
|
111
|
+
` req.user = payload;`,
|
|
112
|
+
` next();`,
|
|
113
|
+
` } catch {`,
|
|
114
|
+
` res.status(401).json({ error: 'Invalid or expired token' });`,
|
|
115
|
+
` }`,
|
|
116
|
+
`}`,
|
|
117
|
+
``,
|
|
118
|
+
`export function authOptional(req: Request, res: Response, next: NextFunction): void {`,
|
|
119
|
+
` const header = req.headers.authorization;`,
|
|
120
|
+
` if (header?.startsWith('Bearer ')) {`,
|
|
121
|
+
` try {`,
|
|
122
|
+
` req.user = jwt.verify(header.slice(7), JWT_SECRET, { algorithms: [JWT_ALGORITHM] }) as AuthUser;`,
|
|
123
|
+
` } catch { /* token invalid — proceed without user */ }`,
|
|
124
|
+
` }`,
|
|
125
|
+
` next();`,
|
|
126
|
+
`}`,
|
|
127
|
+
].join('\n'),
|
|
128
|
+
type: 'middleware',
|
|
129
|
+
};
|
|
130
|
+
middlewareArtifacts.set('auth', { artifact: authArtifact, exportName: 'authRequired', fileBase: 'auth' });
|
|
131
|
+
dependencyComments.push('jsonwebtoken');
|
|
132
|
+
}
|
|
1010
133
|
const lines = [];
|
|
1011
134
|
if (dependencyComments.length > 0) {
|
|
1012
135
|
lines.push(`// Dependencies: ${dependencyComments.join(', ')}`);
|
|
1013
136
|
}
|
|
137
|
+
if (isStrict) {
|
|
138
|
+
lines.push(`import crypto from 'node:crypto';`);
|
|
139
|
+
}
|
|
1014
140
|
lines.push(`import express from 'express';`);
|
|
1015
141
|
lines.push(`import type { NextFunction, Request, Response } from 'express';`);
|
|
1016
142
|
if (websocketNodes.length > 0) {
|
|
@@ -1036,6 +162,12 @@ export function transpileExpress(root, _config) {
|
|
|
1036
162
|
// Hardened defaults (strict mode)
|
|
1037
163
|
if (isStrict) {
|
|
1038
164
|
lines.push(`app.disable('x-powered-by');`);
|
|
165
|
+
lines.push(`app.use((req: Request, res: Response, next: NextFunction) => {`);
|
|
166
|
+
lines.push(` const id = crypto.randomUUID();`);
|
|
167
|
+
lines.push(` res.setHeader('X-Request-ID', id);`);
|
|
168
|
+
lines.push(` (req as any).requestId = id;`);
|
|
169
|
+
lines.push(` next();`);
|
|
170
|
+
lines.push(`});`);
|
|
1039
171
|
if (!hasJsonMiddleware) {
|
|
1040
172
|
lines.push(`app.use(express.json({ limit: '1mb' }));`);
|
|
1041
173
|
}
|
|
@@ -1047,6 +179,13 @@ export function transpileExpress(root, _config) {
|
|
|
1047
179
|
if (serverMiddlewareInvocations.length > 0 && routeArtifacts.length > 0) {
|
|
1048
180
|
lines.push('');
|
|
1049
181
|
}
|
|
182
|
+
// Health check — before user routes so it can't be shadowed by catch-all
|
|
183
|
+
if (isStrict && !hasHealthRoute) {
|
|
184
|
+
lines.push(`app.get('/health', (_req: Request, res: Response) => {`);
|
|
185
|
+
lines.push(` res.status(200).json({ status: 'ok' });`);
|
|
186
|
+
lines.push('});');
|
|
187
|
+
lines.push('');
|
|
188
|
+
}
|
|
1050
189
|
for (const routeArtifact of routeArtifacts) {
|
|
1051
190
|
lines.push(`${routeArtifact.registerName}(app);`);
|
|
1052
191
|
}
|
|
@@ -1086,7 +225,7 @@ export function transpileExpress(root, _config) {
|
|
|
1086
225
|
lines.push('');
|
|
1087
226
|
lines.push(`${wsName}Server.on('connection', (ws: WebSocket) => {`);
|
|
1088
227
|
// on event=connect
|
|
1089
|
-
const connectNode = wsOnNodes.find(n => {
|
|
228
|
+
const connectNode = wsOnNodes.find((n) => {
|
|
1090
229
|
const e = String(getProps(n).event || getProps(n).name || '');
|
|
1091
230
|
return e === 'connect' || e === 'connection';
|
|
1092
231
|
});
|
|
@@ -1100,13 +239,19 @@ export function transpileExpress(root, _config) {
|
|
|
1100
239
|
}
|
|
1101
240
|
}
|
|
1102
241
|
// on event=message
|
|
1103
|
-
const messageNode = wsOnNodes.find(n => {
|
|
242
|
+
const messageNode = wsOnNodes.find((n) => {
|
|
1104
243
|
const e = String(getProps(n).event || getProps(n).name || '');
|
|
1105
244
|
return e === 'message';
|
|
1106
245
|
});
|
|
1107
246
|
lines.push('');
|
|
1108
247
|
lines.push(` ws.on('message', (raw: Buffer) => {`);
|
|
1109
|
-
lines.push(`
|
|
248
|
+
lines.push(` let data: any;`);
|
|
249
|
+
lines.push(` try {`);
|
|
250
|
+
lines.push(` data = JSON.parse(raw.toString());`);
|
|
251
|
+
lines.push(` } catch {`);
|
|
252
|
+
lines.push(` ws.send(JSON.stringify({ error: 'Invalid JSON payload' }));`);
|
|
253
|
+
lines.push(` return;`);
|
|
254
|
+
lines.push(` }`);
|
|
1110
255
|
if (messageNode) {
|
|
1111
256
|
const handlerChild = getChildren(messageNode, 'handler')[0];
|
|
1112
257
|
const code = handlerChild ? String(getProps(handlerChild).code || '') : '';
|
|
@@ -1118,7 +263,7 @@ export function transpileExpress(root, _config) {
|
|
|
1118
263
|
}
|
|
1119
264
|
lines.push(` });`);
|
|
1120
265
|
// on event=error
|
|
1121
|
-
const errorNode = wsOnNodes.find(n => {
|
|
266
|
+
const errorNode = wsOnNodes.find((n) => {
|
|
1122
267
|
const e = String(getProps(n).event || getProps(n).name || '');
|
|
1123
268
|
return e === 'error';
|
|
1124
269
|
});
|
|
@@ -1135,7 +280,7 @@ export function transpileExpress(root, _config) {
|
|
|
1135
280
|
lines.push(` });`);
|
|
1136
281
|
}
|
|
1137
282
|
// on event=disconnect/close
|
|
1138
|
-
const closeNode = wsOnNodes.find(n => {
|
|
283
|
+
const closeNode = wsOnNodes.find((n) => {
|
|
1139
284
|
const e = String(getProps(n).event || getProps(n).name || '');
|
|
1140
285
|
return e === 'disconnect' || e === 'close';
|
|
1141
286
|
});
|
|
@@ -1157,12 +302,37 @@ export function transpileExpress(root, _config) {
|
|
|
1157
302
|
lines.push(`server.listen(port, () => {`);
|
|
1158
303
|
lines.push(` console.log(\`\${serverName} listening on port \${port}\`);`);
|
|
1159
304
|
lines.push('});');
|
|
305
|
+
lines.push(`const shutdown = (signal: string) => {`);
|
|
306
|
+
lines.push(` console.log(\`\${signal} received, shutting down gracefully...\`);`);
|
|
307
|
+
for (const wsNode of websocketNodes) {
|
|
308
|
+
const wsName = String(getProps(wsNode).name || 'ws');
|
|
309
|
+
lines.push(` ${wsName}Server.clients.forEach((client: WebSocket) => client.terminate());`);
|
|
310
|
+
lines.push(` ${wsName}Server.close();`);
|
|
311
|
+
}
|
|
312
|
+
lines.push(` server.close(() => {`);
|
|
313
|
+
lines.push(` console.log('Server closed');`);
|
|
314
|
+
lines.push(` process.exit(0);`);
|
|
315
|
+
lines.push(` });`);
|
|
316
|
+
lines.push(` setTimeout(() => { console.error('Forced shutdown'); process.exit(1); }, 30000);`);
|
|
317
|
+
lines.push(`};`);
|
|
318
|
+
lines.push(`process.on('SIGTERM', () => shutdown('SIGTERM'));`);
|
|
319
|
+
lines.push(`process.on('SIGINT', () => shutdown('SIGINT'));`);
|
|
1160
320
|
}
|
|
1161
321
|
else {
|
|
1162
322
|
lines.push('');
|
|
1163
|
-
lines.push(`app.listen(port, () => {`);
|
|
323
|
+
lines.push(`const server = app.listen(port, () => {`);
|
|
1164
324
|
lines.push(` console.log(\`\${serverName} listening on port \${port}\`);`);
|
|
1165
325
|
lines.push('});');
|
|
326
|
+
lines.push(`const shutdown = (signal: string) => {`);
|
|
327
|
+
lines.push(` console.log(\`\${signal} received, shutting down gracefully...\`);`);
|
|
328
|
+
lines.push(` server.close(() => {`);
|
|
329
|
+
lines.push(` console.log('Server closed');`);
|
|
330
|
+
lines.push(` process.exit(0);`);
|
|
331
|
+
lines.push(` });`);
|
|
332
|
+
lines.push(` setTimeout(() => { console.error('Forced shutdown'); process.exit(1); }, 30000);`);
|
|
333
|
+
lines.push(`};`);
|
|
334
|
+
lines.push(`process.on('SIGTERM', () => shutdown('SIGTERM'));`);
|
|
335
|
+
lines.push(`process.on('SIGINT', () => shutdown('SIGINT'));`);
|
|
1166
336
|
}
|
|
1167
337
|
lines.push('');
|
|
1168
338
|
lines.push('export default app;');
|
|
@@ -1173,17 +343,149 @@ export function transpileExpress(root, _config) {
|
|
|
1173
343
|
outCol: 1,
|
|
1174
344
|
});
|
|
1175
345
|
// Build Prisma schema artifact from model nodes
|
|
1176
|
-
const modelNodes = coreNodes.filter(n => n.type === 'model');
|
|
346
|
+
const modelNodes = coreNodes.filter((n) => n.type === 'model');
|
|
1177
347
|
const prismaArtifact = buildPrismaArtifact(modelNodes, _config);
|
|
348
|
+
// DB connection: implicit path — auto-generate when models exist but no explicit dependency kind=database
|
|
349
|
+
const hasExplicitDb = coreNodes.some((n) => n.type === 'dependency' && String(n.props?.kind) === 'database');
|
|
350
|
+
let dbArtifact = null;
|
|
351
|
+
if (modelNodes.length > 0 && !hasExplicitDb) {
|
|
352
|
+
dbArtifact = {
|
|
353
|
+
path: 'lib/db.ts',
|
|
354
|
+
content: [
|
|
355
|
+
`import { PrismaClient } from '@prisma/client';`,
|
|
356
|
+
``,
|
|
357
|
+
`const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };`,
|
|
358
|
+
``,
|
|
359
|
+
`export const prisma = globalForPrisma.prisma ?? new PrismaClient();`,
|
|
360
|
+
``,
|
|
361
|
+
`if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;`,
|
|
362
|
+
``,
|
|
363
|
+
`export default prisma;`,
|
|
364
|
+
].join('\n'),
|
|
365
|
+
type: 'lib',
|
|
366
|
+
};
|
|
367
|
+
dependencyComments.push('@prisma/client');
|
|
368
|
+
}
|
|
369
|
+
// Backend infrastructure artifacts (job, storage, email)
|
|
370
|
+
const infraArtifacts = [];
|
|
371
|
+
for (const node of coreNodes) {
|
|
372
|
+
const np = getProps(node);
|
|
373
|
+
const nodeName = String(np.name || node.type);
|
|
374
|
+
if (node.type === 'job') {
|
|
375
|
+
const queue = String(np.queue || nodeName);
|
|
376
|
+
const code = getFirstChild(node, 'handler') ? String(getProps(getFirstChild(node, 'handler')).code || '') : '';
|
|
377
|
+
infraArtifacts.push({
|
|
378
|
+
path: `jobs/${nodeName}.ts`,
|
|
379
|
+
content: [
|
|
380
|
+
`import { Worker, Queue } from 'bullmq';`,
|
|
381
|
+
``,
|
|
382
|
+
`export const ${nodeName}Queue = new Queue('${queue}');`,
|
|
383
|
+
``,
|
|
384
|
+
`// Run: npx tsx jobs/${nodeName}.ts`,
|
|
385
|
+
`const worker = new Worker('${queue}', async (job) => {`,
|
|
386
|
+
...(code ? code.split('\n').map((l) => ` ${l}`) : [` // TODO: implement ${nodeName}`]),
|
|
387
|
+
`});`,
|
|
388
|
+
``,
|
|
389
|
+
`worker.on('completed', (job) => console.log(\`Job \${job.id} completed\`));`,
|
|
390
|
+
`worker.on('failed', (job, err) => console.error(\`Job \${job?.id} failed:\`, err));`,
|
|
391
|
+
``,
|
|
392
|
+
`export default worker;`,
|
|
393
|
+
].join('\n'),
|
|
394
|
+
type: 'lib',
|
|
395
|
+
});
|
|
396
|
+
dependencyComments.push('bullmq');
|
|
397
|
+
}
|
|
398
|
+
else if (node.type === 'storage') {
|
|
399
|
+
const provider = String(np.provider || 's3');
|
|
400
|
+
const bucket = String(np.bucket || 'my-app-uploads');
|
|
401
|
+
infraArtifacts.push({
|
|
402
|
+
path: `lib/${nodeName}.ts`,
|
|
403
|
+
content: provider === 's3'
|
|
404
|
+
? [
|
|
405
|
+
`import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';`,
|
|
406
|
+
`import { getSignedUrl } from '@aws-sdk/s3-request-presigner';`,
|
|
407
|
+
``,
|
|
408
|
+
`const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });`,
|
|
409
|
+
`const BUCKET = process.env.S3_BUCKET || '${bucket}';`,
|
|
410
|
+
``,
|
|
411
|
+
`export async function uploadFile(key: string, body: Buffer, contentType: string): Promise<string> {`,
|
|
412
|
+
` await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentType: contentType }));`,
|
|
413
|
+
` return key;`,
|
|
414
|
+
`}`,
|
|
415
|
+
``,
|
|
416
|
+
`export async function getDownloadUrl(key: string, expiresIn = 3600): Promise<string> {`,
|
|
417
|
+
` return getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), { expiresIn });`,
|
|
418
|
+
`}`,
|
|
419
|
+
].join('\n')
|
|
420
|
+
: [
|
|
421
|
+
`import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';`,
|
|
422
|
+
`import { join } from 'node:path';`,
|
|
423
|
+
``,
|
|
424
|
+
`const STORAGE_DIR = process.env.STORAGE_DIR || './uploads';`,
|
|
425
|
+
`mkdirSync(STORAGE_DIR, { recursive: true });`,
|
|
426
|
+
``,
|
|
427
|
+
`export function uploadFile(key: string, body: Buffer): string {`,
|
|
428
|
+
` writeFileSync(join(STORAGE_DIR, key), body);`,
|
|
429
|
+
` return key;`,
|
|
430
|
+
`}`,
|
|
431
|
+
``,
|
|
432
|
+
`export function readFile(key: string): Buffer {`,
|
|
433
|
+
` return readFileSync(join(STORAGE_DIR, key));`,
|
|
434
|
+
`}`,
|
|
435
|
+
].join('\n'),
|
|
436
|
+
type: 'lib',
|
|
437
|
+
});
|
|
438
|
+
if (provider === 's3')
|
|
439
|
+
dependencyComments.push('@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner');
|
|
440
|
+
}
|
|
441
|
+
else if (node.type === 'email') {
|
|
442
|
+
const provider = String(np.provider || 'smtp');
|
|
443
|
+
const from = String(np.from || 'noreply@example.com');
|
|
444
|
+
infraArtifacts.push({
|
|
445
|
+
path: `lib/${nodeName}.ts`,
|
|
446
|
+
content: provider === 'sendgrid'
|
|
447
|
+
? [
|
|
448
|
+
`const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY || '';`,
|
|
449
|
+
`const DEFAULT_FROM = '${from}';`,
|
|
450
|
+
``,
|
|
451
|
+
`export async function sendEmail(to: string, subject: string, html: string, from = DEFAULT_FROM): Promise<void> {`,
|
|
452
|
+
` await fetch('https://api.sendgrid.com/v3/mail/send', {`,
|
|
453
|
+
` method: 'POST',`,
|
|
454
|
+
` headers: { Authorization: \`Bearer \${SENDGRID_API_KEY}\`, 'Content-Type': 'application/json' },`,
|
|
455
|
+
` body: JSON.stringify({ personalizations: [{ to: [{ email: to }] }], from: { email: from }, subject, content: [{ type: 'text/html', value: html }] }),`,
|
|
456
|
+
` });`,
|
|
457
|
+
`}`,
|
|
458
|
+
].join('\n')
|
|
459
|
+
: [
|
|
460
|
+
`import { createTransport } from 'nodemailer';`,
|
|
461
|
+
``,
|
|
462
|
+
`const transporter = createTransport({`,
|
|
463
|
+
` host: process.env.SMTP_HOST || 'localhost',`,
|
|
464
|
+
` port: Number(process.env.SMTP_PORT || 587),`,
|
|
465
|
+
` auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },`,
|
|
466
|
+
`});`,
|
|
467
|
+
``,
|
|
468
|
+
`export async function sendEmail(to: string, subject: string, html: string, from = '${from}'): Promise<void> {`,
|
|
469
|
+
` await transporter.sendMail({ from, to, subject, html });`,
|
|
470
|
+
`}`,
|
|
471
|
+
].join('\n'),
|
|
472
|
+
type: 'lib',
|
|
473
|
+
});
|
|
474
|
+
if (provider !== 'sendgrid')
|
|
475
|
+
dependencyComments.push('nodemailer');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
1178
478
|
const artifacts = [
|
|
1179
|
-
...routeArtifacts.map(route => route.artifact),
|
|
1180
|
-
...[...middlewareArtifacts.values()].map(entry => entry.artifact),
|
|
1181
|
-
...coreArtifactRefs.map(ref => ref.artifact),
|
|
479
|
+
...routeArtifacts.map((route) => route.artifact),
|
|
480
|
+
...[...middlewareArtifacts.values()].map((entry) => entry.artifact),
|
|
481
|
+
...coreArtifactRefs.map((ref) => ref.artifact),
|
|
1182
482
|
...(prismaArtifact ? [prismaArtifact] : []),
|
|
483
|
+
...(dbArtifact ? [dbArtifact] : []),
|
|
484
|
+
...infraArtifacts,
|
|
1183
485
|
];
|
|
1184
486
|
const output = lines.join('\n');
|
|
1185
487
|
const irText = serializeIR(root);
|
|
1186
|
-
const tsText = [output, ...artifacts.map(artifact => artifact.content)].join('\n');
|
|
488
|
+
const tsText = [output, ...artifacts.map((artifact) => artifact.content)].join('\n');
|
|
1187
489
|
const irTokenCount = countTokens(irText);
|
|
1188
490
|
const tsTokenCount = countTokens(tsText);
|
|
1189
491
|
const tokenReduction = Math.round((1 - irTokenCount / tsTokenCount) * 100);
|