@kernlang/express 3.1.5 → 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,853 +1,10 @@
1
- import { camelKey, countTokens, generateCoreNode, getChildren, getFirstChild, getProps, serializeIR, buildDiagnostics, accountNode } from '@kernlang/core';
2
- const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete']);
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 'service': return { dir: 'services', artifactType: 'service' };
818
- case 'type': return { dir: 'types', artifactType: 'types' };
819
- case 'config': return { dir: 'config', artifactType: 'config' };
820
- case 'error': return { dir: 'errors', artifactType: 'error' };
821
- default: return { dir: 'lib', artifactType: 'lib' };
822
- }
823
- }
824
- const TOP_LEVEL_CORE = new Set([
825
- 'type', 'interface', 'service', 'fn', 'machine', 'error',
826
- 'module', 'config', 'store', 'event', 'const',
827
- ]);
828
- function buildCoreArtifact(node) {
829
- const name = String((node.props || {}).name || node.type);
830
- const fileBase = slugify(name);
831
- const { dir, artifactType } = coreNodeMeta(node.type);
832
- const tsLines = generateCoreNode(node);
833
- const content = tsLines.join('\n');
834
- // Extract export names for the import line
835
- const exportNames = [];
836
- for (const line of tsLines) {
837
- const m = line.match(/^export (?:type |interface |function |const |class |enum |abstract class )(\w+)/);
838
- if (m)
839
- exportNames.push(m[1]);
840
- }
841
- return {
842
- importPath: `./${dir}/${fileBase}.js`,
843
- exportNames,
844
- artifact: {
845
- path: `${dir}/${fileBase}.ts`,
846
- content,
847
- type: artifactType,
848
- },
849
- };
850
- }
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';
851
8
  export function transpileExpress(root, _config) {
852
9
  const sourceMap = [];
853
10
  const accounted = new Map();
@@ -866,7 +23,7 @@ export function transpileExpress(root, _config) {
866
23
  for (const rn of routeNodes)
867
24
  accountNode(accounted, rn, 'consumed', 'route artifact', true);
868
25
  const isStrict = !_config || _config.express.security === 'strict';
869
- const hasJsonMiddleware = serverMiddlewares.some(m => String(getProps(m).name || '') === 'json');
26
+ const hasJsonMiddleware = serverMiddlewares.some((m) => String(getProps(m).name || '') === 'json');
870
27
  const serverImports = new Set();
871
28
  const serverMiddlewareInvocations = [];
872
29
  const dependencyComments = [];
@@ -890,26 +47,96 @@ export function transpileExpress(root, _config) {
890
47
  // Collect top-level core language nodes (type, interface, service, config, etc.)
891
48
  // Core nodes may live as siblings of server under the parse root, or as server children.
892
49
  const rootChildren = root.children || [];
893
- const serverChildren = serverNode !== root ? (serverNode.children || []) : [];
50
+ const serverChildren = serverNode !== root ? serverNode.children || [] : [];
894
51
  const coreNodes = [
895
- ...rootChildren.filter(c => TOP_LEVEL_CORE.has(c.type)),
896
- ...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)),
897
54
  ];
898
55
  // If the root itself is a core node (parser wraps first top-level node as root), include it
899
56
  if (TOP_LEVEL_CORE.has(root.type) && root !== serverNode) {
900
57
  coreNodes.unshift(root);
901
58
  }
902
- const coreArtifactRefs = coreNodes.map(n => buildCoreArtifact(n));
59
+ const coreArtifactRefs = coreNodes.map((n) => buildCoreArtifact(n));
903
60
  for (const cn of coreNodes)
904
61
  accountNode(accounted, cn, 'expressed', 'core artifact', true);
905
62
  const websocketNodes = getChildren(serverNode, 'websocket');
906
63
  for (const ws of websocketNodes)
907
64
  accountNode(accounted, ws, 'consumed', 'websocket handler', true);
908
- 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
+ }
909
133
  const lines = [];
910
134
  if (dependencyComments.length > 0) {
911
135
  lines.push(`// Dependencies: ${dependencyComments.join(', ')}`);
912
136
  }
137
+ if (isStrict) {
138
+ lines.push(`import crypto from 'node:crypto';`);
139
+ }
913
140
  lines.push(`import express from 'express';`);
914
141
  lines.push(`import type { NextFunction, Request, Response } from 'express';`);
915
142
  if (websocketNodes.length > 0) {
@@ -935,6 +162,12 @@ export function transpileExpress(root, _config) {
935
162
  // Hardened defaults (strict mode)
936
163
  if (isStrict) {
937
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(`});`);
938
171
  if (!hasJsonMiddleware) {
939
172
  lines.push(`app.use(express.json({ limit: '1mb' }));`);
940
173
  }
@@ -946,6 +179,13 @@ export function transpileExpress(root, _config) {
946
179
  if (serverMiddlewareInvocations.length > 0 && routeArtifacts.length > 0) {
947
180
  lines.push('');
948
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
+ }
949
189
  for (const routeArtifact of routeArtifacts) {
950
190
  lines.push(`${routeArtifact.registerName}(app);`);
951
191
  }
@@ -985,7 +225,7 @@ export function transpileExpress(root, _config) {
985
225
  lines.push('');
986
226
  lines.push(`${wsName}Server.on('connection', (ws: WebSocket) => {`);
987
227
  // on event=connect
988
- const connectNode = wsOnNodes.find(n => {
228
+ const connectNode = wsOnNodes.find((n) => {
989
229
  const e = String(getProps(n).event || getProps(n).name || '');
990
230
  return e === 'connect' || e === 'connection';
991
231
  });
@@ -999,13 +239,19 @@ export function transpileExpress(root, _config) {
999
239
  }
1000
240
  }
1001
241
  // on event=message
1002
- const messageNode = wsOnNodes.find(n => {
242
+ const messageNode = wsOnNodes.find((n) => {
1003
243
  const e = String(getProps(n).event || getProps(n).name || '');
1004
244
  return e === 'message';
1005
245
  });
1006
246
  lines.push('');
1007
247
  lines.push(` ws.on('message', (raw: Buffer) => {`);
1008
- 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(` }`);
1009
255
  if (messageNode) {
1010
256
  const handlerChild = getChildren(messageNode, 'handler')[0];
1011
257
  const code = handlerChild ? String(getProps(handlerChild).code || '') : '';
@@ -1017,7 +263,7 @@ export function transpileExpress(root, _config) {
1017
263
  }
1018
264
  lines.push(` });`);
1019
265
  // on event=error
1020
- const errorNode = wsOnNodes.find(n => {
266
+ const errorNode = wsOnNodes.find((n) => {
1021
267
  const e = String(getProps(n).event || getProps(n).name || '');
1022
268
  return e === 'error';
1023
269
  });
@@ -1034,7 +280,7 @@ export function transpileExpress(root, _config) {
1034
280
  lines.push(` });`);
1035
281
  }
1036
282
  // on event=disconnect/close
1037
- const closeNode = wsOnNodes.find(n => {
283
+ const closeNode = wsOnNodes.find((n) => {
1038
284
  const e = String(getProps(n).event || getProps(n).name || '');
1039
285
  return e === 'disconnect' || e === 'close';
1040
286
  });
@@ -1056,12 +302,37 @@ export function transpileExpress(root, _config) {
1056
302
  lines.push(`server.listen(port, () => {`);
1057
303
  lines.push(` console.log(\`\${serverName} listening on port \${port}\`);`);
1058
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'));`);
1059
320
  }
1060
321
  else {
1061
322
  lines.push('');
1062
- lines.push(`app.listen(port, () => {`);
323
+ lines.push(`const server = app.listen(port, () => {`);
1063
324
  lines.push(` console.log(\`\${serverName} listening on port \${port}\`);`);
1064
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'));`);
1065
336
  }
1066
337
  lines.push('');
1067
338
  lines.push('export default app;');
@@ -1071,14 +342,150 @@ export function transpileExpress(root, _config) {
1071
342
  outLine: 1,
1072
343
  outCol: 1,
1073
344
  });
345
+ // Build Prisma schema artifact from model nodes
346
+ const modelNodes = coreNodes.filter((n) => n.type === 'model');
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
+ }
1074
478
  const artifacts = [
1075
- ...routeArtifacts.map(route => route.artifact),
1076
- ...[...middlewareArtifacts.values()].map(entry => entry.artifact),
1077
- ...coreArtifactRefs.map(ref => ref.artifact),
479
+ ...routeArtifacts.map((route) => route.artifact),
480
+ ...[...middlewareArtifacts.values()].map((entry) => entry.artifact),
481
+ ...coreArtifactRefs.map((ref) => ref.artifact),
482
+ ...(prismaArtifact ? [prismaArtifact] : []),
483
+ ...(dbArtifact ? [dbArtifact] : []),
484
+ ...infraArtifacts,
1078
485
  ];
1079
486
  const output = lines.join('\n');
1080
487
  const irText = serializeIR(root);
1081
- const tsText = [output, ...artifacts.map(artifact => artifact.content)].join('\n');
488
+ const tsText = [output, ...artifacts.map((artifact) => artifact.content)].join('\n');
1082
489
  const irTokenCount = countTokens(irText);
1083
490
  const tsTokenCount = countTokens(tsText);
1084
491
  const tokenReduction = Math.round((1 - irTokenCount / tsTokenCount) * 100);