@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.
@@ -1,954 +1,10 @@
1
- import { camelKey, countTokens, generateCoreNode, getChildren, getFirstChild, getProps, serializeIR, buildDiagnostics, accountNode, propsOf, mapSemanticType } from '@kernlang/core';
2
- const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']);
3
- function analyzeRouteCapabilities(routeNode) {
4
- const streamNode = getFirstChild(routeNode, 'stream');
5
- // spawn must be inside stream (for SSE output), not standalone on route
6
- const spawnNode = streamNode ? getFirstChild(streamNode, 'spawn') : undefined;
7
- const timerNode = getFirstChild(routeNode, 'timer');
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 ? (serverNode.children || []) : [];
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(` const data = JSON.parse(raw.toString());`);
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);