novac 2.2.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nvml CHANGED
@@ -15,11 +15,21 @@
15
15
  * nvml --version Show version
16
16
  */
17
17
 
18
- const fs = require('fs');
18
+ const fs = require('fs');
19
19
  const path = require('path');
20
20
  const http = require('http');
21
- const os = require('os');
22
- const url = require('url');
21
+ const os = require('os');
22
+ const url = require('url');
23
+ const crypto = require('crypto');
24
+
25
+ // ── CSRF token — one per server process, embedded in every compiled page ──
26
+ const CSRF_TOKEN = crypto.randomBytes(32).toString('hex');
27
+
28
+ // ── Named action registry — populated by parsing the NVML file ────────────
29
+ // { actionName → { code, type: 'nova'|'nodejs' } }
30
+ // Actions are registered when the file is first compiled (or re-compiled).
31
+ // The browser calls /_nvml/action/:name — no code ever travels over the wire.
32
+ const _actions = new Map();
23
33
 
24
34
  const chalk = (() => {
25
35
  try { return require('chalk').default ?? require('chalk'); } catch { return null; }
@@ -29,11 +39,11 @@ const VERSION = '1.0.0';
29
39
 
30
40
  // ── colour helpers (graceful fallback if chalk absent) ────────
31
41
  const c = {
32
- green: s => chalk ? chalk.green(s) : s,
33
- red: s => chalk ? chalk.red(s) : s,
34
- cyan: s => chalk ? chalk.cyan(s) : s,
35
- bold: s => chalk ? chalk.bold(s) : s,
36
- dim: s => chalk ? chalk.dim(s) : s,
42
+ green: s => chalk ? chalk.green(s) : s,
43
+ red: s => chalk ? chalk.red(s) : s,
44
+ cyan: s => chalk ? chalk.cyan(s) : s,
45
+ bold: s => chalk ? chalk.bold(s) : s,
46
+ dim: s => chalk ? chalk.dim(s) : s,
37
47
  yellow: s => chalk ? chalk.yellow(s) : s,
38
48
  };
39
49
 
@@ -112,11 +122,11 @@ function findElementById(elements, id) {
112
122
 
113
123
  function makeDocumentProxy(doc) {
114
124
  return {
115
- setConfig: (k, v) => { doc.config[k] = v; },
116
- getConfig: (k) => doc.config[k],
117
- setTitle: (t) => { doc.config.title = t; },
118
- setMeta: (k, v) => { doc.config[k] = v; },
119
- set: (id, content) => {
125
+ setConfig: (k, v) => { doc.config[k] = v; },
126
+ getConfig: (k) => doc.config[k],
127
+ setTitle: (t) => { doc.config.title = t; },
128
+ setMeta: (k, v) => { doc.config[k] = v; },
129
+ set: (id, content) => {
120
130
  const el = findElementById(doc.visual, id);
121
131
  if (el) el.textValue = String(content);
122
132
  },
@@ -155,15 +165,15 @@ function makeDocumentProxy(doc) {
155
165
  const el = findElementById(doc.visual, id);
156
166
  if (el) el.ss = (el.ss ? el.ss + '\n' : '') + `display: ${display || 'block'};`;
157
167
  },
158
- alert: (_msg) => {},
168
+ alert: (_msg) => { },
159
169
  config: doc.config,
160
- _doc: doc,
170
+ _doc: doc,
161
171
  };
162
172
  }
163
173
 
164
174
  // ── error page (served when compile fails during --serve) ─────
165
175
  function errorPage(file, err) {
166
- const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
176
+ const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
167
177
  return `<!DOCTYPE html>
168
178
  <html><head><meta charset="UTF-8"><title>NVML Error</title>
169
179
  <style>
@@ -184,7 +194,7 @@ function errorPage(file, err) {
184
194
 
185
195
  // ── serve ─────────────────────────────────────────────────────
186
196
  function cmdServe(port, nvmlFile, route) {
187
- const nvml = loadKit();
197
+ const nvml = loadKit();
188
198
  const absFile = path.resolve(process.cwd(), nvmlFile);
189
199
 
190
200
  if (!fs.existsSync(absFile)) die(`File not found: ${absFile}`);
@@ -199,233 +209,266 @@ function cmdServe(port, nvmlFile, route) {
199
209
  );
200
210
  }
201
211
 
202
- // Build a novaRunner for a given NvmlDocument + request context
203
- function makeNovaRunner(reqCtx) {
204
- if (!novaRun) return null;
205
- return (code, doc) => {
206
- const scope = {
207
- document: makeDocumentProxy(doc),
208
- request: reqCtx || {},
209
- };
210
- novaRun(code, scope);
211
- };
212
+ // ── registerAction called by renderer at compile time ─────────────────
213
+ // Stores the action's server code by name so the browser can call it by name only.
214
+ function registerAction(name, code, type) {
215
+ _actions.set(name, { code, type: type || 'nova' });
212
216
  }
213
217
 
214
- // Build a mutation-capturing proxy for /_nvml/run
215
- const _sseClients = new Set(); // Server-Sent Events connections
216
-
217
- function makeMutationProxy(mutations, live) {
218
- return {
219
- // config
220
- setConfig: (k, v) => mutations.push({ type: 'setConfig', key: String(k), value: v }),
221
- setTitle: (t) => mutations.push({ type: 'setConfig', key: 'title', value: t }),
222
- setMeta: (k, v) => mutations.push({ type: 'setConfig', key: String(k), value: v }),
223
- // text / html
224
- set: (id, val) => mutations.push({ type: 'setText', id: String(id), value: String(val) }),
225
- setHTML: (id, val) => mutations.push({ type: 'setHTML', id: String(id), value: String(val) }),
226
- // props / attrs
227
- setProp: (id, k, v) => mutations.push({ type: 'setProp', id: String(id), key: String(k), value: String(v) }),
228
- setAttr: (id, k, v) => mutations.push({ type: 'setAttr', id: String(id), key: String(k), value: String(v) }),
229
- removeAttr: (id, k) => mutations.push({ type: 'removeAttr', id: String(id), key: String(k) }),
230
- // classes
231
- addClass: (id, cls) => mutations.push({ type: 'addClass', id: String(id), value: String(cls) }),
232
- removeClass: (id, cls) => mutations.push({ type: 'removeClass', id: String(id), value: String(cls) }),
233
- toggleClass: (id, cls, f) => mutations.push({ type: 'toggleClass', id: String(id), value: String(cls), force: f }),
234
- setClass: (id, cls) => mutations.push({ type: 'setClass', id: String(id), value: String(cls) }),
235
- // style
236
- addStyle: (css) => mutations.push({ type: 'addStyle', value: String(css) }),
237
- addElementStyle:(id, css) => mutations.push({ type: 'addStyle', id: String(id), value: String(css) }),
238
- setStyle: (id, k, v) => mutations.push({ type: 'setStyle', id: String(id), key: String(k), value: String(v) }),
239
- setCSSVar: (name, val) => mutations.push({ type: 'setCSSVar', name: String(name), value: String(val) }),
240
- // visibility
241
- hide: (id, t) => mutations.push({ type: 'hide', id: String(id), transition: t || null }),
242
- show: (id, d, t) => mutations.push({ type: 'show', id: String(id), value: d || 'block', transition: t || null }),
243
- // DOM manipulation
244
- remove: (id, t) => mutations.push({ type: 'remove', id: String(id), transition: t || null }),
245
- appendChild: (id, html) => mutations.push({ type: 'appendChild', id: String(id), html: String(html) }),
246
- insertBefore: (id, html) => mutations.push({ type: 'insertBefore', id: String(id), html: String(html) }),
247
- insertAfter: (id, html) => mutations.push({ type: 'insertAfter', id: String(id), html: String(html) }),
248
- // focus / scroll
249
- focus: (id) => mutations.push({ type: 'focus', id: String(id) }),
250
- blur: (id) => mutations.push({ type: 'blur', id: String(id) }),
251
- scroll: (id, beh) => mutations.push({ type: 'scroll', id: String(id), behavior: beh || 'smooth' }),
252
- // signals
253
- setSignal: (name, val) => mutations.push({ type: 'setSignal', name: String(name), value: val }),
254
- // list patch
255
- patchList: (id, items, tpl, key) => mutations.push({ type: 'patchList', id: String(id), items, template: String(tpl), key: key || null }),
256
- // navigation
257
- navigate: (path) => mutations.push({ type: 'navigate', path: String(path) }),
258
- reload: () => mutations.push({ type: 'reload' }),
259
- redirect: (url) => mutations.push({ type: 'redirect', url: String(url) }),
260
- // feedback
261
- alert: (msg) => mutations.push({ type: 'alert', value: String(msg) }),
262
- toast: (msg, d, t) => mutations.push({ type: 'toast', value: String(msg), duration: d || 3000, type: t || 'info' }),
263
- console: (msg, lvl) => mutations.push({ type: 'console', value: String(msg), level: lvl || 'log' }),
264
- // reads from live snapshot
265
- get: (id) => (live.elements && live.elements[id]) ? live.elements[id].text : null,
266
- getValue: (id) => (live.elements && live.elements[id]) ? live.elements[id].value : null,
267
- getClass: (id) => (live.elements && live.elements[id]) ? live.elements[id].class : null,
268
- getConfig: (k) => live[k] ?? null,
269
- getSignal: (n) => (live.state && live.state[n]) ?? null,
270
- config: live,
271
- // SSE push (server → all connected clients)
272
- push: (name, val) => {
273
- const msg = `event: signal\ndata: ${JSON.stringify({ name, value: val })}\n\n`;
274
- _sseClients.forEach(c => { try { c.write(msg); } catch(_) {} });
275
- },
276
- pushMutations: (muts) => {
277
- const msg = `event: mutations\ndata: ${JSON.stringify(muts)}\n\n`;
278
- _sseClients.forEach(c => { try { c.write(msg); } catch(_) {} });
279
- },
280
- };
218
+ // ── Compile the NVML file (re-reads + re-registers actions on every request) ──
219
+ function compileFile(reqCtx) {
220
+ const source = fs.readFileSync(absFile, 'utf8');
221
+ return nvml.compile(source, {
222
+ novaRunner: makeNovaRunner(reqCtx),
223
+ novaEmitter: null,
224
+ registerAction: registerAction,
225
+ csrfToken: CSRF_TOKEN,
226
+ });
281
227
  }
228
+ if (!novaRun) return null;
229
+ return (code, doc) => {
230
+ const scope = {
231
+ document: makeDocumentProxy(doc),
232
+ request: reqCtx || {},
233
+ };
234
+ novaRun(code, scope);
235
+ };
236
+ }
282
237
 
283
- const server = http.createServer((req, res) => {
284
- const parsed = url.parse(req.url || '/');
285
- const urlPath = parsed.pathname || '/';
238
+ // Build a mutation-capturing proxy for /_nvml/run
239
+ const _sseClients = new Set(); // Server-Sent Events connections
286
240
 
287
- // CORS
288
- res.setHeader('Access-Control-Allow-Origin', '*');
289
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
290
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
291
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
292
-
293
- // ── /_nvml/sse — Server-Sent Events for server-push signal updates ──
294
- if (urlPath === '/_nvml/sse' && req.method === 'GET') {
295
- res.writeHead(200, {
296
- 'Content-Type': 'text/event-stream',
297
- 'Cache-Control': 'no-cache',
298
- 'Connection': 'keep-alive',
299
- 'X-Accel-Buffering': 'no',
300
- });
301
- res.write('retry: 3000\n\n'); // tell client to reconnect after 3s if disconnected
302
- _sseClients.add(res);
303
- req.on('close', () => _sseClients.delete(res));
241
+ function makeMutationProxy(mutations, live) {
242
+ return {
243
+ // config
244
+ setConfig: (k, v) => mutations.push({ type: 'setConfig', key: String(k), value: v }),
245
+ setTitle: (t) => mutations.push({ type: 'setConfig', key: 'title', value: t }),
246
+ setMeta: (k, v) => mutations.push({ type: 'setConfig', key: String(k), value: v }),
247
+ // text / html
248
+ set: (id, val) => mutations.push({ type: 'setText', id: String(id), value: String(val) }),
249
+ setHTML: (id, val) => mutations.push({ type: 'setHTML', id: String(id), value: String(val) }),
250
+ // props / attrs
251
+ setProp: (id, k, v) => mutations.push({ type: 'setProp', id: String(id), key: String(k), value: String(v) }),
252
+ setAttr: (id, k, v) => mutations.push({ type: 'setAttr', id: String(id), key: String(k), value: String(v) }),
253
+ removeAttr: (id, k) => mutations.push({ type: 'removeAttr', id: String(id), key: String(k) }),
254
+ // classes
255
+ addClass: (id, cls) => mutations.push({ type: 'addClass', id: String(id), value: String(cls) }),
256
+ removeClass: (id, cls) => mutations.push({ type: 'removeClass', id: String(id), value: String(cls) }),
257
+ toggleClass: (id, cls, f) => mutations.push({ type: 'toggleClass', id: String(id), value: String(cls), force: f }),
258
+ setClass: (id, cls) => mutations.push({ type: 'setClass', id: String(id), value: String(cls) }),
259
+ // style
260
+ addStyle: (css) => mutations.push({ type: 'addStyle', value: String(css) }),
261
+ addElementStyle: (id, css) => mutations.push({ type: 'addStyle', id: String(id), value: String(css) }),
262
+ setStyle: (id, k, v) => mutations.push({ type: 'setStyle', id: String(id), key: String(k), value: String(v) }),
263
+ setCSSVar: (name, val) => mutations.push({ type: 'setCSSVar', name: String(name), value: String(val) }),
264
+ // visibility
265
+ hide: (id, t) => mutations.push({ type: 'hide', id: String(id), transition: t || null }),
266
+ show: (id, d, t) => mutations.push({ type: 'show', id: String(id), value: d || 'block', transition: t || null }),
267
+ // DOM manipulation
268
+ remove: (id, t) => mutations.push({ type: 'remove', id: String(id), transition: t || null }),
269
+ appendChild: (id, html) => mutations.push({ type: 'appendChild', id: String(id), html: String(html) }),
270
+ insertBefore: (id, html) => mutations.push({ type: 'insertBefore', id: String(id), html: String(html) }),
271
+ insertAfter: (id, html) => mutations.push({ type: 'insertAfter', id: String(id), html: String(html) }),
272
+ // focus / scroll
273
+ focus: (id) => mutations.push({ type: 'focus', id: String(id) }),
274
+ blur: (id) => mutations.push({ type: 'blur', id: String(id) }),
275
+ scroll: (id, beh) => mutations.push({ type: 'scroll', id: String(id), behavior: beh || 'smooth' }),
276
+ // signals
277
+ setSignal: (name, val) => mutations.push({ type: 'setSignal', name: String(name), value: val }),
278
+ // list patch
279
+ patchList: (id, items, tpl, key) => mutations.push({ type: 'patchList', id: String(id), items, template: String(tpl), key: key || null }),
280
+ // navigation
281
+ navigate: (path) => mutations.push({ type: 'navigate', path: String(path) }),
282
+ reload: () => mutations.push({ type: 'reload' }),
283
+ redirect: (url) => mutations.push({ type: 'redirect', url: String(url) }),
284
+ // feedback
285
+ alert: (msg) => mutations.push({ type: 'alert', value: String(msg) }),
286
+ toast: (msg, d, t) => mutations.push({ type: 'toast', value: String(msg), duration: d || 3000, type: t || 'info' }),
287
+ console: (msg, lvl) => mutations.push({ type: 'console', value: String(msg), level: lvl || 'log' }),
288
+ // reads from live snapshot
289
+ get: (id) => (live.elements && live.elements[id]) ? live.elements[id].text : null,
290
+ getValue: (id) => (live.elements && live.elements[id]) ? live.elements[id].value : null,
291
+ getClass: (id) => (live.elements && live.elements[id]) ? live.elements[id].class : null,
292
+ getConfig: (k) => live[k] ?? null,
293
+ getSignal: (n) => (live.state && live.state[n]) ?? null,
294
+ config: live,
295
+ // SSE push (server → all connected clients)
296
+ push: (name, val) => {
297
+ const msg = `event: signal\ndata: ${JSON.stringify({ name, value: val })}\n\n`;
298
+ _sseClients.forEach(c => { try { c.write(msg); } catch (_) { } });
299
+ },
300
+ pushMutations: (muts) => {
301
+ const msg = `event: mutations\ndata: ${JSON.stringify(muts)}\n\n`;
302
+ _sseClients.forEach(c => { try { c.write(msg); } catch (_) { } });
303
+ },
304
+ };
305
+ }
306
+
307
+ const server = http.createServer((req, res) => {
308
+ const parsed = url.parse(req.url || '/');
309
+ const urlPath = parsed.pathname || '/';
310
+
311
+ // ── Localhost-only guard for all internal /_nvml/* endpoints ─────────
312
+ if (urlPath.startsWith('/_nvml/')) {
313
+ const ip = req.socket.remoteAddress;
314
+ if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') {
315
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
316
+ res.end('403 Forbidden');
304
317
  return;
305
318
  }
319
+ }
306
320
 
307
- // ── /_nvml/run triggered server-side Nova scripts ──────
308
- if (urlPath === '/_nvml/run' && req.method === 'POST') {
309
- let rawBody = '';
310
- req.on('data', chunk => { rawBody += chunk; });
311
- req.on('end', () => {
312
- let code, live;
313
- try {
314
- const body = JSON.parse(rawBody);
315
- code = String(body.code || '');
316
- live = body.live || {};
317
- } catch (e) {
318
- return sendJSON(res, 400, { error: 'Invalid JSON body' });
319
- }
321
+ // CORSopen for the main page, locked for internal endpoints
322
+ if (urlPath.startsWith('/_nvml/')) {
323
+ res.setHeader('Access-Control-Allow-Origin', `http://localhost:${port}`);
324
+ } else {
325
+ res.setHeader('Access-Control-Allow-Origin', '*');
326
+ }
327
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
328
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token');
329
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
320
330
 
321
- if (!code.trim()) return sendJSON(res, 200, { mutations: [] });
331
+ // ── CSRF validation helper ────────────────────────────────────────────
332
+ function validCSRF() {
333
+ return (req.headers['x-csrf-token'] || '') === CSRF_TOKEN;
334
+ }
322
335
 
323
- if (!novaRun) {
324
- return sendJSON(res, 503, { error: 'Nova runtime not available' });
325
- }
336
+ // ── /_nvml/sse — Server-Sent Events ──────────────────────────────────
337
+ if (urlPath === '/_nvml/sse' && req.method === 'GET') {
338
+ res.writeHead(200, {
339
+ 'Content-Type': 'text/event-stream',
340
+ 'Cache-Control': 'no-cache',
341
+ 'Connection': 'keep-alive',
342
+ 'X-Accel-Buffering': 'no',
343
+ });
344
+ res.write('retry: 3000\n\n');
345
+ _sseClients.add(res);
346
+ req.on('close', () => _sseClients.delete(res));
347
+ return;
348
+ }
326
349
 
327
- const mutations = [];
328
- const docProxy = makeMutationProxy(mutations, live);
329
- try {
330
- novaRun(code, { document: docProxy, request: live });
331
- return sendJSON(res, 200, { mutations });
332
- } catch (e) {
333
- process.stderr.write(c.red(`[nvml] /_nvml/run error: ${e.message}`) + '\n');
334
- return sendJSON(res, 500, { error: e.message, mutations });
335
- }
336
- });
337
- return;
338
- }
350
+ // ── /_nvml/action/:name — named server action dispatcher ─────────────
351
+ // This is the only server-execution endpoint. The browser calls it by
352
+ // action name (declared in NVML source), never sends executable code.
353
+ const actionMatch = urlPath.match(/^\/_nvml\/action\/([a-zA-Z0-9_-]+)$/);
354
+ if (actionMatch && req.method === 'POST') {
355
+ if (!validCSRF()) return sendJSON(res, 403, { error: 'Invalid CSRF token' });
339
356
 
340
- // ── main route ───────────────────────────────────────────
341
- if (urlPath !== route) {
342
- res.writeHead(404, { 'Content-Type': 'text/plain' });
343
- res.end(`404 — this server only handles: ${route}`);
344
- return;
345
- }
357
+ const actionName = actionMatch[1];
358
+ const action = _actions.get(actionName);
359
+ if (!action) return sendJSON(res, 404, { error: `Unknown action: ${actionName}` });
346
360
 
347
- let source;
348
- try { source = fs.readFileSync(absFile, 'utf8'); }
349
- catch (e) { res.writeHead(500); res.end(e.message); return; }
350
-
351
- // Build request context
352
- const reqContext = {
353
- method: req.method,
354
- url: req.url,
355
- path: urlPath,
356
- query: Object.fromEntries(new URLSearchParams(parsed.query || '')),
357
- headers: req.headers,
358
- params: {},
359
- body: null,
360
- };
361
+ let rawBody = '';
362
+ req.on('data', chunk => { rawBody += chunk; });
363
+ req.on('end', () => {
364
+ let live = {};
365
+ try { live = JSON.parse(rawBody).live || {}; } catch (_) { }
366
+
367
+ const mutations = [];
368
+ const docProxy = makeMutationProxy(mutations, live);
361
369
 
362
- const compileAndSend = () => {
363
- let html;
364
370
  try {
365
- html = nvml.compile(source, {
366
- novaRunner: makeNovaRunner(reqContext),
367
- novaEmitter: null,
368
- });
371
+ if (action.type === 'nova') {
372
+ if (!novaRun) return sendJSON(res, 503, { error: 'Nova runtime not available' });
373
+ novaRun(action.code, { document: docProxy, request: live });
374
+ } else if (action.type === 'nodejs') {
375
+ const vm = require('vm');
376
+ vm.runInNewContext(action.code, {
377
+ document: docProxy, request: live,
378
+ require, console, process,
379
+ }, { timeout: 5000 });
380
+ }
381
+ return sendJSON(res, 200, { mutations });
369
382
  } catch (e) {
370
- process.stderr.write(c.red(`[nvml] compile error: ${e.message}`) + '\n');
371
- const errHtml = errorPage(nvmlFile, e);
372
- res.writeHead(500, {
373
- 'Content-Type': 'text/html; charset=UTF-8',
374
- 'Content-Length': Buffer.byteLength(errHtml),
375
- });
376
- res.end(errHtml);
377
- return;
383
+ process.stderr.write(c.red(`[nvml] action "${actionName}" error: ${e.message}`) + '\n');
384
+ return sendJSON(res, 500, { error: e.message, mutations });
378
385
  }
386
+ });
387
+ return;
388
+ }
379
389
 
380
- res.writeHead(200, {
381
- 'Content-Type': 'text/html; charset=UTF-8',
382
- 'Content-Length': Buffer.byteLength(html),
383
- 'Cache-Control': 'no-store',
384
- 'X-Powered-By': `nvml/${VERSION}`,
385
- });
386
- res.end(html);
387
- process.stdout.write(c.dim(` ${req.method} ${urlPath} → 200`) + '\n');
388
- };
390
+ // ── main route ───────────────────────────────────────────
391
+ if (urlPath !== route) {
392
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
393
+ res.end(`404 — this server only handles: ${route}`);
394
+ return;
395
+ }
389
396
 
390
- // Collect body for POST/PUT/PATCH before compiling
391
- if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
392
- let rawBody = '';
393
- req.on('data', chunk => { rawBody += chunk; });
394
- req.on('end', () => {
395
- try { reqContext.body = JSON.parse(rawBody); } catch { reqContext.body = rawBody; }
396
- compileAndSend();
397
+ // Build request context
398
+ const reqContext = {
399
+ method: req.method,
400
+ url: req.url,
401
+ path: urlPath,
402
+ query: Object.fromEntries(new URLSearchParams(parsed.query || '')),
403
+ headers: req.headers,
404
+ params: {},
405
+ body: null,
406
+ };
407
+
408
+ const compileAndSend = () => {
409
+ let html;
410
+ try {
411
+ html = compileFile(reqContext);
412
+ } catch (e) {
413
+ process.stderr.write(c.red(`[nvml] compile error: ${e.message}`) + '\n');
414
+ const errHtml = errorPage(nvmlFile, e);
415
+ res.writeHead(500, {
416
+ 'Content-Type': 'text/html; charset=UTF-8',
417
+ 'Content-Length': Buffer.byteLength(errHtml),
397
418
  });
398
- } else {
399
- compileAndSend();
419
+ res.end(errHtml);
420
+ return;
400
421
  }
401
- });
402
422
 
403
- server.on('error', e => {
404
- if (e.code === 'EADDRINUSE') die(`Port ${port} is already in use.`);
405
- die(`Server error: ${e.message}`);
406
- });
423
+ res.writeHead(200, {
424
+ 'Content-Type': 'text/html; charset=UTF-8',
425
+ 'Content-Length': Buffer.byteLength(html),
426
+ 'Cache-Control': 'no-store',
427
+ 'X-Powered-By': `nvml/${VERSION}`,
428
+ });
429
+ res.end(html);
430
+ process.stdout.write(c.dim(` ${req.method} ${urlPath} → 200`) + '\n');
431
+ };
407
432
 
408
- server.listen(port, '127.0.0.1', () => {
409
- process.stdout.write(
410
- c.green('\n nvml server running') + '\n' +
411
- c.cyan(` http://localhost:${port}${route}`) + '\n\n' +
412
- c.dim(` file : ${absFile}`) + '\n' +
413
- c.dim(` route : ${route}`) + '\n' +
414
- c.dim(` reload : automatic (re-reads file on every request)`) + '\n\n' +
415
- ` Press ${c.bold('Ctrl+C')} to stop.\n\n`
416
- );
417
- });
433
+ // Collect body for POST/PUT/PATCH before compiling
434
+ if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
435
+ let rawBody = '';
436
+ req.on('data', chunk => { rawBody += chunk; });
437
+ req.on('end', () => {
438
+ try { reqContext.body = JSON.parse(rawBody); } catch { reqContext.body = rawBody; }
439
+ compileAndSend();
440
+ });
441
+ } else {
442
+ compileAndSend();
443
+ }
444
+ });
445
+
446
+ server.on('error', e => {
447
+ if (e.code === 'EADDRINUSE') die(`Port ${port} is already in use.`);
448
+ die(`Server error: ${e.message}`);
449
+ });
450
+
451
+ server.listen(port, '127.0.0.1', () => {
452
+ process.stdout.write(
453
+ c.green('\n nvml server running') + '\n' +
454
+ c.cyan(` http://localhost:${port}${route}`) + '\n\n' +
455
+ c.dim(` file : ${absFile}`) + '\n' +
456
+ c.dim(` route : ${route}`) + '\n' +
457
+ c.dim(` reload : automatic (re-reads file on every request)`) + '\n\n' +
458
+ ` Press ${c.bold('Ctrl+C')} to stop.\n\n`
459
+ );
460
+ });
461
+
462
+ process.on('SIGINT', () => { process.stdout.write('\n'); process.exit(0); });
463
+ process.on('SIGTERM', () => process.exit(0));
418
464
 
419
- process.on('SIGINT', () => { process.stdout.write('\n'); process.exit(0); });
420
- process.on('SIGTERM', () => process.exit(0));
421
- }
422
465
 
423
466
  // ── helpers ───────────────────────────────────────────────────
424
467
  function sendJSON(res, code, obj) {
425
468
  const body = JSON.stringify(obj);
426
469
  res.writeHead(code, {
427
- 'Content-Type': 'application/json; charset=UTF-8',
428
- 'Content-Length': Buffer.byteLength(body),
470
+ 'Content-Type': 'application/json; charset=UTF-8',
471
+ 'Content-Length': Buffer.byteLength(body),
429
472
  'Access-Control-Allow-Origin': '*',
430
473
  });
431
474
  res.end(body);
@@ -433,7 +476,7 @@ function sendJSON(res, code, obj) {
433
476
 
434
477
  // ── compile ───────────────────────────────────────────────────
435
478
  function cmdCompile(nvmlFile) {
436
- const nvml = loadKit();
479
+ const nvml = loadKit();
437
480
  const absFile = path.resolve(process.cwd(), nvmlFile);
438
481
  if (!fs.existsSync(absFile)) die(`File not found: ${absFile}`);
439
482
  const novaRun = resolveNovaRun();
@@ -449,7 +492,7 @@ function cmdCompile(nvmlFile) {
449
492
 
450
493
  // ── ast ───────────────────────────────────────────────────────
451
494
  function cmdAst(nvmlFile) {
452
- const nvml = loadKit();
495
+ const nvml = loadKit();
453
496
  const absFile = path.resolve(process.cwd(), nvmlFile);
454
497
  if (!fs.existsSync(absFile)) die(`File not found: ${absFile}`);
455
498
  try {
@@ -462,7 +505,7 @@ function cmdAst(nvmlFile) {
462
505
  // ── tokens ────────────────────────────────────────────────────
463
506
  function cmdTokens(nvmlFile) {
464
507
  const { Lexer } = loadLexer();
465
- const absFile = path.resolve(process.cwd(), nvmlFile);
508
+ const absFile = path.resolve(process.cwd(), nvmlFile);
466
509
  if (!fs.existsSync(absFile)) die(`File not found: ${absFile}`);
467
510
  try {
468
511
  process.stdout.write(JSON.stringify(new Lexer(fs.readFileSync(absFile, 'utf8')).tokenize(), null, 2) + '\n');
@@ -473,7 +516,7 @@ function cmdTokens(nvmlFile) {
473
516
 
474
517
  // ── check ─────────────────────────────────────────────────────
475
518
  function cmdCheck(nvmlFile) {
476
- const nvml = loadKit();
519
+ const nvml = loadKit();
477
520
  const absFile = path.resolve(process.cwd(), nvmlFile);
478
521
  if (!fs.existsSync(absFile)) die(`File not found: ${absFile}`);
479
522
  try {
@@ -527,13 +570,13 @@ if (args[0] === '--version' || args[0] === '-v') {
527
570
  // subcommands
528
571
  switch (args[0]) {
529
572
  case 'compile': if (!args[1]) die('Usage: nvml compile <file.nvml>'); cmdCompile(args[1]); break;
530
- case 'ast': if (!args[1]) die('Usage: nvml ast <file.nvml>'); cmdAst(args[1]); break;
531
- case 'tokens': if (!args[1]) die('Usage: nvml tokens <file.nvml>'); cmdTokens(args[1]); break;
532
- case 'check': if (!args[1]) die('Usage: nvml check <file.nvml>'); cmdCheck(args[1]); break;
573
+ case 'ast': if (!args[1]) die('Usage: nvml ast <file.nvml>'); cmdAst(args[1]); break;
574
+ case 'tokens': if (!args[1]) die('Usage: nvml tokens <file.nvml>'); cmdTokens(args[1]); break;
575
+ case 'check': if (!args[1]) die('Usage: nvml check <file.nvml>'); cmdCheck(args[1]); break;
533
576
  default: {
534
577
  // nvml <port> <file> [route]
535
- const port = parseInt(args[0], 10);
536
- const file = args[1];
578
+ const port = parseInt(args[0], 10);
579
+ const file = args[1];
537
580
  const route = args[2] || '/';
538
581
  if (!port || port < 1 || port > 65535) die(`Invalid port: "${args[0]}"\nUsage: nvml <port> <file.nvml>`);
539
582
  if (!file) die('Usage: nvml <port> <file.nvml> [route]');
package/bin/nvml.cmd ADDED
@@ -0,0 +1,2 @@
1
+ @echo off
2
+ node "%~dp0nvml" %*