From b304e7d7ee374d651cbe04b903d703ba8129dd63 Mon Sep 17 00:00:00 2001 From: LeoLTM <44781682+LeoLTM@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:59:32 +0100 Subject: [PATCH 1/4] feat: add node native module "sero-node" and pipeline jobs --- .github/workflows/ci.yml | 17 + .github/workflows/release.yml | 40 ++ node-module/.gitignore | 36 + node-module/README.md | 140 ++++ node-module/binding.gyp | 29 + node-module/bun.lock | 149 ++++ node-module/index.ts | 448 ++++++++++++ node-module/package.json | 27 + node-module/src/sero-node.cpp | 1211 +++++++++++++++++++++++++++++++++ node-module/tsconfig.json | 29 + 10 files changed, 2126 insertions(+) create mode 100644 node-module/.gitignore create mode 100644 node-module/README.md create mode 100644 node-module/binding.gyp create mode 100644 node-module/bun.lock create mode 100644 node-module/index.ts create mode 100644 node-module/package.json create mode 100644 node-module/src/sero-node.cpp create mode 100644 node-module/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e93120..317aab7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,20 @@ jobs: - name: Test working-directory: build run: ctest --output-on-failure --parallel $(nproc 2>/dev/null || sysctl -n hw.logicalcpu) + + build-native-module: + name: Build native module + runs-on: ubuntu-latest + env: + WORKING_DIRECTORY: 'node-module' + + + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - name: Install dependencies + working-directory: ${{ env.WORKING_DIRECTORY }} + run: bun install + - name: Build + working-directory: ${{ env.WORKING_DIRECTORY }} + run: bun run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c885a21..4ffc56a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,3 +94,43 @@ jobs: sero-${{ steps.version.outputs.VERSION }}.zip draft: false prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }} + + build-and-publish-native-module: + name: Build and Publish Native Module + runs-on: ubuntu-latest + needs: + - build-and-release + env: + REGISTRY_URL: ${{ secrets.REGISTRY_URL }} + WORKING_DIRECTORY: 'node-module' + APP_NAME: 'sero-node' + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + registries: | + https://registry.npmjs.org/ + @asl-gokart:${{ env.REGISTRY_URL }} + - name: Install dependencies + working-directory: ${{ env.WORKING_DIRECTORY }} + run: bun install + - name: Build + working-directory: ${{ env.WORKING_DIRECTORY }} + run: bun run build + - name: Rename artifact + working-directory: ${{ env.WORKING_DIRECTORY }} + run: | + mv ./build/Release/${{ env.APP_NAME }}.node ./${{ env.APP_NAME }}-linux-x64.node + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.APP_NAME }}-linux-x64 + path: | + ${{ env.WORKING_DIRECTORY }}/${{ env.APP_NAME }}-linux-x64.node + if-no-files-found: error + - name: Publish to registry + working-directory: ${{ env.WORKING_DIRECTORY }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_TOKEN: ${{ secrets.NPM_REGISTRY_AUTH_TOKEN }} + run: bun publish --registry ${{ env.REGISTRY_URL }} --access public diff --git a/node-module/.gitignore b/node-module/.gitignore new file mode 100644 index 0000000..5f2e43c --- /dev/null +++ b/node-module/.gitignore @@ -0,0 +1,36 @@ +# dependencies (bun install) +node_modules + +# output +out +build +dist +*.tgz +index.js + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/node-module/README.md b/node-module/README.md new file mode 100644 index 0000000..d66cd07 --- /dev/null +++ b/node-module/README.md @@ -0,0 +1,140 @@ +# sero-node + +Node.js native bindings for the [sero](../sero) C++17 header-only protocol library. Exposes the full `sero::Runtime` API — service discovery, request/response, event pub/sub, HMAC authentication, and diagnostics — over an embedded POSIX UDP transport (unicast + multicast). + +## Prerequisites + +- Node.js ≥ 18 (or Bun) +- `node-gyp` build toolchain (Python 3, C++17 compiler) +- The `sero` library checked out at `../sero` (sibling directory) + +## Install & build + +```bash +bun install # or npm install +bun run build # or npm run build +``` + +For Electron: + +```bash +bun run electron-rebuild +``` + +## Quick start + +```ts +import { SeroRuntime, ReturnCode, DtcSeverity } from "./index"; + +// ── Create a runtime (binds UDP on port 30491) ────────────── +const rt = new SeroRuntime({ port: 30491, clientId: 0x7701 }); + +// ── Provider: register & offer a service ──────────────────── +rt.registerService(0x1000, { + onRequest(methodId, payload) { + if (methodId === 0x0001) { + return { returnCode: ReturnCode.E_OK, response: Buffer.from("pong") }; + } + return { returnCode: ReturnCode.E_UNKNOWN_METHOD }; + }, +}); +rt.offerService(0x1000, 30); + +// ── Consumer: discover & call a remote service ────────────── +rt.onServiceFound((serviceId, address) => { + console.log(`Found 0x${serviceId.toString(16)} at ${address.ip}:${address.port}`); +}); +rt.findService(0x0001); // e.g. ZC_LIGHTS_ID from ESP32 + +// Send a request (returns a Promise resolved during a future process() call) +const result = await rt.request(0x0001, 0x0002, Buffer.from([0x01]), 2000); +console.log("Response:", result.returnCode, result.payload); + +// ── Event subscription (consumer) ─────────────────────────── +rt.subscribeEvent(0x1000, 0x8001, (sid, eid, payload) => { + console.log("Event:", sid, eid, payload); +}); + +// ── Event notification (provider) ─────────────────────────── +rt.registerEvent(0x1000, 0x8001); +rt.notifyEvent(0x1000, 0x8001, Buffer.from([0x42])); + +// ── Drive the protocol at ~100 Hz ─────────────────────────── +setInterval(() => rt.process(), 10); + +// ── Cleanup ───────────────────────────────────────────────── +// rt.destroy(); +``` + +## API overview + +### `new SeroRuntime(options)` + +| Option | Type | Default | Description | +| ---------- | -------- | ----------- | ------------------------------------ | +| `bindIp` | `string` | `"0.0.0.0"` | IPv4 address to bind | +| `port` | `number` | — | UDP port for unicast traffic | +| `clientId` | `number` | — | Unique client identifier (uint16) | + +### Lifecycle + +| Method | Description | +| --------------- | ---------------------------------------------- | +| `process()` | Drive one protocol cycle (poll, dispatch, SD) | +| `destroy()` | Release all resources | + +### Provider + +| Method | Description | +| --------------------------------------------------- | -------------------------------------------- | +| `registerService(id, handler, opts?)` | Register a service implementation | +| `unregisterService(id)` | Unregister a service | +| `offerService(id, ttl?)` | Announce via Service Discovery | +| `stopOffer(id)` | Stop announcing | +| `registerEvent(serviceId, eventId)` | Register a producible event | +| `notifyEvent(serviceId, eventId, payload)` | Push notification to subscribers | + +### Consumer + +| Method | Description | +| ---------------------------------------------------------------------- | ------------------------------------- | +| `findService(id, major?)` | Search via SD | +| `request(serviceId, methodId, payload?, timeout?) → Promise` | Send request, await response | +| `requestTo(addr, serviceId, methodId, payload?, timeout?) → Promise` | Request to explicit address | +| `fireAndForget(serviceId, methodId, payload?)` | One-way request | +| `subscribeEvent(serviceId, eventId, handler, ttl?)` | Subscribe to remote event | +| `unsubscribeEvent(serviceId, eventId)` | Unsubscribe | + +### SD callbacks + +| Method | Callback signature | +| ----------------------- | --------------------------------------------------------------- | +| `onServiceFound(cb)` | `(serviceId: number, address: Address) => void` | +| `onServiceLost(cb)` | `(serviceId: number) => void` | +| `onSubscriptionAck(cb)` | `(serviceId, eventId, returnCode, grantedTtl) => void` | + +### Security + +| Method | Description | +| ----------------------------- | ------------------------------------ | +| `setHmacKey(peer, key)` | Set 32-byte HMAC key for a peer | + +### Diagnostics & DTCs + +| Method | Description | +| ------------------------------ | ------------------------------------------ | +| `onDiagnostic(cb)` | Callback on counter increment | +| `getDiagnostics()` | Snapshot of all counters | +| `enableDiagnostics(auth?)` | Enable built-in diagnostics service | +| `reportDtc(code, severity)` | Report a trouble code | +| `clearDtc(code)` | Clear one DTC | +| `clearAllDtcs()` | Clear all DTCs | +| `getDtcs()` | Read all active DTCs | + +### Constants + +Exported as frozen objects: `ReturnCode`, `DtcSeverity`, `DiagnosticCounter`, `MessageType`. + +## Compatibility with ESP32 devices + +This module uses the same wire format and multicast group (`239.0.0.1:30490`) as the ESP32 PlatformIO projects (`zc_buttons`, `zc_lights`). A desktop Node.js app using sero-node can discover, call methods on, and receive events from ESP32 devices on the same network — no configuration needed. diff --git a/node-module/binding.gyp b/node-module/binding.gyp new file mode 100644 index 0000000..57356b7 --- /dev/null +++ b/node-module/binding.gyp @@ -0,0 +1,29 @@ +{ + "targets": [ + { + "target_name": "sero-node", + "sources": [ "src/sero-node.cpp" ], + "include_dirs": [ + "= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "make-fetch-happen": ["make-fetch-happen@15.0.4", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw=="], + + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-addon-api": ["node-addon-api@8.6.0", "", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="], + + "node-gyp": ["node-gyp@12.2.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ=="], + + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + + "tar": ["tar@7.5.11", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unique-filename": ["unique-filename@5.0.0", "", { "dependencies": { "unique-slug": "^6.0.0" } }, "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg=="], + + "unique-slug": ["unique-slug@6.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw=="], + + "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + } +} diff --git a/node-module/index.ts b/node-module/index.ts new file mode 100644 index 0000000..c11b19a --- /dev/null +++ b/node-module/index.ts @@ -0,0 +1,448 @@ +/** + * sero-node — Node.js bindings for the sero protocol library. + * + * Provides a POSIX UDP transport (unicast + multicast) and wraps the + * sero::Runtime C++ API for use from JavaScript / TypeScript. + * + * @example + * ```ts + * import { SeroRuntime, ReturnCode } from './index'; + * + * const rt = new SeroRuntime({ port: 30491, clientId: 0x7701 }); + * + * // Consumer: discover remote services + * rt.onServiceFound((serviceId, address) => { + * console.log(`Found 0x${serviceId.toString(16)} at ${address.ip}:${address.port}`); + * }); + * rt.findService(0x0001); + * + * // Poll the protocol at ~100 Hz + * setInterval(() => rt.process(), 10); + * ``` + * + * @module sero-node + */ + +// ── Load the native addon ─────────────────────────────────────── + +const bindings: NativeModule = require("./build/Release/sero-node.node"); + +// ── Types ─────────────────────────────────────────────────────── + +/** Transport address: IPv4 + UDP port. */ +export interface Address { + ip: string; + port: number; +} + +/** Result of a completed request. */ +export interface RequestResult { + /** One of the ReturnCode values. */ + returnCode: number; + /** Response payload (empty Buffer when no payload). */ + payload: Buffer; +} + +/** Service handler — implement to expose a service. */ +export interface ServiceHandler { + /** + * Called for each incoming REQUEST or REQUEST_NO_RETURN. + * Return `{ returnCode, response? }`. + * + * For fire-and-forget calls the return value is ignored. + */ + onRequest( + methodId: number, + payload: Buffer + ): { returnCode: number; response?: Buffer }; + + /** Optional readiness gate — return `false` to reject requests with E_NOT_READY. */ + isReady?(): boolean; +} + +/** Stored Diagnostic Trouble Code. */ +export interface Dtc { + code: number; + severity: number; + occurrenceCount: number; + firstSeenMs: number; + lastSeenMs: number; +} + +/** Snapshot of all diagnostic counters. */ +export interface DiagnosticsSnapshot { + crcErrors: number; + versionMismatches: number; + oversizedPayloads: number; + typeIdMismatches: number; + duplicateMessages: number; + staleMessages: number; + authFailures: number; + unknownMessageTypes: number; + droppedMessages: number; + pendingRequests: number; +} + +/** Options for `registerService()`. */ +export interface RegisterServiceOptions { + majorVersion?: number; + minorVersion?: number; + authRequired?: boolean; +} + +/** Constructor options. */ +export interface SeroRuntimeOptions { + /** IPv4 address to bind (default `"0.0.0.0"`). */ + bindIp?: string; + /** UDP port for unicast traffic. */ + port: number; + /** Unique client identifier for this runtime instance. */ + clientId: number; +} + +// ── Native module shape (internal) ────────────────────────────── + +interface NativeModule { + SeroRuntime: new (opts: SeroRuntimeOptions) => NativeSeroRuntime; + ReturnCode: Record; + DtcSeverity: Record; + DiagnosticCounter: Record; + MessageType: Record; +} + +interface NativeSeroRuntime { + process(nowMs?: number): void; + destroy(): void; + + registerService( + serviceId: number, + handler: ServiceHandler, + options?: RegisterServiceOptions + ): boolean; + unregisterService(serviceId: number): boolean; + offerService(serviceId: number, ttlSeconds?: number): boolean; + stopOffer(serviceId: number): void; + + registerEvent(serviceId: number, eventId: number): boolean; + notifyEvent(serviceId: number, eventId: number, payload: Buffer): boolean; + + findService(serviceId: number, majorVersion?: number): boolean; + + request( + serviceId: number, + methodId: number, + payload: Buffer, + timeoutMs?: number + ): Promise; + requestTo( + address: Address, + serviceId: number, + methodId: number, + payload: Buffer, + timeoutMs?: number + ): Promise; + fireAndForget( + serviceId: number, + methodId: number, + payload?: Buffer + ): boolean; + + subscribeEvent( + serviceId: number, + eventId: number, + handler: (serviceId: number, eventId: number, payload: Buffer) => void, + ttlSeconds?: number + ): boolean; + unsubscribeEvent(serviceId: number, eventId: number): boolean; + + onServiceFound( + cb: (serviceId: number, address: Address) => void + ): void; + onServiceLost(cb: (serviceId: number) => void): void; + onSubscriptionAck( + cb: ( + serviceId: number, + eventId: number, + returnCode: number, + grantedTtl: number + ) => void + ): void; + + setHmacKey(peer: Address, key: Buffer): boolean; + + onDiagnostic( + cb: (counter: string, header: Buffer | null) => void + ): void; + getDiagnostics(): DiagnosticsSnapshot; + enableDiagnostics(authRequired?: boolean): boolean; + + reportDtc(code: number, severity: number): boolean; + clearDtc(code: number): boolean; + clearAllDtcs(): void; + getDtcs(): Dtc[]; + + setLocalAddress(address: Address): void; + getLocalAddress(): Address; +} + +// ── Public API (re-export the native class with TS types) ─────── + +/** + * Main entry point — wraps a sero::Runtime with an embedded UDP transport. + * + * Call `process()` at a regular interval (e.g. `setInterval(() => rt.process(), 10)`) + * to drive the protocol state machine (service discovery, request timeouts, + * event fan-out, etc.). + */ +export class SeroRuntime { + private native: NativeSeroRuntime; + + constructor(options: SeroRuntimeOptions) { + this.native = new bindings.SeroRuntime(options); + } + + // ── Lifecycle ─────────────────────────────────────────────── + + /** Drive one protocol cycle. Pass a custom monotonic timestamp (ms) or omit to use the system clock. */ + process(nowMs?: number): void { + this.native.process(nowMs); + } + + /** Release all resources (sockets, pending promises, registered services). */ + destroy(): void { + this.native.destroy(); + } + + // ── Provider API ──────────────────────────────────────────── + + /** Register a service implementation. */ + registerService( + serviceId: number, + handler: ServiceHandler, + options?: RegisterServiceOptions + ): boolean { + return this.native.registerService(serviceId, handler, options); + } + + /** Unregister a previously registered service. */ + unregisterService(serviceId: number): boolean { + return this.native.unregisterService(serviceId); + } + + /** Start offering a service via Service Discovery. */ + offerService(serviceId: number, ttlSeconds?: number): boolean { + return this.native.offerService(serviceId, ttlSeconds); + } + + /** Stop offering a service. */ + stopOffer(serviceId: number): void { + this.native.stopOffer(serviceId); + } + + /** Register an event that this device can produce (provider side). */ + registerEvent(serviceId: number, eventId: number): boolean { + return this.native.registerEvent(serviceId, eventId); + } + + /** Push a notification to all subscribers of a provider-side event. */ + notifyEvent( + serviceId: number, + eventId: number, + payload: Buffer = Buffer.alloc(0) + ): boolean { + return this.native.notifyEvent(serviceId, eventId, payload); + } + + // ── Consumer API ──────────────────────────────────────────── + + /** Start searching for a remote service via SD. */ + findService(serviceId: number, majorVersion?: number): boolean { + return this.native.findService(serviceId, majorVersion); + } + + /** + * Send a REQUEST and return a Promise that resolves when the RESPONSE + * (or ERROR / timeout) arrives during a future `process()` call. + */ + request( + serviceId: number, + methodId: number, + payload: Buffer = Buffer.alloc(0), + timeoutMs?: number + ): Promise { + return this.native.request(serviceId, methodId, payload, timeoutMs); + } + + /** Like `request()` but targets an explicit address (bypasses SD lookup). */ + requestTo( + address: Address, + serviceId: number, + methodId: number, + payload: Buffer = Buffer.alloc(0), + timeoutMs?: number + ): Promise { + return this.native.requestTo( + address, + serviceId, + methodId, + payload, + timeoutMs + ); + } + + /** Fire-and-forget request — no response expected. */ + fireAndForget( + serviceId: number, + methodId: number, + payload: Buffer = Buffer.alloc(0) + ): boolean { + return this.native.fireAndForget(serviceId, methodId, payload); + } + + /** Subscribe to a remote event (consumer side). */ + subscribeEvent( + serviceId: number, + eventId: number, + handler: (serviceId: number, eventId: number, payload: Buffer) => void, + ttlSeconds?: number + ): boolean { + return this.native.subscribeEvent(serviceId, eventId, handler, ttlSeconds); + } + + /** Unsubscribe from a remote event. */ + unsubscribeEvent(serviceId: number, eventId: number): boolean { + return this.native.unsubscribeEvent(serviceId, eventId); + } + + // ── Service Discovery callbacks ───────────────────────────── + + /** Called when a service is found via SD. */ + onServiceFound( + callback: (serviceId: number, address: Address) => void + ): void { + this.native.onServiceFound(callback); + } + + /** Called when a service TTL expires (service lost). */ + onServiceLost(callback: (serviceId: number) => void): void { + this.native.onServiceLost(callback); + } + + /** Called when an event subscription ACK is received. */ + onSubscriptionAck( + callback: ( + serviceId: number, + eventId: number, + returnCode: number, + grantedTtl: number + ) => void + ): void { + this.native.onSubscriptionAck(callback); + } + + // ── Security ──────────────────────────────────────────────── + + /** Set the HMAC-SHA256 key for a peer address (32 bytes). */ + setHmacKey(peer: Address, key: Buffer): boolean { + return this.native.setHmacKey(peer, key); + } + + // ── Diagnostics ───────────────────────────────────────────── + + /** Register a callback fired when any diagnostic counter is incremented. */ + onDiagnostic( + callback: (counter: string, header: Buffer | null) => void + ): void { + this.native.onDiagnostic(callback); + } + + /** Read all diagnostic counters as a snapshot object. */ + getDiagnostics(): DiagnosticsSnapshot { + return this.native.getDiagnostics(); + } + + /** Enable the built-in diagnostics service (0xFFFE). */ + enableDiagnostics(authRequired?: boolean): boolean { + return this.native.enableDiagnostics(authRequired); + } + + // ── DTCs ──────────────────────────────────────────────────── + + /** Report (or re-report) a Diagnostic Trouble Code. */ + reportDtc(code: number, severity: number): boolean { + return this.native.reportDtc(code, severity); + } + + /** Clear a single DTC by code. */ + clearDtc(code: number): boolean { + return this.native.clearDtc(code); + } + + /** Clear all DTCs. */ + clearAllDtcs(): void { + this.native.clearAllDtcs(); + } + + /** Read all active DTCs. */ + getDtcs(): Dtc[] { + return this.native.getDtcs(); + } + + // ── Address ───────────────────────────────────────────────── + + /** Override the local transport address (set automatically in constructor). */ + setLocalAddress(address: Address): void { + this.native.setLocalAddress(address); + } + + /** Get the local transport address. */ + getLocalAddress(): Address { + return this.native.getLocalAddress(); + } +} + +// ── Re-export constants ───────────────────────────────────────── + +/** Protocol return codes. */ +export const ReturnCode = bindings.ReturnCode as { + readonly E_OK: 0x00; + readonly E_NOT_OK: 0x01; + readonly E_UNKNOWN_SERVICE: 0x02; + readonly E_UNKNOWN_METHOD: 0x03; + readonly E_NOT_READY: 0x04; + readonly E_NOT_REACHABLE: 0x05; + readonly E_TIMEOUT: 0x06; + readonly E_MALFORMED_MESSAGE: 0x07; + readonly E_AUTH_FAILED: 0x08; + readonly E_DUPLICATE: 0x09; +}; + +/** DTC severity levels. */ +export const DtcSeverity = bindings.DtcSeverity as { + readonly Info: 0; + readonly Warning: 1; + readonly Error: 2; + readonly Fatal: 3; +}; + +/** Diagnostic counter indices. */ +export const DiagnosticCounter = bindings.DiagnosticCounter as { + readonly CrcErrors: 0; + readonly VersionMismatches: 1; + readonly OversizedPayloads: 2; + readonly TypeIdMismatches: 3; + readonly DuplicateMessages: 4; + readonly StaleMessages: 5; + readonly AuthFailures: 6; + readonly UnknownMessageTypes: 7; + readonly DroppedMessages: 8; +}; + +/** Protocol message types. */ +export const MessageType = bindings.MessageType as { + readonly REQUEST: 0x00; + readonly REQUEST_NO_RETURN: 0x01; + readonly RESPONSE: 0x02; + readonly NOTIFICATION: 0x03; + readonly ERROR: 0x80; +}; \ No newline at end of file diff --git a/node-module/package.json b/node-module/package.json new file mode 100644 index 0000000..a954fd9 --- /dev/null +++ b/node-module/package.json @@ -0,0 +1,27 @@ +{ + "name": "@asl-gokart/sero-node", + "version": "0.0.2", + + "description": "sero Node.js bindings", + "main": "index.js", + "publishConfig": { + "registry": "https://registry.leotm.de/", + "access": "public" + }, + "scripts": { + "build": "node-gyp rebuild && bun build ./index.ts --outdir . --target node --format cjs", + "build:ts": "bun build ./index.ts --outdir . --target node --format cjs", + "build:native": "node-gyp rebuild", + "electron-rebuild": "electron-rebuild -f -w sero-node" + }, + "devDependencies": { + "@types/bun": "latest", + "node-gyp": "^12.2.0" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "node-addon-api": "^8.6.0" + } +} diff --git a/node-module/src/sero-node.cpp b/node-module/src/sero-node.cpp new file mode 100644 index 0000000..2af28d4 --- /dev/null +++ b/node-module/src/sero-node.cpp @@ -0,0 +1,1211 @@ +/// @file sero-node.cpp +/// Node.js N-API wrapper for the sero C++17 header-only protocol library. +/// +/// Embeds a POSIX UDP transport (unicast + multicast) and exposes the +/// sero::Runtime API as a JavaScript ObjectWrap class. +/// +/// Usage from JS/TS: +/// const { SeroRuntime } = require('./build/Release/sero-node'); +/// const rt = new SeroRuntime({ bindIp: '0.0.0.0', port: 30491, clientId: 0x7701 }); +/// setInterval(() => rt.process(), 10); + +#include +#include "sero.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ══════════════════════════════════════════════════════════════════ +// Monotonic clock +// ══════════════════════════════════════════════════════════════════ + +static uint32_t now_ms() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec * 1000 + ts.tv_nsec / 1000000); +} + +// ══════════════════════════════════════════════════════════════════ +// Configuration +// ══════════════════════════════════════════════════════════════════ + +// Desktop-oriented config (matches sero::DefaultConfig). +using Config = sero::DefaultConfig; +using Addr = sero::Address; + +// ══════════════════════════════════════════════════════════════════ +// Address helpers +// ══════════════════════════════════════════════════════════════════ + +/// 8-byte wire format: [IPv4 (4B)] [port big-endian (2B)] [padding (2B)] + +static Addr make_addr(const char* ip, uint16_t port) { + Addr a{}; + inet_pton(AF_INET, ip, a.data()); + a[4] = static_cast(port >> 8); + a[5] = static_cast(port & 0xFF); + return a; +} + +static sockaddr_in addr_to_sockaddr(const Addr& a) { + sockaddr_in sa{}; + sa.sin_family = AF_INET; + std::memcpy(&sa.sin_addr, a.data(), 4); + sa.sin_port = htons(static_cast((a[4] << 8) | a[5])); + return sa; +} + +static Addr sockaddr_to_addr(const sockaddr_in& sa) { + Addr a{}; + std::memcpy(a.data(), &sa.sin_addr, 4); + uint16_t port = ntohs(sa.sin_port); + a[4] = static_cast(port >> 8); + a[5] = static_cast(port & 0xFF); + return a; +} + +/// Convert JS { ip: string, port: number } → 8-byte Addr. +static Addr js_to_addr(Napi::Env env, Napi::Value val) { + auto obj = val.As(); + std::string ip = obj.Get("ip").As().Utf8Value(); + uint16_t port = static_cast( + obj.Get("port").As().Uint32Value()); + return make_addr(ip.c_str(), port); +} + +/// Convert 8-byte Addr → JS { ip: string, port: number }. +static Napi::Object addr_to_js(Napi::Env env, const Addr& a) { + char ip_str[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, a.data(), ip_str, sizeof(ip_str)); + uint16_t port = static_cast((a[4] << 8) | a[5]); + + auto obj = Napi::Object::New(env); + obj.Set("ip", Napi::String::New(env, ip_str)); + obj.Set("port", Napi::Number::New(env, port)); + return obj; +} + +// ══════════════════════════════════════════════════════════════════ +// POSIX UDP Transport (desktop, based on sero/examples/udp_transport.hpp) +// ══════════════════════════════════════════════════════════════════ + +static constexpr const char* MCAST_GROUP = "239.0.0.1"; +static constexpr uint16_t MCAST_PORT = 30490; + +class NodeUdpTransport + : public sero::ITransport { +public: + static constexpr std::size_t MAX_BUF = + sero::HEADER_SIZE + Config::MaxPayloadSize + sero::HMAC_SIZE + sero::CRC_SIZE; + + NodeUdpTransport() = default; + ~NodeUdpTransport() { shutdown(); } + + bool init(const char* bind_ip, uint16_t bind_port) { + // ── Unicast socket ────────────────────────────────────── + unicast_fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); + if (unicast_fd_ < 0) return false; + + int reuse = 1; + setsockopt(unicast_fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(unicast_fd_, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + set_nonblocking(unicast_fd_); + + sockaddr_in uaddr{}; + uaddr.sin_family = AF_INET; + uaddr.sin_port = htons(bind_port); + inet_pton(AF_INET, bind_ip, &uaddr.sin_addr); + if (::bind(unicast_fd_, reinterpret_cast(&uaddr), + sizeof(uaddr)) < 0) { + return false; + } + + // ── Multicast socket ──────────────────────────────────── + mcast_fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); + if (mcast_fd_ < 0) return false; + + setsockopt(mcast_fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(mcast_fd_, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + set_nonblocking(mcast_fd_); + + sockaddr_in maddr{}; + maddr.sin_family = AF_INET; + maddr.sin_port = htons(MCAST_PORT); + maddr.sin_addr.s_addr = htonl(INADDR_ANY); + if (::bind(mcast_fd_, reinterpret_cast(&maddr), + sizeof(maddr)) < 0) { + return false; + } + + ip_mreq mreq{}; + inet_pton(AF_INET, MCAST_GROUP, &mreq.imr_multiaddr); + inet_pton(AF_INET, bind_ip, &mreq.imr_interface); + if (setsockopt(mcast_fd_, IPPROTO_IP, IP_ADD_MEMBERSHIP, + &mreq, sizeof(mreq)) < 0) { + return false; + } + + in_addr mcast_if{}; + inet_pton(AF_INET, bind_ip, &mcast_if); + setsockopt(unicast_fd_, IPPROTO_IP, IP_MULTICAST_IF, + &mcast_if, sizeof(mcast_if)); + + uint8_t loop = 1; + setsockopt(unicast_fd_, IPPROTO_IP, IP_MULTICAST_LOOP, + &loop, sizeof(loop)); + + local_addr_ = make_addr(bind_ip, bind_port); + return true; + } + + void shutdown() { + if (unicast_fd_ >= 0) { ::close(unicast_fd_); unicast_fd_ = -1; } + if (mcast_fd_ >= 0) { ::close(mcast_fd_); mcast_fd_ = -1; } + } + + const Addr& local_addr() const { return local_addr_; } + + // ── CRTP implementation ───────────────────────────────────── + + bool impl_send(const Addr& dest, const uint8_t* data, std::size_t len) { + sockaddr_in sa = addr_to_sockaddr(dest); + ssize_t n = ::sendto(unicast_fd_, data, len, 0, + reinterpret_cast(&sa), sizeof(sa)); + return n == static_cast(len); + } + + bool impl_broadcast(const uint8_t* data, std::size_t len) { + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_port = htons(MCAST_PORT); + inet_pton(AF_INET, MCAST_GROUP, &sa.sin_addr); + ssize_t n = ::sendto(unicast_fd_, data, len, 0, + reinterpret_cast(&sa), sizeof(sa)); + return n == static_cast(len); + } + + bool impl_poll(Addr& source, const uint8_t*& data, std::size_t& len) { + if (try_recv(mcast_fd_, source, data, len)) return true; + if (try_recv(unicast_fd_, source, data, len)) return true; + return false; + } + +private: + int unicast_fd_ = -1; + int mcast_fd_ = -1; + Addr local_addr_{}; + uint8_t recv_buf_[MAX_BUF]{}; + + static void set_nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + } + + bool try_recv(int fd, Addr& source, const uint8_t*& data, std::size_t& len) { + sockaddr_in from{}; + socklen_t fromlen = sizeof(from); + ssize_t n = ::recvfrom(fd, recv_buf_, sizeof(recv_buf_), 0, + reinterpret_cast(&from), &fromlen); + if (n <= 0) return false; + source = sockaddr_to_addr(from); + data = recv_buf_; + len = static_cast(n); + return true; + } +}; + +// ══════════════════════════════════════════════════════════════════ +// Type aliases for the concrete Runtime +// ══════════════════════════════════════════════════════════════════ + +using NodeRuntime = sero::Runtime; + +// ══════════════════════════════════════════════════════════════════ +// JsService — wraps a JS callback into sero::IService<> +// ══════════════════════════════════════════════════════════════════ + +class JsService : public sero::IService { +public: + explicit JsService(Napi::Function on_request, Napi::Function is_ready) + : on_request_cb_(Napi::Persistent(on_request)) + { + on_request_cb_.SuppressDestruct(); + if (!is_ready.IsUndefined() && !is_ready.IsNull()) { + is_ready_cb_ = Napi::Persistent(is_ready); + is_ready_cb_.SuppressDestruct(); + } + } + + ~JsService() = default; + + sero::ReturnCode impl_on_request(uint16_t method_id, + const uint8_t* payload, + std::size_t payload_length, + uint8_t* response, + std::size_t& response_length) + { + Napi::Env env = on_request_cb_.Env(); + Napi::HandleScope scope(env); + + auto js_payload = Napi::Buffer::Copy(env, payload, payload_length); + Napi::Value result = on_request_cb_.Call( + {Napi::Number::New(env, method_id), js_payload}); + + if (result.IsObject()) { + auto obj = result.As(); + uint8_t rc = static_cast( + obj.Get("returnCode").As().Uint32Value()); + + if (obj.Has("response") && !obj.Get("response").IsUndefined() + && !obj.Get("response").IsNull()) + { + auto resp_buf = obj.Get("response").As>(); + std::size_t len = resp_buf.Length(); + if (len > Config::MaxPayloadSize) len = Config::MaxPayloadSize; + std::memcpy(response, resp_buf.Data(), len); + response_length = len; + } else { + response_length = 0; + } + return static_cast(rc); + } + + response_length = 0; + return sero::ReturnCode::E_NOT_OK; + } + + bool impl_is_ready() const { + if (is_ready_cb_.IsEmpty()) return true; + Napi::Env env = is_ready_cb_.Env(); + Napi::HandleScope scope(env); + Napi::Value val = is_ready_cb_.Call({}); + return val.ToBoolean().Value(); + } + +private: + Napi::FunctionReference on_request_cb_; + Napi::FunctionReference is_ready_cb_; +}; + +// ══════════════════════════════════════════════════════════════════ +// JsEventHandler — wraps a JS callback into sero::IEventHandler<> +// ══════════════════════════════════════════════════════════════════ + +class JsEventHandler : public sero::IEventHandler { +public: + explicit JsEventHandler(Napi::Function callback) + : callback_(Napi::Persistent(callback)) + { + callback_.SuppressDestruct(); + } + + ~JsEventHandler() = default; + + void impl_on_event(uint16_t service_id, uint16_t event_id, + const uint8_t* payload, std::size_t payload_length) + { + Napi::Env env = callback_.Env(); + Napi::HandleScope scope(env); + callback_.Call({ + Napi::Number::New(env, service_id), + Napi::Number::New(env, event_id), + Napi::Buffer::Copy(env, payload, payload_length) + }); + } + +private: + Napi::FunctionReference callback_; +}; + +// ══════════════════════════════════════════════════════════════════ +// PendingRequest — links a sero request to a JS Promise +// ══════════════════════════════════════════════════════════════════ + +class SeroRuntime; // forward + +struct PendingRequest { + Napi::Promise::Deferred deferred; + SeroRuntime* owner; + + PendingRequest(Napi::Env env, SeroRuntime* o) + : deferred(Napi::Promise::Deferred::New(env)), owner(o) {} +}; + +// ══════════════════════════════════════════════════════════════════ +// Diagnostic counter name helper +// ══════════════════════════════════════════════════════════════════ + +static const char* diag_counter_name(sero::DiagnosticCounter c) { + switch (c) { + case sero::DiagnosticCounter::CrcErrors: return "crcErrors"; + case sero::DiagnosticCounter::VersionMismatches: return "versionMismatches"; + case sero::DiagnosticCounter::OversizedPayloads: return "oversizedPayloads"; + case sero::DiagnosticCounter::TypeIdMismatches: return "typeIdMismatches"; + case sero::DiagnosticCounter::DuplicateMessages: return "duplicateMessages"; + case sero::DiagnosticCounter::StaleMessages: return "staleMessages"; + case sero::DiagnosticCounter::AuthFailures: return "authFailures"; + case sero::DiagnosticCounter::UnknownMessageTypes: return "unknownMessageTypes"; + case sero::DiagnosticCounter::DroppedMessages: return "droppedMessages"; + default: return "unknown"; + } +} + +// ══════════════════════════════════════════════════════════════════ +// SeroRuntime — main ObjectWrap binding +// ══════════════════════════════════════════════════════════════════ + +class SeroRuntime : public Napi::ObjectWrap { +public: + static Napi::Object Init(Napi::Env env, Napi::Object exports) { + Napi::Function func = DefineClass(env, "SeroRuntime", { + // Main loop + InstanceMethod<&SeroRuntime::Process>("process"), + InstanceMethod<&SeroRuntime::Destroy>("destroy"), + + // Provider — services + InstanceMethod<&SeroRuntime::RegisterService>("registerService"), + InstanceMethod<&SeroRuntime::UnregisterService>("unregisterService"), + InstanceMethod<&SeroRuntime::OfferService>("offerService"), + InstanceMethod<&SeroRuntime::StopOffer>("stopOffer"), + + // Provider — events + InstanceMethod<&SeroRuntime::RegisterEvent>("registerEvent"), + InstanceMethod<&SeroRuntime::NotifyEvent>("notifyEvent"), + + // Consumer — service discovery + InstanceMethod<&SeroRuntime::FindService>("findService"), + + // Consumer — requests + InstanceMethod<&SeroRuntime::Request>("request"), + InstanceMethod<&SeroRuntime::RequestTo>("requestTo"), + InstanceMethod<&SeroRuntime::FireAndForget>("fireAndForget"), + + // Consumer — event subscription + InstanceMethod<&SeroRuntime::SubscribeEvent>("subscribeEvent"), + InstanceMethod<&SeroRuntime::UnsubscribeEvent>("unsubscribeEvent"), + + // SD callbacks + InstanceMethod<&SeroRuntime::OnServiceFound>("onServiceFound"), + InstanceMethod<&SeroRuntime::OnServiceLost>("onServiceLost"), + InstanceMethod<&SeroRuntime::OnSubscriptionAck>("onSubscriptionAck"), + + // Security + InstanceMethod<&SeroRuntime::SetHmacKey>("setHmacKey"), + + // Diagnostics + InstanceMethod<&SeroRuntime::OnDiagnostic>("onDiagnostic"), + InstanceMethod<&SeroRuntime::GetDiagnostics>("getDiagnostics"), + InstanceMethod<&SeroRuntime::EnableDiagnostics>("enableDiagnostics"), + + // DTCs + InstanceMethod<&SeroRuntime::ReportDtc>("reportDtc"), + InstanceMethod<&SeroRuntime::ClearDtc>("clearDtc"), + InstanceMethod<&SeroRuntime::ClearAllDtcs>("clearAllDtcs"), + InstanceMethod<&SeroRuntime::GetDtcs>("getDtcs"), + + // Address + InstanceMethod<&SeroRuntime::SetLocalAddress>("setLocalAddress"), + InstanceMethod<&SeroRuntime::GetLocalAddress>("getLocalAddress"), + }); + + // Export constants alongside the class + + // ReturnCode + auto rc = Napi::Object::New(env); + rc.Set("E_OK", Napi::Number::New(env, 0x00)); + rc.Set("E_NOT_OK", Napi::Number::New(env, 0x01)); + rc.Set("E_UNKNOWN_SERVICE", Napi::Number::New(env, 0x02)); + rc.Set("E_UNKNOWN_METHOD", Napi::Number::New(env, 0x03)); + rc.Set("E_NOT_READY", Napi::Number::New(env, 0x04)); + rc.Set("E_NOT_REACHABLE", Napi::Number::New(env, 0x05)); + rc.Set("E_TIMEOUT", Napi::Number::New(env, 0x06)); + rc.Set("E_MALFORMED_MESSAGE", Napi::Number::New(env, 0x07)); + rc.Set("E_AUTH_FAILED", Napi::Number::New(env, 0x08)); + rc.Set("E_DUPLICATE", Napi::Number::New(env, 0x09)); + exports.Set("ReturnCode", rc); + + // DtcSeverity + auto sev = Napi::Object::New(env); + sev.Set("Info", Napi::Number::New(env, 0)); + sev.Set("Warning", Napi::Number::New(env, 1)); + sev.Set("Error", Napi::Number::New(env, 2)); + sev.Set("Fatal", Napi::Number::New(env, 3)); + exports.Set("DtcSeverity", sev); + + // DiagnosticCounter + auto dc = Napi::Object::New(env); + dc.Set("CrcErrors", Napi::Number::New(env, 0)); + dc.Set("VersionMismatches", Napi::Number::New(env, 1)); + dc.Set("OversizedPayloads", Napi::Number::New(env, 2)); + dc.Set("TypeIdMismatches", Napi::Number::New(env, 3)); + dc.Set("DuplicateMessages", Napi::Number::New(env, 4)); + dc.Set("StaleMessages", Napi::Number::New(env, 5)); + dc.Set("AuthFailures", Napi::Number::New(env, 6)); + dc.Set("UnknownMessageTypes", Napi::Number::New(env, 7)); + dc.Set("DroppedMessages", Napi::Number::New(env, 8)); + exports.Set("DiagnosticCounter", dc); + + // MessageType + auto mt = Napi::Object::New(env); + mt.Set("REQUEST", Napi::Number::New(env, 0x00)); + mt.Set("REQUEST_NO_RETURN", Napi::Number::New(env, 0x01)); + mt.Set("RESPONSE", Napi::Number::New(env, 0x02)); + mt.Set("NOTIFICATION", Napi::Number::New(env, 0x03)); + mt.Set("ERROR", Napi::Number::New(env, 0x80)); + exports.Set("MessageType", mt); + + exports.Set("SeroRuntime", func); + return exports; + } + + // ── Constructor ───────────────────────────────────────────── + + SeroRuntime(const Napi::CallbackInfo& info) + : Napi::ObjectWrap(info) + { + Napi::Env env = info.Env(); + + if (info.Length() < 1 || !info[0].IsObject()) { + Napi::TypeError::New(env, + "Expected options object: { bindIp?: string, port: number, clientId: number }") + .ThrowAsJavaScriptException(); + return; + } + + auto opts = info[0].As(); + + std::string bind_ip = "0.0.0.0"; + if (opts.Has("bindIp") && !opts.Get("bindIp").IsUndefined()) { + bind_ip = opts.Get("bindIp").As().Utf8Value(); + } + + if (!opts.Has("port")) { + Napi::TypeError::New(env, "Missing required option: port") + .ThrowAsJavaScriptException(); + return; + } + uint16_t port = static_cast( + opts.Get("port").As().Uint32Value()); + + if (!opts.Has("clientId")) { + Napi::TypeError::New(env, "Missing required option: clientId") + .ThrowAsJavaScriptException(); + return; + } + uint16_t client_id = static_cast( + opts.Get("clientId").As().Uint32Value()); + + // Initialize transport + if (!transport_.init(bind_ip.c_str(), port)) { + Napi::Error::New(env, + "Failed to initialise UDP transport on " + bind_ip + ":" + + std::to_string(port)) + .ThrowAsJavaScriptException(); + return; + } + + // Create runtime + runtime_ = std::make_unique(transport_, client_id); + runtime_->set_local_address(transport_.local_addr()); + + // Wire up SD callbacks pointing to our static trampolines + auto& sd = runtime_->sd_callbacks(); + sd.on_service_found = sd_on_service_found; + sd.service_found_ctx = this; + sd.on_service_lost = sd_on_service_lost; + sd.service_lost_ctx = this; + sd.on_subscription_ack = sd_on_subscription_ack; + sd.subscription_ack_ctx = this; + + // Wire up diagnostic callback + runtime_->set_diagnostic_callback(diag_callback_trampoline, this); + } + + ~SeroRuntime() { + do_destroy(); + } + + // ── process(nowMs?) ───────────────────────────────────────── + + Napi::Value Process(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint32_t now = now_ms(); + if (info.Length() > 0 && info[0].IsNumber()) { + now = info[0].As().Uint32Value(); + } + + current_env_ = env; + runtime_->process(now); + current_env_ = nullptr; + + return env.Undefined(); + } + + // ── destroy() ─────────────────────────────────────────────── + + Napi::Value Destroy(const Napi::CallbackInfo& info) { + do_destroy(); + return info.Env().Undefined(); + } + + // ── registerService(serviceId, handler, options?) ─────────── + // handler: { onRequest(methodId, payload) → { returnCode, response? }, isReady?() → bool } + // options: { majorVersion?, minorVersion?, authRequired? } + + Napi::Value RegisterService(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + if (info.Length() < 2) { + Napi::TypeError::New(env, + "registerService(serviceId, handler, options?)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + + auto handler_obj = info[1].As(); + Napi::Function on_request_fn = + handler_obj.Get("onRequest").As(); + Napi::Value is_ready_val = handler_obj.Get("isReady"); + Napi::Function is_ready_fn = + is_ready_val.IsFunction() ? is_ready_val.As() + : Napi::Function(); + + uint8_t major = 1, minor = 0; + bool auth_required = false; + + if (info.Length() > 2 && info[2].IsObject()) { + auto opts = info[2].As(); + if (opts.Has("majorVersion")) + major = static_cast( + opts.Get("majorVersion").As().Uint32Value()); + if (opts.Has("minorVersion")) + minor = static_cast( + opts.Get("minorVersion").As().Uint32Value()); + if (opts.Has("authRequired")) + auth_required = opts.Get("authRequired").As().Value(); + } + + // Allocate the JsService on the heap (must outlive the registration) + auto svc = std::make_unique(on_request_fn, is_ready_fn); + bool ok = runtime_->register_service(service_id, *svc, + major, minor, auth_required); + if (ok) { + services_[service_id] = std::move(svc); + } + return Napi::Boolean::New(env, ok); + } + + // ── unregisterService(serviceId) ──────────────────────────── + + Napi::Value UnregisterService(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + bool ok = runtime_->unregister_service(service_id); + services_.erase(service_id); + return Napi::Boolean::New(env, ok); + } + + // ── offerService(serviceId, ttlSeconds?) ──────────────────── + + Napi::Value OfferService(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint16_t ttl = Config::OfferTtlSeconds; + if (info.Length() > 1 && info[1].IsNumber()) { + ttl = static_cast( + info[1].As().Uint32Value()); + } + return Napi::Boolean::New(env, + runtime_->offer_service(service_id, ttl, now_ms())); + } + + // ── stopOffer(serviceId) ──────────────────────────────────── + + Napi::Value StopOffer(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + runtime_->stop_offer(service_id); + return env.Undefined(); + } + + // ── registerEvent(serviceId, eventId) ─────────────────────── + + Napi::Value RegisterEvent(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint16_t event_id = static_cast( + info[1].As().Uint32Value()); + return Napi::Boolean::New(env, + runtime_->register_event(service_id, event_id)); + } + + // ── notifyEvent(serviceId, eventId, payload) ──────────────── + + Napi::Value NotifyEvent(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint16_t event_id = static_cast( + info[1].As().Uint32Value()); + + const uint8_t* data = nullptr; + std::size_t len = 0; + if (info.Length() > 2 && info[2].IsBuffer()) { + auto buf = info[2].As>(); + data = buf.Data(); + len = buf.Length(); + } + return Napi::Boolean::New(env, + runtime_->notify_event(service_id, event_id, data, len, now_ms())); + } + + // ── findService(serviceId, majorVersion?) ─────────────────── + + Napi::Value FindService(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint8_t major = 1; + if (info.Length() > 1 && info[1].IsNumber()) { + major = static_cast( + info[1].As().Uint32Value()); + } + return Napi::Boolean::New(env, + runtime_->find_service(service_id, major, now_ms())); + } + + // ── request(serviceId, methodId, payload, timeoutMs?) → Promise + + Napi::Value Request(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint16_t method_id = static_cast( + info[1].As().Uint32Value()); + + const uint8_t* data = nullptr; + std::size_t len = 0; + if (info[2].IsBuffer()) { + auto buf = info[2].As>(); + data = buf.Data(); + len = buf.Length(); + } + + uint32_t timeout_ms = Config::RequestTimeoutMs; + if (info.Length() > 3 && info[3].IsNumber()) { + timeout_ms = info[3].As().Uint32Value(); + } + + auto* pending = new PendingRequest(env, this); + pending_requests_.insert(pending); + + uint32_t now = now_ms(); + auto rid = runtime_->request(service_id, method_id, + data, len, + request_complete_cb, pending, + timeout_ms, now); + if (!rid) { + pending_requests_.erase(pending); + auto promise = pending->deferred.Promise(); + pending->deferred.Reject( + Napi::Error::New(env, "Request failed (service not found or table full)") + .Value()); + delete pending; + return promise; + } + return pending->deferred.Promise(); + } + + // ── requestTo(address, serviceId, methodId, payload, timeoutMs?) → Promise + + Napi::Value RequestTo(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + Addr target = js_to_addr(env, info[0]); + uint16_t service_id = static_cast( + info[1].As().Uint32Value()); + uint16_t method_id = static_cast( + info[2].As().Uint32Value()); + + const uint8_t* data = nullptr; + std::size_t len = 0; + if (info[3].IsBuffer()) { + auto buf = info[3].As>(); + data = buf.Data(); + len = buf.Length(); + } + + uint32_t timeout_ms = Config::RequestTimeoutMs; + if (info.Length() > 4 && info[4].IsNumber()) { + timeout_ms = info[4].As().Uint32Value(); + } + + auto* pending = new PendingRequest(env, this); + pending_requests_.insert(pending); + + uint32_t now = now_ms(); + auto rid = runtime_->request(target, service_id, method_id, + data, len, + request_complete_cb, pending, + timeout_ms, now); + if (!rid) { + pending_requests_.erase(pending); + auto promise = pending->deferred.Promise(); + pending->deferred.Reject( + Napi::Error::New(env, "Request failed (table full)") + .Value()); + delete pending; + return promise; + } + return pending->deferred.Promise(); + } + + // ── fireAndForget(serviceId, methodId, payload?) ──────────── + + Napi::Value FireAndForget(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint16_t method_id = static_cast( + info[1].As().Uint32Value()); + + const uint8_t* data = nullptr; + std::size_t len = 0; + if (info.Length() > 2 && info[2].IsBuffer()) { + auto buf = info[2].As>(); + data = buf.Data(); + len = buf.Length(); + } + + return Napi::Boolean::New(env, + runtime_->fire_and_forget(service_id, method_id, data, len)); + } + + // ── subscribeEvent(serviceId, eventId, handler, ttlSeconds?) ── + + Napi::Value SubscribeEvent(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint16_t event_id = static_cast( + info[1].As().Uint32Value()); + Napi::Function handler = info[2].As(); + + uint16_t ttl = Config::SubscriptionTtlSeconds; + if (info.Length() > 3 && info[3].IsNumber()) { + ttl = static_cast( + info[3].As().Uint32Value()); + } + + auto eh = std::make_unique(handler); + uint32_t key = event_handler_key(service_id, event_id); + bool ok = runtime_->subscribe_event(service_id, event_id, + *eh, ttl, now_ms()); + if (ok) { + event_handlers_[key] = std::move(eh); + } + return Napi::Boolean::New(env, ok); + } + + // ── unsubscribeEvent(serviceId, eventId) ──────────────────── + + Napi::Value UnsubscribeEvent(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t service_id = static_cast( + info[0].As().Uint32Value()); + uint16_t event_id = static_cast( + info[1].As().Uint32Value()); + + bool ok = runtime_->unsubscribe_event(service_id, event_id); + event_handlers_.erase(event_handler_key(service_id, event_id)); + return Napi::Boolean::New(env, ok); + } + + // ── SD callback setters ───────────────────────────────────── + + Napi::Value OnServiceFound(const Napi::CallbackInfo& info) { + on_service_found_cb_ = Napi::Persistent(info[0].As()); + on_service_found_cb_.SuppressDestruct(); + return info.Env().Undefined(); + } + + Napi::Value OnServiceLost(const Napi::CallbackInfo& info) { + on_service_lost_cb_ = Napi::Persistent(info[0].As()); + on_service_lost_cb_.SuppressDestruct(); + return info.Env().Undefined(); + } + + Napi::Value OnSubscriptionAck(const Napi::CallbackInfo& info) { + on_subscription_ack_cb_ = Napi::Persistent(info[0].As()); + on_subscription_ack_cb_.SuppressDestruct(); + return info.Env().Undefined(); + } + + // ── Security ──────────────────────────────────────────────── + + Napi::Value SetHmacKey(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + Addr peer = js_to_addr(env, info[0]); + auto key_buf = info[1].As>(); + + if (key_buf.Length() < Config::HmacKeySize) { + Napi::TypeError::New(env, + "HMAC key must be at least " + + std::to_string(Config::HmacKeySize) + " bytes") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + return Napi::Boolean::New(env, + runtime_->set_hmac_key(peer, key_buf.Data())); + } + + // ── Diagnostics ───────────────────────────────────────────── + + Napi::Value OnDiagnostic(const Napi::CallbackInfo& info) { + on_diagnostic_cb_ = Napi::Persistent(info[0].As()); + on_diagnostic_cb_.SuppressDestruct(); + return info.Env().Undefined(); + } + + Napi::Value GetDiagnostics(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + auto result = Napi::Object::New(env); + const auto& d = runtime_->diagnostics(); + for (uint8_t i = 0; + i < static_cast(sero::DiagnosticCounter::_Count); ++i) + { + auto c = static_cast(i); + result.Set(diag_counter_name(c), + Napi::Number::New(env, d.get(c))); + } + result.Set("pendingRequests", + Napi::Number::New(env, + static_cast(runtime_->request_tracker().active_count()))); + return result; + } + + Napi::Value EnableDiagnostics(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + bool auth_required = false; + if (info.Length() > 0 && info[0].IsBoolean()) { + auth_required = info[0].As().Value(); + } + return Napi::Boolean::New(env, + runtime_->enable_diagnostics(now_ms(), auth_required)); + } + + // ── DTCs ──────────────────────────────────────────────────── + + Napi::Value ReportDtc(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t code = static_cast( + info[0].As().Uint32Value()); + auto severity = static_cast( + info[1].As().Uint32Value()); + return Napi::Boolean::New(env, + runtime_->report_dtc(code, severity, now_ms())); + } + + Napi::Value ClearDtc(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + uint16_t code = static_cast( + info[0].As().Uint32Value()); + return Napi::Boolean::New(env, runtime_->clear_dtc(code)); + } + + Napi::Value ClearAllDtcs(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + runtime_->clear_all_dtcs(); + return env.Undefined(); + } + + Napi::Value GetDtcs(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + const auto& store = runtime_->dtc_store(); + auto arr = Napi::Array::New(env, store.count()); + uint32_t idx = 0; + store.for_each([&](const sero::Dtc& dtc) { + auto obj = Napi::Object::New(env); + obj.Set("code", Napi::Number::New(env, dtc.code)); + obj.Set("severity", Napi::Number::New(env, dtc.severity)); + obj.Set("occurrenceCount", + Napi::Number::New(env, dtc.occurrence_count)); + obj.Set("firstSeenMs", Napi::Number::New(env, dtc.first_seen_ms)); + obj.Set("lastSeenMs", Napi::Number::New(env, dtc.last_seen_ms)); + arr.Set(idx++, obj); + }); + return arr; + } + + // ── Address ───────────────────────────────────────────────── + + Napi::Value SetLocalAddress(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + ensure_alive(env); + + Addr addr = js_to_addr(env, info[0]); + runtime_->set_local_address(addr); + return env.Undefined(); + } + + Napi::Value GetLocalAddress(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + return addr_to_js(env, transport_.local_addr()); + } + + // ── Helpers called from C callbacks during process() ──────── + + void remove_pending(PendingRequest* p) { + pending_requests_.erase(p); + } + +private: + NodeUdpTransport transport_; + std::unique_ptr runtime_; + + // JS callback holders for services (provider) + std::unordered_map> services_; + + // JS callback holders for event handlers (consumer) + std::unordered_map> event_handlers_; + + // Pending request promises + std::unordered_set pending_requests_; + + // SD callback references + Napi::FunctionReference on_service_found_cb_; + Napi::FunctionReference on_service_lost_cb_; + Napi::FunctionReference on_subscription_ack_cb_; + + // Diagnostic callback reference + Napi::FunctionReference on_diagnostic_cb_; + + // Env valid only during process() — used by static C callback trampolines + Napi::Env current_env_{nullptr}; + + // ── Composite key for event handlers ──────────────────────── + + static uint32_t event_handler_key(uint16_t sid, uint16_t eid) { + return (static_cast(sid) << 16) | eid; + } + + // ── Liveness check ────────────────────────────────────────── + + void ensure_alive(Napi::Env env) { + if (!runtime_) { + Napi::Error::New(env, "SeroRuntime has been destroyed") + .ThrowAsJavaScriptException(); + } + } + + // ── Cleanup ───────────────────────────────────────────────── + + void do_destroy() { + // Reject all pending request promises + for (auto* pending : pending_requests_) { + try { + Napi::Env env = pending->deferred.Env(); + Napi::HandleScope scope(env); + pending->deferred.Reject( + Napi::Error::New(env, "Runtime destroyed").Value()); + } catch (...) { + // Ignore errors during teardown + } + delete pending; + } + pending_requests_.clear(); + + // Release owned objects — must happen before runtime_ is destroyed + // because the Runtime's ServiceEntry/EventHandlerEntry store raw + // pointers into JsService/JsEventHandler instances. + runtime_.reset(); + services_.clear(); + event_handlers_.clear(); + + transport_.shutdown(); + } + + // ══════════════════════════════════════════════════════════════ + // Static C callback trampolines + // These fire synchronously during runtime_->process(), which + // is always called from JavaScript → current_env_ is valid. + // ══════════════════════════════════════════════════════════════ + + // ── Request completion ────────────────────────────────────── + + static void request_complete_cb(sero::ReturnCode rc, + const uint8_t* payload, + std::size_t len, + void* ctx) + { + auto* pending = static_cast(ctx); + pending->owner->remove_pending(pending); + + Napi::Env env = pending->deferred.Env(); + Napi::HandleScope scope(env); + + auto result = Napi::Object::New(env); + result.Set("returnCode", + Napi::Number::New(env, static_cast(rc))); + + if (payload && len > 0) { + result.Set("payload", + Napi::Buffer::Copy(env, payload, len)); + } else { + result.Set("payload", + Napi::Buffer::New(env, 0)); + } + + pending->deferred.Resolve(result); + delete pending; + } + + // ── SD: service found ─────────────────────────────────────── + + static void sd_on_service_found(uint16_t sid, const Addr& addr, void* ctx) { + auto* self = static_cast(ctx); + if (self->on_service_found_cb_.IsEmpty()) return; + + Napi::Env env = self->current_env_; + if (!env) return; + Napi::HandleScope scope(env); + + self->on_service_found_cb_.Call({ + Napi::Number::New(env, sid), + addr_to_js(env, addr) + }); + } + + // ── SD: service lost ──────────────────────────────────────── + + static void sd_on_service_lost(uint16_t sid, void* ctx) { + auto* self = static_cast(ctx); + if (self->on_service_lost_cb_.IsEmpty()) return; + + Napi::Env env = self->current_env_; + if (!env) return; + Napi::HandleScope scope(env); + + self->on_service_lost_cb_.Call({ + Napi::Number::New(env, sid) + }); + } + + // ── SD: subscription ack ──────────────────────────────────── + + static void sd_on_subscription_ack(uint16_t sid, uint16_t eid, + sero::ReturnCode rc, uint16_t ttl, + void* ctx) + { + auto* self = static_cast(ctx); + if (self->on_subscription_ack_cb_.IsEmpty()) return; + + Napi::Env env = self->current_env_; + if (!env) return; + Napi::HandleScope scope(env); + + self->on_subscription_ack_cb_.Call({ + Napi::Number::New(env, sid), + Napi::Number::New(env, eid), + Napi::Number::New(env, static_cast(rc)), + Napi::Number::New(env, ttl) + }); + } + + // ── Diagnostic callback ───────────────────────────────────── + + static void diag_callback_trampoline(sero::DiagnosticCounter counter, + const uint8_t* header, + void* ctx) + { + auto* self = static_cast(ctx); + if (self->on_diagnostic_cb_.IsEmpty()) return; + + Napi::Env env = self->current_env_; + if (!env) return; + Napi::HandleScope scope(env); + + Napi::Value header_val; + if (header) { + header_val = Napi::Buffer::Copy(env, header, + sero::HEADER_SIZE); + } else { + header_val = env.Null(); + } + + self->on_diagnostic_cb_.Call({ + Napi::String::New(env, diag_counter_name(counter)), + header_val + }); + } +}; + +// ══════════════════════════════════════════════════════════════════ +// Module registration +// ══════════════════════════════════════════════════════════════════ + +Napi::Object Init(Napi::Env env, Napi::Object exports) { + return SeroRuntime::Init(env, exports); +} + +NODE_API_MODULE(sero_node, Init) \ No newline at end of file diff --git a/node-module/tsconfig.json b/node-module/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/node-module/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} From 58a9a41f669249a7b812420e2956467d30f260bc Mon Sep 17 00:00:00 2001 From: LeoLTM <44781682+LeoLTM@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:03:57 +0100 Subject: [PATCH 2/4] feat: bump package version --- node-module/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-module/package.json b/node-module/package.json index a954fd9..940d3d8 100644 --- a/node-module/package.json +++ b/node-module/package.json @@ -1,6 +1,6 @@ { "name": "@asl-gokart/sero-node", - "version": "0.0.2", + "version": "0.1.1", "description": "sero Node.js bindings", "main": "index.js", From e1f65dbf9431293776db636bd6ba60dc689a4ed8 Mon Sep 17 00:00:00 2001 From: LeoLTM <44781682+LeoLTM@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:05:44 +0100 Subject: [PATCH 3/4] feat: update CI configuration to only use newer GCC and Clang versions --- .github/workflows/ci.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 317aab7..c55edc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,33 +20,17 @@ jobs: matrix: include: # Ubuntu – GCC - - os: ubuntu-24.04 - compiler: gcc-13 - cc: gcc-13 - cxx: g++-13 - std: 17 - os: ubuntu-24.04 compiler: gcc-14 cc: gcc-14 cxx: g++-14 std: 17 # Ubuntu – Clang - - os: ubuntu-24.04 - compiler: clang-17 - cc: clang-17 - cxx: clang++-17 - std: 17 - os: ubuntu-24.04 compiler: clang-18 cc: clang-18 cxx: clang++-18 std: 17 - # macOS – Apple Clang - - os: macos-14 - compiler: apple-clang - cc: clang - cxx: clang++ - std: 17 steps: - uses: actions/checkout@v4 From f7159964d62c18cc54567264cd67ecbe0e3d144d Mon Sep 17 00:00:00 2001 From: Michael Dick <45396189+mad201802@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:07:11 +0100 Subject: [PATCH 4/4] Potential fix for code scanning alert no. 5: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c55edc8..cd5cc73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true