4bnode 4.2.0 → 4.2.1

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/lib/codegen.js CHANGED
@@ -853,7 +853,6 @@ export const PACKAGE_VERSIONS = {
853
853
  mongoose: "^8.8.0",
854
854
  multer: "^1.4.5-lts.1",
855
855
  nodemailer: "^6.9.0",
856
- resend: "^4.0.0",
857
856
  "bonjour-service": "^1.4.3",
858
857
  };
859
858
 
@@ -955,7 +954,7 @@ function openApiType(t) {
955
954
  // Build an OpenAPI 3.0 document from parsed endpoints + models.
956
955
  // endpoints: [{ method, path, fullPath, bodyFields[], queryFields[], params[], fileFields[], hasAuth }]
957
956
  // models: [{ name, fields:[{name,type,required}] }]
958
- export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints = [], models = [] } = {}) {
957
+ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints = [], models = [], apiKey = false } = {}) {
959
958
  const paths = {};
960
959
  for (const ep of endpoints) {
961
960
  const raw = ep.fullPath || ep.path || "/";
@@ -997,7 +996,18 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
997
996
  const components = { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } };
998
997
  if (Object.keys(schemas).length) components.schemas = schemas;
999
998
 
1000
- return { openapi: "3.0.0", info: { title, version }, servers: [{ url: "/" }], paths, components };
999
+ const spec = { openapi: "3.0.0", info: { title, version }, servers: [{ url: "/" }], paths, components };
1000
+ if (apiKey) {
1001
+ // App-wide gate: every operation needs x-api-key; JWT routes need both.
1002
+ components.securitySchemes.apiKeyAuth = { type: "apiKey", in: "header", name: "x-api-key" };
1003
+ spec.security = [{ apiKeyAuth: [] }];
1004
+ for (const ops of Object.values(paths)) {
1005
+ for (const op of Object.values(ops)) {
1006
+ if (op.security) op.security = [{ apiKeyAuth: [], bearerAuth: [] }];
1007
+ }
1008
+ }
1009
+ }
1010
+ return spec;
1001
1011
  }
1002
1012
 
1003
1013
  // A self-contained, modern API documentation page rendered from the spec.
@@ -1098,6 +1108,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1098
1108
  var SPEC_URL='${specUrl}';
1099
1109
  var COLORS={get:'#0079ff',post:'#22c55e',put:'#f59e0b',patch:'#a855f7',delete:'#ef4444',head:'#64748b',options:'#64748b'};
1100
1110
  var uid=0; var ITEMS=[];
1111
+ var APIKEY=false; // set from the spec in render()
1101
1112
  function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
1102
1113
  function typeName(sc){if(!sc)return'';if(sc.format==='binary')return'file';if(sc.$ref)return sc.$ref.split('/').pop();return sc.type||'string';}
1103
1114
  function paramsOf(op,where){return (op.parameters||[]).filter(function(p){return p.in===where;});}
@@ -1122,10 +1133,15 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1122
1133
  });
1123
1134
  return out;
1124
1135
  }
1136
+ // Auth headers a sample needs: x-api-key when the app-wide gate is on (spec
1137
+ // top-level security), plus a Bearer token for routes behind the JWT guard.
1138
+ function hasBearer(e){return !!(e.op.security&&e.op.security.some(function(s){return s.bearerAuth;}));}
1139
+ function authPairs(e){var p=[];if(APIKEY)p.push("'x-api-key': '<your-api-key>'");if(hasBearer(e))p.push("'Authorization': 'Bearer <token>'");return p;}
1125
1140
  function curlSample(e){
1126
1141
  var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];
1127
1142
  L.push("curl -X "+e.method.toUpperCase()+" '"+sampleUrl(e)+"'");
1128
- if(e.op.security)L.push("-H 'Authorization: Bearer <token>'");
1143
+ if(APIKEY)L.push("-H 'x-api-key: <your-api-key>'");
1144
+ if(hasBearer(e))L.push("-H 'Authorization: Bearer <token>'");
1129
1145
  if(b&&isMultipart(b)){
1130
1146
  // curl sets the multipart Content-Type (with boundary) itself; -F per field.
1131
1147
  fieldList(b).forEach(function(f){L.push("-F '"+f.name+"="+(f.file?"@/path/to/file":"")+"'");});
@@ -1144,7 +1160,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1144
1160
  var h=[];
1145
1161
  // For multipart, never set Content-Type by hand — the browser adds the boundary.
1146
1162
  if(b&&!mp)h.push(" 'Content-Type': 'application/json'");
1147
- if(e.op.security)h.push(" 'Authorization': 'Bearer <token>'");
1163
+ authPairs(e).forEach(function(x){h.push(" "+x);});
1148
1164
  if(h.length){L.push(" headers: {");L.push(h.join(","+NL));L.push(" },");}
1149
1165
  if(mp)L.push(" body: form");
1150
1166
  else if(b)L.push(" body: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+")");
@@ -1159,7 +1175,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1159
1175
  L.push("const res = await axios({");
1160
1176
  L.push(" method: '"+e.method.toLowerCase()+"',");
1161
1177
  L.push(" url: '"+sampleUrl(e)+"',");
1162
- if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
1178
+ if(authPairs(e).length)L.push(" headers: { "+authPairs(e).join(", ")+" },");
1163
1179
  if(mp)L.push(" data: form");
1164
1180
  else if(b)L.push(" data: "+indentBody(bodySkeleton(b.schema)," "));
1165
1181
  L.push("});");
@@ -1174,7 +1190,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1174
1190
  L.push(" method: '"+e.method.toUpperCase()+"',");
1175
1191
  if(mp){L.push(" data: form,");L.push(" processData: false,");L.push(" contentType: false,");}
1176
1192
  else if(b)L.push(" contentType: 'application/json',");
1177
- if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
1193
+ if(authPairs(e).length)L.push(" headers: { "+authPairs(e).join(", ")+" },");
1178
1194
  if(b&&!mp)L.push(" data: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+"),");
1179
1195
  L.push(" success: function(data) {");
1180
1196
  L.push(" console.log(data);");
@@ -1258,6 +1274,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1258
1274
  }
1259
1275
 
1260
1276
  function render(spec){
1277
+ APIKEY=!!(spec.security&&spec.security.some(function(s){return s.apiKeyAuth;}));
1261
1278
  document.getElementById('apiTitle').textContent=(spec.info&&spec.info.title)||'API';
1262
1279
  document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');
1263
1280
  ITEMS=collect(spec);
@@ -1462,12 +1479,27 @@ export default router;
1462
1479
  }
1463
1480
 
1464
1481
  // ─────────────────────────────────────────────────────────────────────────────
1465
- // Email — two backends: IntraApp(Postmaster) via the Resend API, or any SMTP
1466
- // server via nodemailer. `type` selects the code path; `apiKeyOnly` tells the
1467
- // dashboard to show a single API-key field (no host/port/user).
1482
+ // Email — two backends: Sancharak via its HTTP API, or any SMTP server via
1483
+ // nodemailer. `type` selects the code path; `apiKeyOnly` tells the dashboard to
1484
+ // show a single API-key field (no host/port/user).
1468
1485
  // ─────────────────────────────────────────────────────────────────────────────
1486
+ export const SANCHARAK_API_URL = "https://api.sancharak.com/v1/emails";
1487
+
1488
+ // Why a Sancharak API key can't be right, or null if it looks fine. Catches the
1489
+ // common paste mistake of copying a key shown shortened (e.g. "snk_UwtPIq-5…"),
1490
+ // which otherwise fails deep inside fetch with a cryptic ByteString error.
1491
+ export function sancharakKeyProblem(key) {
1492
+ const k = String(key || "").trim();
1493
+ if (!k) return "A Sancharak API key is required.";
1494
+ if (/[^!-~]/.test(k)) return k.includes("\u2026")
1495
+ ? 'That key contains "…" — it looks shortened. Copy the full key from Sancharak (use its Copy button), not the abbreviated one shown in a list.'
1496
+ : "That key contains spaces or non-ASCII characters. Copy the key again exactly as Sancharak shows it.";
1497
+ if (!k.startsWith("snk_")) return 'A Sancharak API key starts with "snk_".';
1498
+ if (k.length < 24) return "That key is too short (" + k.length + " characters) — it may be cut off. Copy the full key from Sancharak.";
1499
+ return null;
1500
+ }
1469
1501
  export const MAIL_PROVIDERS = {
1470
- resend: { label: "IntraApp(Postmaster)", type: "resend", apiKeyOnly: true, hint: "Paste your API key (re_...). Sends via the Resend API — no SMTP host or port needed." },
1502
+ sancharak: { label: "Sancharak", type: "sancharak", apiKeyOnly: true, hint: "Paste your Sancharak API key (snk_...). Sends over the Sancharak API — no SMTP host or port, no extra package." },
1471
1503
  smtp: { label: "Custom SMTP", type: "smtp", host: "", port: 587, secure: false, hint: "Any SMTP server. Tick TLS for port 465, otherwise 587." },
1472
1504
  };
1473
1505
 
@@ -1486,20 +1518,54 @@ export function isOfficialMailDomain(email) {
1486
1518
  // is identical regardless of backend, so calling code never changes.
1487
1519
  export function buildMailerService() {
1488
1520
  return `// Unified mailer. Backend is chosen by MAIL_PROVIDER in .env:
1489
- // - "resend": IntraApp(Postmaster), via the Resend API (RESEND_API_KEY, MAIL_FROM)
1490
- // - "smtp": any SMTP server, via nodemailer (SMTP_* vars)
1491
- // Only the selected backend's package is imported (lazily), so you don't need
1492
- // both installed. sendMail({ to, subject, html }) works the same either way.
1521
+ // - "sancharak": the Sancharak HTTP API (SANCHARAK_API_KEY, MAIL_FROM, MAIL_REPLY_TO)
1522
+ // - "smtp": any SMTP server, via nodemailer (SMTP_* vars)
1523
+ // Sancharak needs no package (it uses fetch); nodemailer is imported lazily only
1524
+ // for SMTP. sendMail({ to, subject, html }) works the same either way.
1525
+ import crypto from 'crypto';
1526
+
1493
1527
  const PROVIDER = (process.env.MAIL_PROVIDER || 'smtp').toLowerCase();
1528
+ const SANCHARAK_URL = process.env.SANCHARAK_API_URL || '${SANCHARAK_API_URL}';
1529
+
1530
+ const asList = (v) => (v == null || v === '' ? undefined : Array.isArray(v) ? v : String(v).split(',').map((x) => x.trim()).filter(Boolean));
1531
+
1532
+ // POST one email to Sancharak. Each call gets its own Idempotency-Key, so a
1533
+ // retried request can never send the same email twice.
1534
+ async function sancharakSend({ from, to, replyTo, subject, text, html, idempotencyKey }) {
1535
+ const key = String(process.env.SANCHARAK_API_KEY || '').trim();
1536
+ if (!key) throw new Error('SANCHARAK_API_KEY is not set.');
1537
+ // Header values must be plain ASCII; a key pasted in shortened form ("snk_…")
1538
+ // would otherwise fail inside fetch with an unreadable ByteString error.
1539
+ if (/[^!-~]/.test(key) || key.length < 24) throw new Error('SANCHARAK_API_KEY looks shortened or contains invalid characters. Copy the full key from Sancharak.');
1540
+ const body = { from, to: asList(to), subject, stream: 'tx' };
1541
+ if (replyTo) body.reply_to = asList(replyTo);
1542
+ if (text) body.text = text;
1543
+ if (html) body.html = html;
1544
+ const res = await fetch(SANCHARAK_URL, {
1545
+ method: 'POST',
1546
+ headers: {
1547
+ Authorization: 'Bearer ' + key,
1548
+ 'Content-Type': 'application/json',
1549
+ 'Idempotency-Key': idempotencyKey || crypto.randomUUID(),
1550
+ },
1551
+ body: JSON.stringify(body),
1552
+ signal: AbortSignal.timeout(15000),
1553
+ });
1554
+ const raw = await res.text();
1555
+ let data = null;
1556
+ try { data = raw ? JSON.parse(raw) : null; } catch { data = { raw }; }
1557
+ if (!res.ok) {
1558
+ const msg = (data && (data.message || data.error || (data.error && data.error.message))) || raw || res.statusText;
1559
+ throw new Error('Sancharak ' + res.status + ': ' + (typeof msg === 'string' ? msg : JSON.stringify(msg)));
1560
+ }
1561
+ return data;
1562
+ }
1494
1563
 
1495
1564
  let _clientPromise = null;
1496
1565
  function getClient() {
1497
1566
  if (_clientPromise) return _clientPromise;
1498
1567
  _clientPromise = (async () => {
1499
- if (PROVIDER === 'resend') {
1500
- const { Resend } = await import('resend');
1501
- return { type: 'resend', resend: new Resend(process.env.RESEND_API_KEY) };
1502
- }
1568
+ if (PROVIDER === 'sancharak') return { type: 'sancharak' };
1503
1569
  const nodemailer = (await import('nodemailer')).default;
1504
1570
  return {
1505
1571
  type: 'smtp',
@@ -1534,24 +1600,31 @@ export function emailTemplate({ title = '', body = '' } = {}) {
1534
1600
 
1535
1601
  // Send an email. Pass html or text (or use emailTemplate for html).
1536
1602
  // attachments: [{ filename, path }] or [{ filename, content }].
1537
- export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
1603
+ // idempotencyKey (Sancharak only): pass your own to make retries safe across restarts.
1604
+ export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments, idempotencyKey } = {}) {
1538
1605
  if (!to || !subject) throw new Error('sendMail requires { to, subject }');
1539
1606
  // 4brains.in is an official domain. Sending from it is only allowed once it has
1540
1607
  // been configured through the 4bnode dashboard/CLI with the authorization
1541
1608
  // password (which sets OFFICIAL_MAIL_AUTHORIZED). Direct/code use is blocked.
1542
- const effectiveFrom = from || (PROVIDER === 'resend' ? process.env.MAIL_FROM : (process.env.SMTP_FROM || process.env.SMTP_USER));
1609
+ const effectiveFrom = from || (PROVIDER === 'sancharak' ? process.env.MAIL_FROM : (process.env.SMTP_FROM || process.env.SMTP_USER));
1543
1610
  const touchesOfficial = /4brains\\.in/i.test(String(effectiveFrom || '')) || (PROVIDER === 'smtp' && /4brains\\.in/i.test(String(process.env.SMTP_HOST || '')));
1544
1611
  if (touchesOfficial && !process.env.OFFICIAL_MAIL_AUTHORIZED) {
1545
1612
  throw new Error('The 4brains.in domain is official and cannot be used directly — configure it via the 4bnode dashboard (Email) with the authorization password.');
1546
1613
  }
1547
1614
  const client = await getClient();
1548
- if (client.type === 'resend') {
1549
- const { data, error } = await client.resend.emails.send({
1550
- from: from || process.env.MAIL_FROM,
1551
- to, cc, bcc, replyTo, subject, html, text, attachments,
1615
+ if (client.type === 'sancharak') {
1616
+ if (cc || bcc || (attachments && attachments.length)) {
1617
+ throw new Error('cc, bcc and attachments are not supported by the Sancharak backend yet. Send separate emails, or use SMTP.');
1618
+ }
1619
+ return sancharakSend({
1620
+ from: effectiveFrom,
1621
+ to,
1622
+ replyTo: replyTo || process.env.MAIL_REPLY_TO,
1623
+ subject,
1624
+ text,
1625
+ html,
1626
+ idempotencyKey,
1552
1627
  });
1553
- if (error) throw new Error(error.message || 'Resend send failed');
1554
- return data;
1555
1628
  }
1556
1629
  return client.transporter.sendMail({
1557
1630
  from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
@@ -1559,11 +1632,13 @@ export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo
1559
1632
  });
1560
1633
  }
1561
1634
 
1562
- // Verify the mailer is usable (SMTP handshake, or that the Resend key is set).
1635
+ // Verify the mailer is usable (SMTP handshake, or that the Sancharak key and
1636
+ // From address are set).
1563
1637
  export async function verifyMailer() {
1564
1638
  const client = await getClient();
1565
- if (client.type === 'resend') {
1566
- if (!process.env.RESEND_API_KEY) throw new Error('RESEND_API_KEY is not set.');
1639
+ if (client.type === 'sancharak') {
1640
+ if (!process.env.SANCHARAK_API_KEY) throw new Error('SANCHARAK_API_KEY is not set.');
1641
+ if (!process.env.MAIL_FROM) throw new Error('MAIL_FROM is not set.');
1567
1642
  return true;
1568
1643
  }
1569
1644
  return client.transporter.verify();
@@ -1611,13 +1686,23 @@ export function buildDiscoveryModule() {
1611
1686
  // - Clean shutdown sends TTL=0 "goodbye" packets so stale records do not linger
1612
1687
  // in client caches for the record's full TTL (can be over an hour).
1613
1688
  //
1614
- // Configure via .env: BONJOUR_ENABLED=off | BONJOUR_NAME=... | BONJOUR_TYPE=http
1689
+ // - OFF by default. Advertising only starts with BONJOUR_ENABLED=on.
1690
+ // - On a machine with several networks (e.g. Wi-Fi AND Ethernet), mDNS is bound
1691
+ // to ONE chosen interface (BONJOUR_INTERFACE=en0) and only that network's
1692
+ // addresses are advertised. Otherwise a phone on Wi-Fi can be handed the
1693
+ // Ethernet IP it cannot reach. The interface NAME is stored, not its IP, so a
1694
+ // new DHCP lease does not break it.
1695
+ //
1696
+ // Configure via .env:
1697
+ // BONJOUR_ENABLED=on | BONJOUR_NAME=... | BONJOUR_TYPE=http | BONJOUR_INTERFACE=en0
1615
1698
 
1616
1699
  import os from "os";
1700
+ import { execFileSync } from "child_process";
1617
1701
 
1618
1702
  let bonjour = null; // shared mDNS controller (created lazily, one per process)
1619
1703
  let published = null; // handle to the currently-advertised service, if any
1620
1704
  let BonjourCtor = null; // cached constructor after the first successful import
1705
+ let boundInterface = null; // interface name the current controller is bound to ("" = all)
1621
1706
 
1622
1707
  let selfState = {
1623
1708
  status: "idle", // idle | advertising | error | unsupported
@@ -1627,6 +1712,8 @@ let selfState = {
1627
1712
  host: null,
1628
1713
  txt: null,
1629
1714
  error: null,
1715
+ interface: null, // { name, label, address } actually used, or null for all
1716
+ warning: null,
1630
1717
  };
1631
1718
 
1632
1719
  // Import bonjour-service on demand. Returns the constructor, or null if the
@@ -1664,9 +1751,89 @@ function defaultServiceName() {
1664
1751
  return host ? base + " (" + host + ")" : base;
1665
1752
  }
1666
1753
 
1667
- // On by default; opt out with BONJOUR_ENABLED=off.
1754
+ // Off by default; opt in with BONJOUR_ENABLED=on.
1668
1755
  export function isDiscoveryEnabled() {
1669
- return process.env.BONJOUR_ENABLED !== "off";
1756
+ return process.env.BONJOUR_ENABLED === "on";
1757
+ }
1758
+
1759
+ // Virtual / tunnel / container interfaces that never reach other LAN devices.
1760
+ const VIRTUAL_IFACE = /^(lo|utun|awdl|llw|gif|stf|anpi|ap|bridge|vmnet|vboxnet|docker|br-|veth|virbr|tun|tap|zt|tailscale|wg)/i;
1761
+
1762
+ // macOS: map device names (en0) to their hardware port ("Wi-Fi", "Ethernet").
1763
+ let macPorts = null;
1764
+ function macHardwarePorts() {
1765
+ if (macPorts) return macPorts;
1766
+ macPorts = {};
1767
+ try {
1768
+ const out = execFileSync("networksetup", ["-listallhardwareports"], { encoding: "utf8", timeout: 3000 });
1769
+ let current = null;
1770
+ for (const raw of out.split(String.fromCharCode(10))) {
1771
+ const line = raw.trim(); // trim also drops a Windows CR
1772
+ const port = line.match(/^Hardware Port: (.+)$/);
1773
+ if (port) current = port[1].trim();
1774
+ const dev = line.match(/^Device: (.+)$/);
1775
+ if (dev && current) macPorts[dev[1].trim()] = current;
1776
+ }
1777
+ } catch {}
1778
+ return macPorts;
1779
+ }
1780
+
1781
+ function interfaceKind(name) {
1782
+ if (os.platform() === "darwin") {
1783
+ const hw = macHardwarePorts()[name];
1784
+ if (hw) return /wi-?fi|airport/i.test(hw) ? "Wi-Fi" : hw;
1785
+ }
1786
+ if (/^(wl|wlan|wifi)/i.test(name) || /wi-?fi|wireless|wlan/i.test(name)) return "Wi-Fi";
1787
+ if (/^(eth|en|em|eno|ens|enp)/i.test(name) || /ethernet|lan/i.test(name)) return "Ethernet";
1788
+ return "Network";
1789
+ }
1790
+
1791
+ // Physical networks this machine is connected to, each with its IPv4 address.
1792
+ // Used by the dashboard / CLI to let the user choose which one to advertise on.
1793
+ export function listNetworkInterfaces() {
1794
+ const all = os.networkInterfaces();
1795
+ const result = [];
1796
+ for (const name of Object.keys(all)) {
1797
+ if (VIRTUAL_IFACE.test(name)) continue;
1798
+ const addrs = all[name] || [];
1799
+ const v4 = addrs.find((a) => a.family === "IPv4" && !a.internal);
1800
+ if (!v4) continue;
1801
+ const kind = interfaceKind(name);
1802
+ result.push({
1803
+ name,
1804
+ kind,
1805
+ label: kind + " (" + name + ")",
1806
+ address: v4.address,
1807
+ // 169.254.x.x = self-assigned: the cable/adapter is up but no network answered.
1808
+ selfAssigned: v4.address.startsWith("169.254."),
1809
+ ipv6: addrs.filter((a) => a.family === "IPv6" && !a.internal).map((a) => a.address),
1810
+ mac: v4.mac,
1811
+ });
1812
+ }
1813
+ return result;
1814
+ }
1815
+
1816
+ // Resolve BONJOUR_INTERFACE to a live interface. Returns { iface, warning }:
1817
+ // iface null means "all interfaces".
1818
+ function resolveInterface() {
1819
+ const wanted = (process.env.BONJOUR_INTERFACE || "").trim();
1820
+ const list = listNetworkInterfaces();
1821
+ if (wanted) {
1822
+ const hit = list.find((i) => i.name === wanted);
1823
+ if (hit) return { iface: hit, warning: null };
1824
+ return {
1825
+ iface: null,
1826
+ warning: "Network " + JSON.stringify(wanted) + " is not connected; advertising on all networks instead.",
1827
+ };
1828
+ }
1829
+ if (list.length > 1) {
1830
+ return {
1831
+ iface: null,
1832
+ warning: "Several networks are connected (" + list.map((i) => i.label).join(", ") +
1833
+ "). Advertising on all of them; choose one with BONJOUR_INTERFACE or in the dashboard.",
1834
+ };
1835
+ }
1836
+ return { iface: null, warning: null };
1670
1837
  }
1671
1838
 
1672
1839
  // Snapshot of what this process is currently advertising (read by the dashboard).
@@ -1674,6 +1841,29 @@ export function getSelfState() {
1674
1841
  return { ...selfState, enabled: isDiscoveryEnabled() };
1675
1842
  }
1676
1843
 
1844
+ // (Re)create the mDNS controller bound to the chosen interface. Binding the
1845
+ // socket to 0.0.0.0 while pinning multicast to the interface IP keeps receiving
1846
+ // queries reliable on macOS/Linux.
1847
+ function ensureController(Ctor, iface) {
1848
+ const key = iface ? iface.name : "";
1849
+ if (bonjour && boundInterface === key) return;
1850
+ if (bonjour) {
1851
+ try { bonjour.destroy(); } catch {}
1852
+ }
1853
+ bonjour = iface ? new Ctor({ interface: iface.address, bind: "0.0.0.0" }) : new Ctor();
1854
+ boundInterface = key;
1855
+ }
1856
+
1857
+ // Keep only the chosen interface's addresses in A/AAAA records, so clients on
1858
+ // that network are never handed an IP from another network.
1859
+ function restrictAddresses(service, iface) {
1860
+ if (!iface || typeof service.records !== "function") return;
1861
+ const allowed = new Set([iface.address].concat(iface.ipv6));
1862
+ const original = service.records.bind(service);
1863
+ service.records = () =>
1864
+ original().filter((r) => (r.type !== "A" && r.type !== "AAAA") || allowed.has(r.data));
1865
+ }
1866
+
1677
1867
  // Advertise this app on the local network. Safe to call repeatedly; each call
1678
1868
  // replaces any prior advertisement, so a port change re-publishes cleanly. Must
1679
1869
  // be called AFTER the HTTP server is actually listening (so the port is bound).
@@ -1703,7 +1893,9 @@ export async function startAdvertising({ port, name, type } = {}) {
1703
1893
 
1704
1894
  // Replace any previous advertisement so two conflicting records never coexist.
1705
1895
  await stopAdvertising();
1706
- if (!bonjour) bonjour = new Ctor();
1896
+ const { iface, warning } = resolveInterface();
1897
+ ensureController(Ctor, iface);
1898
+ if (warning) console.warn("mDNS: " + warning);
1707
1899
 
1708
1900
  const serviceName = name || defaultServiceName();
1709
1901
  const serviceType = type || process.env.BONJOUR_TYPE || "http";
@@ -1715,6 +1907,7 @@ export async function startAdvertising({ port, name, type } = {}) {
1715
1907
 
1716
1908
  try {
1717
1909
  published = bonjour.publish({ name: serviceName, type: serviceType, port, host, txt });
1910
+ restrictAddresses(published, iface);
1718
1911
  selfState = {
1719
1912
  status: "advertising",
1720
1913
  name: serviceName,
@@ -1723,10 +1916,13 @@ export async function startAdvertising({ port, name, type } = {}) {
1723
1916
  host,
1724
1917
  txt,
1725
1918
  error: null,
1919
+ interface: iface ? { name: iface.name, label: iface.label, address: iface.address } : null,
1920
+ warning,
1726
1921
  };
1727
1922
  published.on("up", () => {
1728
1923
  console.log(
1729
- "mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)",
1924
+ "mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)" +
1925
+ (iface ? " on " + iface.label + " " + iface.address : " on all networks"),
1730
1926
  );
1731
1927
  });
1732
1928
  published.on("error", (err) => {
@@ -1776,6 +1972,7 @@ export async function destroyDiscovery() {
1776
1972
  if (bonjour) bonjour.destroy();
1777
1973
  } catch {}
1778
1974
  bonjour = null;
1975
+ boundInterface = null;
1779
1976
  }
1780
1977
 
1781
1978
  // Browse the LAN for services of a given type. Collects results for timeoutMs
@@ -1784,7 +1981,7 @@ export async function destroyDiscovery() {
1784
1981
  export async function browse({ type = "http", timeoutMs = 2500 } = {}) {
1785
1982
  const Ctor = await loadBonjour();
1786
1983
  if (!Ctor) return { available: false, services: [] };
1787
- if (!bonjour) bonjour = new Ctor();
1984
+ if (!bonjour) ensureController(Ctor, resolveInterface().iface);
1788
1985
 
1789
1986
  return new Promise((resolve) => {
1790
1987
  const found = new Map();
@@ -1894,3 +2091,1212 @@ export function wireDiscoveryIntoIndex(content) {
1894
2091
  return out;
1895
2092
  }
1896
2093
 
2094
+ // ─────────────────────────────────────────────────────────────────────────────
2095
+ // App-wide API key gate
2096
+ //
2097
+ // Every API route (login and registration included) requires a valid x-api-key
2098
+ // header. skeleton/src/middleware/apiKey.js is generated from
2099
+ // buildApiKeyMiddleware() (a test asserts they're identical), and the dashboard
2100
+ // writes the same file into older projects when it wires the gate in. Keys are
2101
+ // stored as SHA-256 hashes in src/api-keys.json; the plaintext is shown once.
2102
+ // ─────────────────────────────────────────────────────────────────────────────
2103
+
2104
+ export function buildApiKeyMiddleware() {
2105
+ return `// API key authentication.
2106
+ //
2107
+ // Every request to this app's API must carry a valid key in the x-api-key
2108
+ // header, including login and registration, unless the gate is switched off
2109
+ // with API_KEY_REQUIRED=off. Keys are created and revoked in the 4bnode
2110
+ // dashboard (Security -> API keys) and stored HASHED in src/api-keys.json, so
2111
+ // that file never holds a usable key. Revoking a key takes effect immediately.
2112
+ //
2113
+ // index.js (whole app, built in): app.use(apiKeyGate);
2114
+ // a single router only: app.use('/api/private', apiKey);
2115
+ //
2116
+ // Never needs a key: the /docs page, CORS preflight (OPTIONS) requests, the
2117
+ // /_dev dashboard, and any path prefix listed in API_KEY_PUBLIC_PATHS
2118
+ // (comma-separated, e.g. /health,/webhooks/stripe).
2119
+ //
2120
+ // Routes that need a signed-in user still check the JWT (auth middleware) on
2121
+ // top of this: the API key identifies the calling app, the JWT the user.
2122
+ import fs from 'fs';
2123
+ import path from 'path';
2124
+ import crypto from 'crypto';
2125
+ import { fileURLToPath } from 'url';
2126
+
2127
+ const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'api-keys.json');
2128
+ let _cache = { mtimeMs: -1, hashes: [] };
2129
+
2130
+ export function hashApiKey(key) {
2131
+ return crypto.createHash('sha256').update(String(key)).digest('hex');
2132
+ }
2133
+
2134
+ // Read the keys file fresh when it changes (mtime-cached), so keys created or
2135
+ // revoked in the dashboard apply without a restart. Older files stored the
2136
+ // plaintext key; those entries are hashed on read.
2137
+ function loadKeyHashes() {
2138
+ try {
2139
+ const { mtimeMs } = fs.statSync(KEYS_FILE);
2140
+ if (mtimeMs !== _cache.mtimeMs) {
2141
+ const list = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8'));
2142
+ const hashes = (Array.isArray(list) ? list : [])
2143
+ .map((k) => k && (k.hash || (k.key ? hashApiKey(k.key) : null)))
2144
+ .filter(Boolean);
2145
+ _cache = { mtimeMs, hashes };
2146
+ }
2147
+ } catch {
2148
+ _cache = { mtimeMs: -1, hashes: [] };
2149
+ }
2150
+ return _cache.hashes;
2151
+ }
2152
+
2153
+ // Constant-time membership test over fixed-length digests: no early return,
2154
+ // so timing reveals neither which key matched nor the key length.
2155
+ function keyMatches(provided) {
2156
+ const p = Buffer.from(hashApiKey(provided), 'hex');
2157
+ let ok = false;
2158
+ for (const h of loadKeyHashes()) {
2159
+ const v = Buffer.from(h, 'hex');
2160
+ if (v.length === p.length && crypto.timingSafeEqual(p, v)) ok = true;
2161
+ }
2162
+ return ok;
2163
+ }
2164
+
2165
+ const apiKey = (req, res, next) => {
2166
+ const provided = req.header('x-api-key');
2167
+ if (!provided || !keyMatches(provided)) {
2168
+ return res.status(401).json({ message: 'Invalid or missing API key' });
2169
+ }
2170
+ next();
2171
+ };
2172
+
2173
+ function publicPaths() {
2174
+ const extra = String(process.env.API_KEY_PUBLIC_PATHS || '')
2175
+ .split(',')
2176
+ .map((p) => p.trim())
2177
+ .filter((p) => p.startsWith('/'));
2178
+ return ['/_dev', '/docs'].concat(extra);
2179
+ }
2180
+
2181
+ function isPublicPath(p) {
2182
+ return publicPaths().some((pre) => {
2183
+ const base = pre.length > 1 && pre.endsWith('/') ? pre.slice(0, -1) : pre;
2184
+ return p === base || p.startsWith(base + '/');
2185
+ });
2186
+ }
2187
+
2188
+ // App-wide gate. Mount after static files and before any routes.
2189
+ export function apiKeyGate(req, res, next) {
2190
+ if (process.env.API_KEY_REQUIRED === 'off') return next();
2191
+ if (req.method === 'OPTIONS') return next();
2192
+ if (isPublicPath(req.path)) return next();
2193
+ return apiKey(req, res, next);
2194
+ }
2195
+
2196
+ export default apiKey;
2197
+ `;
2198
+ }
2199
+
2200
+ export const API_KEY_GATE_IMPORT = 'import { apiKeyGate } from "./src/middleware/apiKey.js";';
2201
+ export const API_KEY_GATE_USE = "app.use(apiKeyGate);";
2202
+
2203
+ // Idempotently mount the gate in an index.js: right after static files (so
2204
+ // public assets stay public) and therefore before every route registration.
2205
+ export function wireApiKeyGateIntoIndex(content) {
2206
+ let out = content;
2207
+ if (!out.includes("apiKeyGate")) {
2208
+ out = addImportAfterLastImport(out, API_KEY_GATE_IMPORT);
2209
+ }
2210
+ if (!out.includes(API_KEY_GATE_USE)) {
2211
+ const block =
2212
+ "// ── API key gate ────────────────────────────────────\n" +
2213
+ "// Every API route (login/register included) needs a valid x-api-key header.\n" +
2214
+ "// Manage keys in the /_dev dashboard → Security. API_KEY_REQUIRED=off disables.\n" +
2215
+ API_KEY_GATE_USE;
2216
+ const m = out.match(/^app\.use\(express\.static\(.*\);[ \t]*$/m);
2217
+ if (m) {
2218
+ const at = m.index + m[0].length;
2219
+ out = out.slice(0, at) + "\n\n" + block + out.slice(at);
2220
+ } else {
2221
+ out = insertBeforeRoutesContent(out, block);
2222
+ }
2223
+ }
2224
+ return out;
2225
+ }
2226
+
2227
+ // New API key: "4b_" + 32 random bytes, base64url. Returned once to the user;
2228
+ // only hashApiKeyValue(key) is stored.
2229
+ export function generateApiKey(randomBytes) {
2230
+ return "4b_" + Buffer.from(randomBytes(32)).toString("base64url");
2231
+ }
2232
+
2233
+ export function hashApiKeyValue(key, createHash) {
2234
+ return createHash("sha256").update(String(key)).digest("hex");
2235
+ }
2236
+
2237
+ // ─────────────────────────────────────────────────────────────────────────────
2238
+ // Serial devices (Arduino / ESP32 over USB serial)
2239
+ //
2240
+ // Generated projects never hard-code a COM path. Devices are listed in
2241
+ // src/serial/devices.json and matched at connect time by USB serial number,
2242
+ // USB vendor/product id, or a firmware identity handshake; the manager
2243
+ // auto-reconnects, queues command -> reply with timeouts, and closes ports on
2244
+ // shutdown. Both the CLI (add-serialport) and the dashboard write these same
2245
+ // files and validate config with normalizeSerialDevice().
2246
+ // ─────────────────────────────────────────────────────────────────────────────
2247
+
2248
+ export const SERIAL_DEPS = ["serialport", "@serialport/parser-readline"];
2249
+ export const SERIAL_BAUD_RATES = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
2250
+ export const SERIAL_ROUTE_PREFIX = "/api/serial";
2251
+ // Identity command presets (also tried in this order when auto-adding a board).
2252
+ export const SERIAL_ID_PRESETS = [
2253
+ { command: "ID?", label: "ID? (your own firmware)" },
2254
+ { command: "M115", label: "M115 (Marlin / RepRap 3D printers)" },
2255
+ { command: "$I", label: "$I (GRBL CNC)" },
2256
+ { command: "ATI", label: "ATI (AT-command modems)" },
2257
+ ];
2258
+ export const SERIAL_RULE_WHEN = ["contains", "equals", "startsWith", "regex"];
2259
+ export const SERIAL_RULE_ACTIONS = ["webhook", "send"];
2260
+
2261
+ export function buildSerialDeviceModule() {
2262
+ return `// Serial device manager for Arduino / ESP32 boards over USB serial.
2263
+ //
2264
+ // Generated by 4bnode. It follows the rules that keep serial hardware working
2265
+ // on an unattended machine:
2266
+ // - Never hard-code a COM path. A device is MATCHED every time it connects,
2267
+ // by USB serial number, USB vendor/product id, or a firmware identity
2268
+ // handshake (send "ID?", read the reply). COM numbers and tty names change
2269
+ // between reboots and USB ports; these do not.
2270
+ // - Always read through a parser, so each "data" event is one complete line.
2271
+ // - Every write ends with the delimiter the firmware expects.
2272
+ // - send() is command -> reply with a timeout. Commands are queued, so only
2273
+ // one is in flight and a reply can't be matched to the wrong command.
2274
+ // Lines that can't be the reply are skipped: an echo of the command, the
2275
+ // device's ignoreLines, and heartbeats it prints on its own (any line seen
2276
+ // 3+ times while idle, e.g. Marlin's "wait").
2277
+ // - Wait out the board's auto-reset after opening before the first command.
2278
+ // - Auto-reconnect: an unexpected close (board reset, cable pulled) re-runs
2279
+ // find + open until the board is back, on whatever port it returns as.
2280
+ // - Ports are probed one at a time, never in parallel, and a port this
2281
+ // process already holds open is never probed (opening resets most boards).
2282
+ // - Ports are closed on shutdown so the next launch can open them.
2283
+ // - Hotplug: portWatcher notices boards being plugged in or removed, so an
2284
+ // offline device reconnects the moment its board is attached.
2285
+
2286
+ import { EventEmitter } from 'events';
2287
+
2288
+ let SerialPortCtor = null;
2289
+ let ReadlineParserCtor = null;
2290
+
2291
+ // Imported lazily: if the native serialport module is missing or fails to
2292
+ // load on this machine, the app still boots and devices just stay offline.
2293
+ async function loadSerial() {
2294
+ if (SerialPortCtor) return true;
2295
+ try {
2296
+ SerialPortCtor = (await import('serialport')).SerialPort;
2297
+ ReadlineParserCtor = (await import('@serialport/parser-readline')).ReadlineParser;
2298
+ return true;
2299
+ } catch {
2300
+ return false;
2301
+ }
2302
+ }
2303
+
2304
+ // USB bridge chips by vendor id. A hint, not proof: an ESP32 and an ESP8266
2305
+ // can both report 10c4 or 1a86, so confirm with the identity handshake.
2306
+ export const USB_VENDORS = {
2307
+ '2341': 'Arduino',
2308
+ '2a03': 'Arduino (.org)',
2309
+ '303a': 'Espressif (ESP32 native USB)',
2310
+ '10c4': 'Silicon Labs CP210x',
2311
+ '1a86': 'WCH CH340 / CH9102',
2312
+ '0403': 'FTDI',
2313
+ '239a': 'Adafruit',
2314
+ '2e8a': 'Raspberry Pi (RP2040)',
2315
+ '0483': 'STMicroelectronics (STM32)',
2316
+ };
2317
+
2318
+ // Replies that are clearly not an identity: firmware errors and bare acks
2319
+ // (e.g. Marlin answers an unknown command with "echo:Unknown command" + "ok").
2320
+ const NOT_AN_IDENTITY = /unknown command|invalid|not supported|error|^ok$|^echo:/i;
2321
+
2322
+ // Identity commands tried in order when a board's command is unknown:
2323
+ // ID? (your own firmware), M115 (Marlin / RepRap 3D printers), $I (GRBL CNC),
2324
+ // ATI (AT-command modems). Override with SERIAL_ID_COMMANDS=ID?,M115
2325
+ export const ID_COMMANDS = ['ID?', 'M115', '$I', 'ATI'];
2326
+
2327
+ export function idCommands() {
2328
+ const env = String(process.env.SERIAL_ID_COMMANDS || '').split(',').map((c) => c.trim()).filter(Boolean);
2329
+ return env.length ? env : ID_COMMANDS;
2330
+ }
2331
+
2332
+ // Short, stable key for an identity reply, used to match and name a board.
2333
+ // "LED-CTRL:2.1" -> "LED-CTRL"
2334
+ // "FIRMWARE_NAME:Marlin 2.0.5.4 (GitHub)" -> "Marlin"
2335
+ // "[VER:1.1h.20190825:]" -> "GRBL"
2336
+ export function identityKey(reply) {
2337
+ const v = String(reply || '').trim();
2338
+ if (!v) return null;
2339
+ const fw = v.match(/FIRMWARE_NAME: *([A-Za-z0-9._-]+)/);
2340
+ if (fw) return fw[1];
2341
+ if (v.startsWith('[VER:')) return 'GRBL';
2342
+ const head = v.split(/[: ]/)[0].replace(/[^A-Za-z0-9._-]/g, '');
2343
+ return head ? head.slice(0, 40) : null;
2344
+ }
2345
+
2346
+ const CR = String.fromCharCode(13);
2347
+ const LF = String.fromCharCode(10);
2348
+ const LINE_BREAKS = new RegExp('[' + CR + LF + ']', 'g');
2349
+
2350
+ export function delimiterFor(name) {
2351
+ return name === 'crlf' ? CR + LF : LF;
2352
+ }
2353
+
2354
+ const openPaths = new Set(); // ports this process currently holds open
2355
+ // Last identity each port answered with (path -> "LED-CTRL:2.1"). Probing
2356
+ // resets most boards, so a reply is remembered until the board is unplugged.
2357
+ const identities = new Map();
2358
+ let probeChain = Promise.resolve(); // serializes probing across all devices
2359
+
2360
+ function normId(v) {
2361
+ return String(v || '').toLowerCase().replace(/^0x/, '');
2362
+ }
2363
+
2364
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
2365
+
2366
+ // Every serial port with its USB metadata.
2367
+ export async function listPorts() {
2368
+ if (!(await loadSerial())) {
2369
+ throw new Error('serialport is not installed. Run: npm install serialport @serialport/parser-readline');
2370
+ }
2371
+ const ports = await SerialPortCtor.list();
2372
+ return ports.map((p) => {
2373
+ const vendorId = normId(p.vendorId) || null;
2374
+ return {
2375
+ path: p.path,
2376
+ vendorId,
2377
+ productId: normId(p.productId) || null,
2378
+ manufacturer: p.manufacturer || null,
2379
+ serialNumber: p.serialNumber || null,
2380
+ vendor: (vendorId && USB_VENDORS[vendorId]) || null,
2381
+ usb: !!vendorId,
2382
+ inUse: openPaths.has(p.path),
2383
+ identity: identities.get(p.path) || null,
2384
+ };
2385
+ });
2386
+ }
2387
+
2388
+ // Open a port, wait for the board's auto-reset, send the identity command and
2389
+ // return the first line it answers with, or null. Never touches an open port.
2390
+ export function probeIdentity(path, opts = {}) {
2391
+ const baudRate = Number(opts.baudRate) || 115200;
2392
+ const command = opts.command || 'ID?';
2393
+ const delimiter = delimiterFor(opts.delimiter);
2394
+ const timeout = Number(opts.timeout) || 1500;
2395
+ const resetDelayMs = opts.resetDelayMs == null ? 1200 : Number(opts.resetDelayMs);
2396
+ if (openPaths.has(path)) return Promise.resolve(null);
2397
+ return loadSerial().then((ok) => {
2398
+ if (!ok) return null;
2399
+ return new Promise((resolve) => {
2400
+ let done = false;
2401
+ const port = new SerialPortCtor({ path, baudRate, autoOpen: false });
2402
+ const parser = port.pipe(new ReadlineParserCtor({ delimiter }));
2403
+ const finish = (result) => {
2404
+ if (done) return;
2405
+ done = true;
2406
+ clearTimeout(guard);
2407
+ openPaths.delete(path);
2408
+ if (port.isOpen) port.close(() => resolve(result));
2409
+ else resolve(result);
2410
+ };
2411
+ const guard = setTimeout(() => finish(null), resetDelayMs + timeout + 1500);
2412
+ // Only a line that arrives AFTER the command counts. Lines the board was
2413
+ // already sending on its own (boot banners, "wait" heartbeats) are noise,
2414
+ // and so is a repeat of one of them after the command.
2415
+ let sent = false;
2416
+ const before = new Set();
2417
+ parser.on('data', (line) => {
2418
+ const v = String(line).trim();
2419
+ if (!v) return;
2420
+ if (!sent) {
2421
+ before.add(v);
2422
+ return;
2423
+ }
2424
+ if (before.has(v) || v === command || NOT_AN_IDENTITY.test(v)) return; // ignore echoes too
2425
+ identities.set(path, v);
2426
+ finish(v); // first real reply wins
2427
+ });
2428
+ port.on('error', () => finish(null));
2429
+ port.open((err) => {
2430
+ if (err) return finish(null);
2431
+ openPaths.add(path);
2432
+ setTimeout(() => {
2433
+ if (done) return;
2434
+ sent = true;
2435
+ port.write(command + delimiter, (e) => { if (e) finish(null); });
2436
+ }, resetDelayMs);
2437
+ });
2438
+ });
2439
+ });
2440
+ }
2441
+
2442
+ // ── Hotplug detection ──
2443
+ // Polls the port list (cheap: it does not open any port) and emits
2444
+ // "attach" / "detach" with the port's USB metadata. Boards already present
2445
+ // when watching starts are reported once as "attach" with initial: true.
2446
+ // Start it with watchPorts().
2447
+ export const portWatcher = new EventEmitter();
2448
+ portWatcher.setMaxListeners(100);
2449
+ let watchTimer = null;
2450
+ let knownPorts = null; // path -> port info from the last poll
2451
+
2452
+ export function watchPorts(intervalMs = 2000) {
2453
+ if (watchTimer) return portWatcher;
2454
+ const poll = async () => {
2455
+ let ports;
2456
+ try {
2457
+ ports = await listPorts();
2458
+ } catch {
2459
+ return; // serialport missing: nothing to watch
2460
+ }
2461
+ const now = new Map(ports.map((p) => [p.path, p]));
2462
+ // The first poll reports boards already plugged in at startup
2463
+ // (initial: true), so they are handled exactly like a fresh plug-in.
2464
+ const initial = !knownPorts;
2465
+ for (const [path, info] of now) {
2466
+ if (initial || !knownPorts.has(path)) portWatcher.emit('attach', Object.assign({}, info, { initial }));
2467
+ }
2468
+ if (!initial) {
2469
+ for (const [path, info] of knownPorts) {
2470
+ if (now.has(path)) continue;
2471
+ identities.delete(path); // a different board may come back on this path
2472
+ portWatcher.emit('detach', info);
2473
+ }
2474
+ }
2475
+ knownPorts = now;
2476
+ };
2477
+ poll();
2478
+ watchTimer = setInterval(poll, intervalMs);
2479
+ if (watchTimer.unref) watchTimer.unref();
2480
+ return portWatcher;
2481
+ }
2482
+
2483
+ export function unwatchPorts() {
2484
+ if (watchTimer) clearInterval(watchTimer);
2485
+ watchTimer = null;
2486
+ knownPorts = null;
2487
+ }
2488
+
2489
+ // Sleep for ms, but wake early when any board is plugged in.
2490
+ function waitOrAttach(ms) {
2491
+ return new Promise((resolve) => {
2492
+ const done = () => {
2493
+ clearTimeout(t);
2494
+ portWatcher.off('attach', done);
2495
+ resolve();
2496
+ };
2497
+ const t = setTimeout(done, ms);
2498
+ portWatcher.on('attach', done);
2499
+ });
2500
+ }
2501
+
2502
+ // Every USB serial port with both identification layers: USB metadata
2503
+ // (vendor id, serial number) and the firmware identity (reply to idCommand).
2504
+ // Probes one port at a time, skips ports this process holds open, and reuses
2505
+ // remembered identities unless refresh is set.
2506
+ export async function listDevices(opts = {}) {
2507
+ const ports = (await listPorts()).filter((p) => p.usb);
2508
+ return withProbeLock(async () => {
2509
+ const out = [];
2510
+ for (const p of ports) {
2511
+ let identity = p.identity;
2512
+ if ((!identity || opts.refresh) && !p.inUse) identity = await probeIdentity(p.path, opts);
2513
+ out.push(Object.assign({}, p, { identity: identity || null }));
2514
+ }
2515
+ return out;
2516
+ });
2517
+ }
2518
+
2519
+ // Try each identity command until one gets a real reply. Returns
2520
+ // { identity, command } or null. Each try opens the port (resets the board).
2521
+ export async function identifyBoard(path, opts = {}) {
2522
+ const commands = opts.commands || idCommands();
2523
+ for (const command of commands) {
2524
+ const identity = await probeIdentity(path, Object.assign({}, opts, { command }));
2525
+ if (identity) return { identity, command };
2526
+ }
2527
+ return null;
2528
+ }
2529
+
2530
+ // Run fn with the global probe lock, so ports are never probed in parallel.
2531
+ function withProbeLock(fn) {
2532
+ const run = probeChain.then(fn, fn);
2533
+ probeChain = run.catch(() => {});
2534
+ return run;
2535
+ }
2536
+
2537
+ // Find a device's CURRENT path. Cheapest, non-intrusive checks first:
2538
+ // serialNumber, then vendorId/productId, then the identity handshake (which
2539
+ // opens ports, so it only runs on USB ports), then a fixed path.
2540
+ export async function resolvePort(match = {}, opts = {}) {
2541
+ const ports = (await listPorts()).filter((p) => !p.inUse);
2542
+ if (match.serialNumber) {
2543
+ const hit = ports.find((p) => p.serialNumber === match.serialNumber);
2544
+ return hit ? hit.path : null;
2545
+ }
2546
+ let candidates = ports;
2547
+ if (match.vendorId) {
2548
+ const vid = normId(match.vendorId);
2549
+ const pid = normId(match.productId);
2550
+ candidates = candidates.filter((p) => p.vendorId === vid && (!pid || p.productId === pid));
2551
+ if (!match.identity) return candidates[0] ? candidates[0].path : null;
2552
+ }
2553
+ if (match.identity) {
2554
+ const usb = candidates.filter((p) => p.usb);
2555
+ return withProbeLock(async () => {
2556
+ for (const p of usb) {
2557
+ const id = await probeIdentity(p.path, opts);
2558
+ if (id && id.includes(match.identity)) return p.path;
2559
+ }
2560
+ return null;
2561
+ });
2562
+ }
2563
+ if (match.path) return ports.some((p) => p.path === match.path) ? match.path : null;
2564
+ return null;
2565
+ }
2566
+
2567
+ export class SerialDevice extends EventEmitter {
2568
+ constructor(name, config = {}) {
2569
+ super();
2570
+ this.name = name;
2571
+ this.match = config.match || {};
2572
+ this.baudRate = Number(config.baudRate) || 115200;
2573
+ this.delimiter = config.delimiter === 'crlf' ? 'crlf' : 'lf';
2574
+ this.idCommand = config.idCommand || 'ID?';
2575
+ this.timeout = Number(config.timeout) || 2000;
2576
+ this.retryMs = Number(config.retryMs) || 3000;
2577
+ this.openDelayMs = config.openDelayMs == null ? 1200 : Number(config.openDelayMs);
2578
+ this.port = null;
2579
+ this.parser = null;
2580
+ this.path = null;
2581
+ this.connected = false;
2582
+ this.status = 'idle'; // idle | searching | connected | reconnecting | offline | error | stopped
2583
+ this.error = null;
2584
+ this.connectedAt = null;
2585
+ this.ignoreLines = Array.isArray(config.ignoreLines) ? config.ignoreLines : [];
2586
+ this._idle = new Map(); // line -> times seen while no command was in flight
2587
+ this._inFlight = 0;
2588
+ this.recent = []; // last lines sent (tx) and received (rx)
2589
+ this.stats = {
2590
+ connects: 0,
2591
+ reconnects: 0,
2592
+ bytesIn: 0,
2593
+ bytesOut: 0,
2594
+ linesIn: 0,
2595
+ linesOut: 0,
2596
+ lastRx: null,
2597
+ lastTx: null,
2598
+ replies: 0,
2599
+ replyMsTotal: 0,
2600
+ timeouts: 0,
2601
+ };
2602
+ this._stopped = true;
2603
+ this._looping = false;
2604
+ this._readyAt = 0;
2605
+ this._queue = Promise.resolve();
2606
+ }
2607
+
2608
+ start() {
2609
+ if (!this._stopped) return;
2610
+ this._stopped = false;
2611
+ this._connectLoop();
2612
+ }
2613
+
2614
+ async stop() {
2615
+ this._stopped = true;
2616
+ const port = this.port;
2617
+ if (port && port.isOpen) await new Promise((r) => port.close(() => r()));
2618
+ this._setStatus('stopped');
2619
+ }
2620
+
2621
+ state() {
2622
+ return {
2623
+ name: this.name,
2624
+ status: this.status,
2625
+ connected: this.connected,
2626
+ path: this.path,
2627
+ error: this.error,
2628
+ connectedAt: this.connectedAt,
2629
+ match: this.match,
2630
+ baudRate: this.baudRate,
2631
+ delimiter: this.delimiter,
2632
+ stats: Object.assign({}, this.stats, {
2633
+ avgReplyMs: this.stats.replies ? Math.round(this.stats.replyMsTotal / this.stats.replies) : null,
2634
+ uptimeMs: this.connected && this.connectedAt ? Date.now() - this.connectedAt : 0,
2635
+ }),
2636
+ recent: this.recent.slice(-50),
2637
+ };
2638
+ }
2639
+
2640
+ _setStatus(status, error) {
2641
+ this.status = status;
2642
+ this.error = error || null;
2643
+ this.emit('status', status);
2644
+ }
2645
+
2646
+ _log(dir, line) {
2647
+ this.recent.push({ dir, line, ts: Date.now() });
2648
+ if (this.recent.length > 1000) this.recent.splice(0, this.recent.length - 1000);
2649
+ }
2650
+
2651
+ async _connectLoop() {
2652
+ if (this._looping) return;
2653
+ this._looping = true;
2654
+ try {
2655
+ while (!this._stopped && !this.connected) {
2656
+ this._setStatus(this.status === 'reconnecting' ? 'reconnecting' : 'searching');
2657
+ let path = null;
2658
+ try {
2659
+ path = await resolvePort(this.match, {
2660
+ baudRate: this.baudRate,
2661
+ command: this.idCommand,
2662
+ delimiter: this.delimiter,
2663
+ });
2664
+ } catch (err) {
2665
+ this._setStatus('error', err.message);
2666
+ }
2667
+ if (path && !this._stopped) {
2668
+ try {
2669
+ await this._open(path);
2670
+ } catch (err) {
2671
+ this._setStatus('error', err.message);
2672
+ }
2673
+ }
2674
+ if (!this.connected && !this._stopped) {
2675
+ if (this.status !== 'error') this._setStatus('offline', 'Device not found. Retrying.');
2676
+ await waitOrAttach(this.retryMs);
2677
+ }
2678
+ }
2679
+ } finally {
2680
+ this._looping = false;
2681
+ }
2682
+ }
2683
+
2684
+ _open(path) {
2685
+ return new Promise((resolve, reject) => {
2686
+ const port = new SerialPortCtor({ path, baudRate: this.baudRate, autoOpen: false });
2687
+ const parser = port.pipe(new ReadlineParserCtor({ delimiter: delimiterFor(this.delimiter) }));
2688
+ port.open((err) => {
2689
+ if (err) return reject(err);
2690
+ if (this._stopped) {
2691
+ port.close(() => {});
2692
+ return reject(new Error('stopped'));
2693
+ }
2694
+ openPaths.add(path);
2695
+ this.port = port;
2696
+ this.parser = parser;
2697
+ this.path = path;
2698
+ this.connected = true;
2699
+ this.connectedAt = Date.now();
2700
+ this.stats.connects++;
2701
+ if (this.stats.connects > 1) this.stats.reconnects++;
2702
+ this._readyAt = Date.now() + this.openDelayMs; // board auto-resets on open
2703
+ this._setStatus('connected');
2704
+ this.emit('connect', path);
2705
+
2706
+ parser.on('data', (line) => {
2707
+ const v = String(line).trim();
2708
+ if (!v) return;
2709
+ this.stats.linesIn++;
2710
+ this.stats.bytesIn += Buffer.byteLength(String(line)) + delimiterFor(this.delimiter).length;
2711
+ this.stats.lastRx = Date.now();
2712
+ if (!this._inFlight) {
2713
+ if (this._idle.size > 200) this._idle.clear();
2714
+ this._idle.set(v, (this._idle.get(v) || 0) + 1);
2715
+ }
2716
+ this._log('rx', v);
2717
+ this.emit('data', v);
2718
+ });
2719
+ port.on('close', () => {
2720
+ openPaths.delete(path);
2721
+ this.connected = false;
2722
+ this.port = null;
2723
+ this.parser = null;
2724
+ if (!this._stopped) {
2725
+ this.emit('disconnect');
2726
+ this._setStatus('reconnecting');
2727
+ this._connectLoop();
2728
+ }
2729
+ });
2730
+ port.on('error', (e) => {
2731
+ this.error = e.message;
2732
+ if (this.listenerCount('error')) this.emit('error', e);
2733
+ });
2734
+ resolve();
2735
+ });
2736
+ });
2737
+ }
2738
+
2739
+ async _waitReady() {
2740
+ const ms = this._readyAt - Date.now();
2741
+ if (ms > 0) await wait(ms);
2742
+ }
2743
+
2744
+ _write(data) {
2745
+ return new Promise((resolve, reject) => {
2746
+ const port = this.port;
2747
+ if (!this.connected || !port) return reject(new Error(this.name + ' is not connected'));
2748
+ const line = String(data).replace(LINE_BREAKS, ''); // one command per write
2749
+ this._log('tx', line);
2750
+ this.stats.linesOut++;
2751
+ this.stats.bytesOut += Buffer.byteLength(line) + delimiterFor(this.delimiter).length;
2752
+ this.stats.lastTx = Date.now();
2753
+ this.emit('tx', line);
2754
+ port.write(line + delimiterFor(this.delimiter), (err) => {
2755
+ if (err) return reject(err);
2756
+ port.drain((e) => (e ? reject(e) : resolve())); // flush now: low-latency triggers
2757
+ });
2758
+ });
2759
+ }
2760
+
2761
+ _enqueue(task) {
2762
+ const run = this._queue.then(() => this._waitReady()).then(task);
2763
+ this._queue = run.catch(() => {});
2764
+ return run;
2765
+ }
2766
+
2767
+ // Is this line noise rather than a reply to command?
2768
+ _isNoise(line, command) {
2769
+ return line === command || this.ignoreLines.includes(line) || (this._idle.get(line) || 0) >= 3;
2770
+ }
2771
+
2772
+ // Send a command and resolve with the board's reply: the next line that is
2773
+ // not noise, or (with opts.expect) the next line containing that text /
2774
+ // matching that RegExp.
2775
+ send(command, opts = {}) {
2776
+ const ms = Number(opts.timeout) || this.timeout;
2777
+ const expect = opts.expect;
2778
+ const cmd = String(command);
2779
+ return this._enqueue(() => new Promise((resolve, reject) => {
2780
+ if (!this.connected) return reject(new Error(this.name + ' is not connected'));
2781
+ let timer = null;
2782
+ const started = Date.now();
2783
+ this._inFlight++;
2784
+ const onData = (line) => {
2785
+ if (expect) {
2786
+ if (expect instanceof RegExp ? !expect.test(line) : !line.includes(String(expect))) return;
2787
+ } else if (this._isNoise(line, cmd)) {
2788
+ return;
2789
+ }
2790
+ cleanup();
2791
+ this.stats.replies++;
2792
+ this.stats.replyMsTotal += Date.now() - started;
2793
+ resolve(line);
2794
+ };
2795
+ let cleaned = false;
2796
+ const cleanup = () => {
2797
+ if (cleaned) return;
2798
+ cleaned = true;
2799
+ this._inFlight--;
2800
+ clearTimeout(timer);
2801
+ this.off('data', onData);
2802
+ };
2803
+ timer = setTimeout(() => {
2804
+ cleanup();
2805
+ this.stats.timeouts++;
2806
+ reject(new Error('Timeout waiting for reply to ' + JSON.stringify(String(command))));
2807
+ }, ms);
2808
+ this.on('data', onData);
2809
+ this._write(command).catch((err) => {
2810
+ cleanup();
2811
+ reject(err);
2812
+ });
2813
+ }));
2814
+ }
2815
+
2816
+ // Fire-and-forget write (no reply expected).
2817
+ write(data) {
2818
+ return this._enqueue(() => this._write(data));
2819
+ }
2820
+ }
2821
+ `;
2822
+ }
2823
+
2824
+ export function buildSerialDevicesModule() {
2825
+ return `// Serial devices for this app, configured in src/serial/devices.json, started
2826
+ // at boot and kept connected. Manage them in the /_dev dashboard
2827
+ // (Features -> SerialPort) or edit the JSON by hand; reloadSerialDevices()
2828
+ // applies changes without a restart.
2829
+ //
2830
+ // Plug and play: boards are detected as they are plugged in. A configured
2831
+ // device reconnects immediately, and an unknown board is added automatically
2832
+ // (matched by USB serial number, firmware identity, or USB vendor/product id)
2833
+ // unless SERIAL_AUTO_ADD=off.
2834
+ //
2835
+ // Use a device anywhere in the app:
2836
+ // import { getDevice, onSerialLine } from './src/serial/devices.js';
2837
+ // const reply = await getDevice('led').send('LED:ON'); // waits for the reply
2838
+ // const stop = onSerialLine((e) => console.log(e.device, e.dir, e.line));
2839
+ //
2840
+ // Rules (per device, in devices.json): when a received line matches, call a
2841
+ // webhook or send a command to a device. See runRules() below.
2842
+ import fs from 'fs';
2843
+ import path from 'path';
2844
+ import { EventEmitter } from 'events';
2845
+ import { fileURLToPath } from 'url';
2846
+ import { SerialDevice, watchPorts, portWatcher, identifyBoard, identityKey, listPorts, listDevices } from './SerialDevice.js';
2847
+
2848
+ const CONFIG_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'devices.json');
2849
+ const devices = new Map(); // name -> SerialDevice
2850
+ const events = []; // recent hotplug / auto-add / rule events, newest last
2851
+ let eventSeq = 0;
2852
+ let watching = false;
2853
+
2854
+ // Every line sent or received by any device, plus status changes:
2855
+ // { type: 'line', device, dir: 'rx' | 'tx', line, ts }
2856
+ // { type: 'status', device, status, path, ts }
2857
+ export const serialBus = new EventEmitter();
2858
+ serialBus.setMaxListeners(200);
2859
+
2860
+ export function onSerialLine(fn) {
2861
+ const h = (e) => { if (e.type === 'line') fn(e); };
2862
+ serialBus.on('event', h);
2863
+ return () => serialBus.off('event', h);
2864
+ }
2865
+
2866
+ export function readSerialConfig() {
2867
+ try {
2868
+ const list = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
2869
+ return Array.isArray(list) ? list : [];
2870
+ } catch {
2871
+ return [];
2872
+ }
2873
+ }
2874
+
2875
+ function writeSerialConfig(list) {
2876
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(list, null, 2) + String.fromCharCode(10), 'utf8');
2877
+ }
2878
+
2879
+ function logEvent(type, port, extra) {
2880
+ const e = Object.assign({ id: ++eventSeq, type, ts: Date.now(), path: port.path || null, vendor: port.vendor || port.manufacturer || null, serialNumber: port.serialNumber || null }, extra || {});
2881
+ events.push(e);
2882
+ if (events.length > 100) events.splice(0, events.length - 100);
2883
+ return e;
2884
+ }
2885
+
2886
+ // Events after a given id (the dashboard polls with the last id it saw).
2887
+ export function getSerialEvents(sinceId = 0) {
2888
+ return events.filter((e) => e.id > sinceId);
2889
+ }
2890
+
2891
+ export function autoAddEnabled() {
2892
+ return process.env.SERIAL_AUTO_ADD !== 'off';
2893
+ }
2894
+
2895
+ // ── Rules ──
2896
+ // { id, enabled, when: 'contains'|'equals'|'startsWith'|'regex', pattern,
2897
+ // action: 'webhook'|'send', url, target, command, cooldownMs }
2898
+ const ruleLastFired = new Map(); // "device|ruleId" -> ts
2899
+ const ruleStats = new Map(); // "device|ruleId" -> { fired, lastFired, lastError }
2900
+
2901
+ function ruleMatches(rule, line) {
2902
+ const p = String(rule.pattern || '');
2903
+ if (!p) return false;
2904
+ if (rule.when === 'equals') return line === p;
2905
+ if (rule.when === 'startsWith') return line.startsWith(p);
2906
+ if (rule.when === 'regex') {
2907
+ try { return new RegExp(p).test(line); } catch { return false; }
2908
+ }
2909
+ return line.includes(p);
2910
+ }
2911
+
2912
+ async function runRules(deviceName, line, config) {
2913
+ const rules = (config && Array.isArray(config.rules)) ? config.rules : [];
2914
+ for (const rule of rules) {
2915
+ if (!rule || rule.enabled === false || !ruleMatches(rule, line)) continue;
2916
+ const key = deviceName + '|' + rule.id;
2917
+ const cooldown = Number(rule.cooldownMs) >= 0 ? Number(rule.cooldownMs) : 2000;
2918
+ const last = ruleLastFired.get(key) || 0;
2919
+ if (Date.now() - last < cooldown) continue;
2920
+ ruleLastFired.set(key, Date.now());
2921
+ const st = ruleStats.get(key) || { fired: 0, lastFired: null, lastError: null };
2922
+ st.fired++;
2923
+ st.lastFired = Date.now();
2924
+ ruleStats.set(key, st);
2925
+ try {
2926
+ if (rule.action === 'webhook' && rule.url) {
2927
+ const res = await fetch(rule.url, {
2928
+ method: 'POST',
2929
+ headers: { 'content-type': 'application/json' },
2930
+ body: JSON.stringify({ device: deviceName, line, rule: rule.id, ts: Date.now() }),
2931
+ signal: AbortSignal.timeout(5000),
2932
+ });
2933
+ if (!res.ok) throw new Error('webhook answered ' + res.status);
2934
+ } else if (rule.action === 'send' && rule.target && rule.command) {
2935
+ const target = devices.get(rule.target);
2936
+ if (!target || !target.connected) throw new Error('device "' + rule.target + '" is offline');
2937
+ await target.write(rule.command);
2938
+ }
2939
+ st.lastError = null;
2940
+ logEvent('rule', {}, { name: deviceName, rule: rule.id, line });
2941
+ } catch (err) {
2942
+ st.lastError = err.message;
2943
+ console.error('serial: rule ' + rule.id + ' on ' + deviceName + ' failed: ' + err.message);
2944
+ }
2945
+ }
2946
+ }
2947
+
2948
+ export function getRuleStats(deviceName) {
2949
+ const out = {};
2950
+ for (const [key, st] of ruleStats) {
2951
+ const [dev, id] = key.split('|');
2952
+ if (dev === deviceName) out[id] = st;
2953
+ }
2954
+ return out;
2955
+ }
2956
+
2957
+ // ── Auto-add ──
2958
+ // Does a configured device already claim this port?
2959
+ function claimedBy(port, config) {
2960
+ return config.find((c) => {
2961
+ const m = c.match || {};
2962
+ if (m.serialNumber) return m.serialNumber === port.serialNumber;
2963
+ if (m.path) return m.path === port.path;
2964
+ if (m.vendorId && !m.identity) return m.vendorId === port.vendorId && (!m.productId || m.productId === port.productId);
2965
+ return false; // identity matches can't be known without probing
2966
+ });
2967
+ }
2968
+
2969
+ function uniqueName(base, config) {
2970
+ const clean = String(base || 'board').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '') || 'board';
2971
+ let name = clean.slice(0, 32);
2972
+ let n = 2;
2973
+ while (config.some((c) => c.name === name)) name = clean.slice(0, 30) + '-' + n++;
2974
+ return name;
2975
+ }
2976
+
2977
+ // Register a newly attached, unknown USB board as a device and connect it.
2978
+ async function autoAdd(port) {
2979
+ if (claimedBy(port, readSerialConfig())) return;
2980
+ // Let configured identity-matched devices try this port first.
2981
+ await new Promise((r) => setTimeout(r, 2500));
2982
+ const fresh = (await listPorts().catch(() => [])).find((p) => p.path === port.path);
2983
+ if (!fresh || fresh.inUse) return;
2984
+ if (claimedBy(fresh, readSerialConfig())) return;
2985
+
2986
+ const baudRate = Number(process.env.SERIAL_DEFAULT_BAUD) || 115200;
2987
+ // Try the identity commands in turn (ID?, M115, $I, ATI) for a name and a
2988
+ // firmware match; remember which command worked.
2989
+ const found = fresh.identity ? { identity: fresh.identity, command: 'ID?' } : await identifyBoard(fresh.path, { baudRate });
2990
+ const key = found ? identityKey(found.identity) : null;
2991
+ // A configured device already matches this firmware: it will connect to the
2992
+ // board itself (e.g. after a replug into another USB port).
2993
+ if (key && !fresh.serialNumber && readSerialConfig().some((c) => c.match && c.match.identity && found.identity.includes(c.match.identity))) return;
2994
+
2995
+ let match;
2996
+ if (fresh.serialNumber) match = { serialNumber: fresh.serialNumber };
2997
+ else if (key) match = { identity: key, vendorId: fresh.vendorId };
2998
+ else {
2999
+ const sameModel = readSerialConfig().some((c) => c.match && c.match.vendorId === fresh.vendorId && c.match.productId === fresh.productId);
3000
+ match = sameModel ? { path: fresh.path } : { vendorId: fresh.vendorId, productId: fresh.productId };
3001
+ }
3002
+ const name = uniqueName(key || (fresh.vendor || fresh.manufacturer || 'board').split(' ')[0], readSerialConfig());
3003
+ const entry = { name, match, baudRate, delimiter: 'lf', idCommand: found ? found.command : 'ID?', timeout: 2000, enabled: true, auto: true };
3004
+ const list = readSerialConfig();
3005
+ if (list.some((c) => c.name === name) || claimedBy(fresh, list)) return;
3006
+ list.push(entry);
3007
+ writeSerialConfig(list);
3008
+ logEvent('added', fresh, { name, identity: found ? found.identity : null });
3009
+ console.log('serial: new board on ' + fresh.path + ' added as "' + name + '"');
3010
+ await reloadSerialDevices();
3011
+ }
3012
+
3013
+ function startWatching() {
3014
+ if (watching) return;
3015
+ watching = true;
3016
+ watchPorts(2000);
3017
+ portWatcher.on('attach', (port) => {
3018
+ if (!port.usb) return;
3019
+ logEvent(port.initial ? 'found' : 'attach', port);
3020
+ console.log('serial: board ' + (port.initial ? 'found' : 'attached') + ' on ' + port.path + (port.vendor ? ' (' + port.vendor + ')' : ''));
3021
+ if (autoAddEnabled()) autoAdd(port).catch((err) => console.error('serial: auto-add failed: ' + err.message));
3022
+ });
3023
+ portWatcher.on('detach', (port) => {
3024
+ if (!port.usb) return;
3025
+ logEvent('detach', port);
3026
+ console.log('serial: board removed from ' + port.path);
3027
+ });
3028
+ }
3029
+
3030
+ // Start new devices, restart changed ones, stop removed ones.
3031
+ export async function reloadSerialDevices() {
3032
+ startWatching();
3033
+ const config = readSerialConfig().filter((c) => c && c.name && c.enabled !== false);
3034
+ const wanted = new Map(config.map((c) => [c.name, c]));
3035
+ for (const [name, dev] of devices) {
3036
+ const next = wanted.get(name);
3037
+ if (!next || dev.configKey !== JSON.stringify(next)) {
3038
+ await dev.stop();
3039
+ devices.delete(name);
3040
+ }
3041
+ }
3042
+ for (const c of config) {
3043
+ if (devices.has(c.name)) continue;
3044
+ const dev = new SerialDevice(c.name, c);
3045
+ dev.configKey = JSON.stringify(c);
3046
+ dev.on('connect', (p) => console.log('serial: ' + c.name + ' connected on ' + p));
3047
+ dev.on('disconnect', () => console.warn('serial: ' + c.name + ' disconnected, reconnecting...'));
3048
+ dev.on('error', (e) => console.error('serial: ' + c.name + ' error: ' + e.message));
3049
+ dev.on('status', (status) => serialBus.emit('event', { type: 'status', device: c.name, status, path: dev.path, ts: Date.now() }));
3050
+ dev.on('tx', (line) => serialBus.emit('event', { type: 'line', device: c.name, dir: 'tx', line, ts: Date.now() }));
3051
+ dev.on('data', (line) => {
3052
+ serialBus.emit('event', { type: 'line', device: c.name, dir: 'rx', line, ts: Date.now() });
3053
+ runRules(c.name, line, c);
3054
+ });
3055
+ devices.set(c.name, dev);
3056
+ dev.start();
3057
+ }
3058
+ return listSerialDevices();
3059
+ }
3060
+
3061
+ export const startSerialDevices = reloadSerialDevices;
3062
+
3063
+ // Close every port (call on shutdown so the ports are free next launch).
3064
+ export async function stopSerialDevices() {
3065
+ await Promise.all([...devices.values()].map((d) => d.stop().catch(() => {})));
3066
+ devices.clear();
3067
+ }
3068
+
3069
+ export function getDevice(name) {
3070
+ return devices.get(name) || null;
3071
+ }
3072
+
3073
+ // Re-exported for the dashboard / CLI: every USB port with USB metadata and
3074
+ // firmware identity (point 7 of the serial guide).
3075
+ export { listDevices };
3076
+
3077
+ export function listSerialDevices() {
3078
+ return [...devices.values()].map((d) => Object.assign(d.state(), { rules: getRuleStats(d.name) }));
3079
+ }
3080
+ `;
3081
+ }
3082
+
3083
+ export function buildSerialRouter() {
3084
+ return `// HTTP API for the serial devices. The backend owns the hardware; browsers and
3085
+ // other clients talk to it through these routes (covered by the API key gate).
3086
+ // GET /api/serial/devices status + health of every device
3087
+ // POST /api/serial/devices/:name/send { "command": "ID?", "timeout": 2000 } -> { "reply": "..." }
3088
+ // POST /api/serial/devices/:name/write { "data": "LED:ON" } (no reply expected)
3089
+ // GET /api/serial/stream live lines from every device (Server-Sent Events)
3090
+ // GET /api/serial/devices/:name/stream live lines from one device
3091
+ //
3092
+ // Streams send the x-api-key header like any other request, so read them with
3093
+ // fetch() and a stream reader (EventSource cannot set headers):
3094
+ // const res = await fetch('/api/serial/stream', { headers: { 'x-api-key': KEY } });
3095
+ // const reader = res.body.getReader(); // each event: "data: {json}" + blank line
3096
+ import express from 'express';
3097
+ import { getDevice, listSerialDevices, serialBus } from '../serial/devices.js';
3098
+
3099
+ const router = express.Router();
3100
+
3101
+ router.get('/devices', (req, res) => {
3102
+ res.json(listSerialDevices().map(({ recent, ...d }) => d));
3103
+ });
3104
+
3105
+ router.post('/devices/:name/send', async (req, res) => {
3106
+ const device = getDevice(req.params.name);
3107
+ if (!device) return res.status(404).json({ message: 'Unknown device' });
3108
+ if (!device.connected) return res.status(503).json({ message: 'Device offline' });
3109
+ const command = req.body.command;
3110
+ const timeout = req.body.timeout;
3111
+ if (typeof command !== 'string' || !command.trim()) {
3112
+ return res.status(400).json({ message: 'command is required' });
3113
+ }
3114
+ try {
3115
+ const reply = await device.send(command, { timeout });
3116
+ res.json({ reply });
3117
+ } catch (err) {
3118
+ res.status(504).json({ message: err.message });
3119
+ }
3120
+ });
3121
+
3122
+ router.post('/devices/:name/write', async (req, res) => {
3123
+ const device = getDevice(req.params.name);
3124
+ if (!device) return res.status(404).json({ message: 'Unknown device' });
3125
+ if (!device.connected) return res.status(503).json({ message: 'Device offline' });
3126
+ const data = req.body.data;
3127
+ if (typeof data !== 'string' || !data.trim()) {
3128
+ return res.status(400).json({ message: 'data is required' });
3129
+ }
3130
+ try {
3131
+ await device.write(data);
3132
+ res.json({ ok: true });
3133
+ } catch (err) {
3134
+ res.status(500).json({ message: err.message });
3135
+ }
3136
+ });
3137
+
3138
+ // Server-Sent Events: one "data: {json}" message per line / status change.
3139
+ function stream(req, res, only) {
3140
+ res.writeHead(200, {
3141
+ 'Content-Type': 'text/event-stream',
3142
+ 'Cache-Control': 'no-cache',
3143
+ Connection: 'keep-alive',
3144
+ 'X-Accel-Buffering': 'no',
3145
+ });
3146
+ res.write(': connected' + String.fromCharCode(10, 10));
3147
+ const onEvent = (e) => {
3148
+ if (only && e.device !== only) return;
3149
+ res.write('data: ' + JSON.stringify(e) + String.fromCharCode(10, 10));
3150
+ };
3151
+ serialBus.on('event', onEvent);
3152
+ const ping = setInterval(() => res.write(': ping' + String.fromCharCode(10, 10)), 25000);
3153
+ req.on('close', () => {
3154
+ clearInterval(ping);
3155
+ serialBus.off('event', onEvent);
3156
+ });
3157
+ }
3158
+
3159
+ router.get('/stream', (req, res) => stream(req, res, null));
3160
+
3161
+ router.get('/devices/:name/stream', (req, res) => {
3162
+ if (!getDevice(req.params.name)) return res.status(404).json({ message: 'Unknown device' });
3163
+ stream(req, res, req.params.name);
3164
+ });
3165
+
3166
+ export default router;
3167
+ `;
3168
+ }
3169
+
3170
+ // Validate + normalize one device entry for devices.json. Exactly one match
3171
+ // strategy is kept (vendorId may pair with identity to narrow the probe).
3172
+ // Throws a readable Error on invalid input.
3173
+ export function normalizeSerialDevice(input = {}) {
3174
+ const name = String(input.name || "").trim();
3175
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(name)) {
3176
+ throw new Error("Device name must start with a letter and use only letters, numbers, - or _ (max 40).");
3177
+ }
3178
+ const m = input.match || {};
3179
+ const clean = (v, max = 120) => String(v == null ? "" : v).replace(/[\r\n]/g, "").trim().slice(0, max);
3180
+ const hex = (v) => clean(v, 8).toLowerCase().replace(/^0x/, "");
3181
+ let match;
3182
+ if (clean(m.serialNumber)) match = { serialNumber: clean(m.serialNumber) };
3183
+ else if (clean(m.identity)) {
3184
+ match = { identity: clean(m.identity, 60) };
3185
+ if (hex(m.vendorId)) match.vendorId = hex(m.vendorId);
3186
+ } else if (hex(m.vendorId)) {
3187
+ match = { vendorId: hex(m.vendorId) };
3188
+ if (hex(m.productId)) match.productId = hex(m.productId);
3189
+ } else if (clean(m.path)) match = { path: clean(m.path, 200) };
3190
+ else throw new Error("Choose how to find the device: USB serial number, firmware identity, USB vendor id, or a fixed port path.");
3191
+ if (match.vendorId && !/^[0-9a-f]{4}$/.test(match.vendorId)) throw new Error("USB vendor id must be 4 hex digits, e.g. 10c4.");
3192
+ if (match.productId && !/^[0-9a-f]{4}$/.test(match.productId)) throw new Error("USB product id must be 4 hex digits.");
3193
+
3194
+ const baudRate = Number(input.baudRate) || 115200;
3195
+ if (!Number.isInteger(baudRate) || baudRate < 300 || baudRate > 4000000) throw new Error("Baud rate must match the firmware's Serial.begin(), e.g. 115200.");
3196
+ const timeout = Math.min(60000, Math.max(100, Number(input.timeout) || 2000));
3197
+
3198
+ // Saved commands: one-click buttons in the dashboard.
3199
+ const commands = (Array.isArray(input.commands) ? input.commands : []).slice(0, 50)
3200
+ .map((x) => ({ label: clean(x && x.label, 40), command: clean(x && x.command, 200), expectReply: !(x && x.expectReply === false) }))
3201
+ .filter((x) => x.command)
3202
+ .map((x) => ({ ...x, label: x.label || x.command }));
3203
+
3204
+ // Rules: when a received line matches, call a webhook or send a command.
3205
+ const rules = (Array.isArray(input.rules) ? input.rules : []).slice(0, 50).map((r, i) => {
3206
+ r = r || {};
3207
+ const when = SERIAL_RULE_WHEN.includes(r.when) ? r.when : "contains";
3208
+ const pattern = clean(r.pattern, 200);
3209
+ if (!pattern) throw new Error("Rule " + (i + 1) + ": a pattern is required.");
3210
+ if (when === "regex") {
3211
+ try { new RegExp(pattern); } catch { throw new Error("Rule " + (i + 1) + ": invalid regular expression."); }
3212
+ }
3213
+ const action = SERIAL_RULE_ACTIONS.includes(r.action) ? r.action : "webhook";
3214
+ const rule = {
3215
+ id: clean(r.id, 40).replace(/[^a-zA-Z0-9_-]/g, "") || "rule-" + (i + 1),
3216
+ enabled: r.enabled !== false,
3217
+ when,
3218
+ pattern,
3219
+ action,
3220
+ cooldownMs: Math.min(3600000, Math.max(0, Number(r.cooldownMs) >= 0 ? Number(r.cooldownMs) : 2000)),
3221
+ };
3222
+ if (action === "webhook") {
3223
+ const url = clean(r.url, 500);
3224
+ if (!/^https?:[/][/][^ ]+$/i.test(url)) throw new Error("Rule " + (i + 1) + ": webhook URL must start with http:// or https://.");
3225
+ rule.url = url;
3226
+ } else {
3227
+ rule.target = clean(r.target, 40);
3228
+ rule.command = clean(r.command, 200);
3229
+ if (!rule.target || !rule.command) throw new Error("Rule " + (i + 1) + ": choose a device and a command to send.");
3230
+ }
3231
+ return rule;
3232
+ });
3233
+
3234
+ const out = {
3235
+ name,
3236
+ match,
3237
+ baudRate,
3238
+ delimiter: input.delimiter === "crlf" ? "crlf" : "lf",
3239
+ idCommand: clean(input.idCommand, 40) || "ID?",
3240
+ timeout,
3241
+ enabled: input.enabled !== false,
3242
+ };
3243
+ // Lines that are never a reply (heartbeats, banners). Heartbeats are also learned automatically.
3244
+ const ignoreLines = (Array.isArray(input.ignoreLines) ? input.ignoreLines : String(input.ignoreLines || "").split(","))
3245
+ .map((x) => clean(x, 80)).filter(Boolean).slice(0, 20);
3246
+ if (ignoreLines.length) out.ignoreLines = ignoreLines;
3247
+ if (commands.length) out.commands = commands;
3248
+ if (rules.length) out.rules = rules;
3249
+ if (input.auto === true) out.auto = true;
3250
+ return out;
3251
+ }
3252
+
3253
+ export const SERIAL_IMPORTS = [
3254
+ 'import { startSerialDevices, stopSerialDevices } from "./src/serial/devices.js";',
3255
+ 'import serialRouter from "./src/routes/serial.js";',
3256
+ ];
3257
+
3258
+ // Idempotently wire the serial devices into index.js: mount the HTTP routes
3259
+ // (after the API key gate, so hardware control is protected), start the
3260
+ // devices at boot, and close their ports on shutdown / nodemon restart.
3261
+ export function wireSerialIntoIndex(content) {
3262
+ let out = content;
3263
+ for (const line of SERIAL_IMPORTS) out = addImportAfterLastImport(out, line);
3264
+ if (!out.includes("serialRouter)")) {
3265
+ out = insertBeforeListenContent(out, [
3266
+ "// ── Serial devices (src/serial/devices.json) ──",
3267
+ 'app.use("' + SERIAL_ROUTE_PREFIX + '", serialRouter);',
3268
+ 'startSerialDevices().catch((err) => console.error("serial:", err.message));',
3269
+ ].join("\n"));
3270
+ }
3271
+ if (!out.includes("stopSerialDevices()")) {
3272
+ if (out.includes("await destroyDiscovery();")) {
3273
+ // Reuse the existing shutdown handlers (SIGINT/SIGTERM + nodemon SIGUSR2).
3274
+ out = out.split("await destroyDiscovery();").join("await stopSerialDevices().catch(() => {}); await destroyDiscovery();");
3275
+ } else {
3276
+ out = out.trimEnd() + "\n\n" + [
3277
+ "// Close serial ports on shutdown so they're free on the next launch.",
3278
+ "let _serialShuttingDown = false;",
3279
+ "async function _serialShutdown() {",
3280
+ " if (_serialShuttingDown) return;",
3281
+ " _serialShuttingDown = true;",
3282
+ " await stopSerialDevices().catch(() => {});",
3283
+ " process.exit(0);",
3284
+ "}",
3285
+ 'process.on("SIGINT", _serialShutdown);',
3286
+ 'process.on("SIGTERM", _serialShutdown);',
3287
+ 'process.once("SIGUSR2", async () => {',
3288
+ " await stopSerialDevices().catch(() => {});",
3289
+ ' process.kill(process.pid, "SIGUSR2");',
3290
+ "});",
3291
+ ].join("\n") + "\n";
3292
+ }
3293
+ }
3294
+ return out;
3295
+ }
3296
+
3297
+ // Legacy (pre-device-manager) ports were inlined in index.js as
3298
+ // "const x = new SerialPort({ path: ..., baudRate: ... })". Detect them so the
3299
+ // dashboard can show and remove them.
3300
+ export function hasLegacySerialPorts(content) {
3301
+ return /new\s+SerialPort\(/.test(content);
3302
+ }