@technoculture/safeserial 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CMakeLists.txt +66 -0
  2. package/README.md +77 -0
  3. package/deps/include/safeserial/config.hpp +69 -0
  4. package/deps/include/safeserial/protocol/crc32.hpp +19 -0
  5. package/deps/include/safeserial/protocol/packet.hpp +175 -0
  6. package/deps/include/safeserial/protocol/reassembler.hpp +80 -0
  7. package/deps/include/safeserial/resilient_bridge.hpp +106 -0
  8. package/deps/include/safeserial/safeserial.hpp +87 -0
  9. package/deps/include/safeserial/transport/factory.hpp +0 -0
  10. package/deps/include/safeserial/transport/iserial_port.hpp +15 -0
  11. package/deps/include/safeserial/transport/serial_port.hpp +28 -0
  12. package/deps/src/CMakeLists.txt +21 -0
  13. package/deps/src/protocol/packet.cpp +1 -0
  14. package/deps/src/resilient_bridge.cpp +338 -0
  15. package/deps/src/safeserial.cpp +246 -0
  16. package/deps/src/transport/platform/linux/linux_serial.cpp +53 -0
  17. package/deps/src/transport/platform/windows/windows_serial.cpp +51 -0
  18. package/dist/index.d.mts +93 -0
  19. package/dist/index.d.ts +93 -0
  20. package/dist/index.js +202 -0
  21. package/dist/index.mjs +170 -0
  22. package/lib/index.ts +23 -0
  23. package/lib/native.ts +108 -0
  24. package/lib/reliable.ts +94 -0
  25. package/lib/resilient.ts +122 -0
  26. package/package.json +78 -0
  27. package/prebuilds/darwin-arm64/Release/data_bridge_node.node +0 -0
  28. package/prebuilds/darwin-arm64/Release/safeserial_node.node +0 -0
  29. package/scripts/copy_deps.js +44 -0
  30. package/scripts/receiver.js +37 -0
  31. package/scripts/sender.js +48 -0
  32. package/src/addon.cpp +807 -0
  33. package/src/serial_wrapper.cpp +8 -0
package/lib/native.ts ADDED
@@ -0,0 +1,108 @@
1
+ import path from "path";
2
+
3
+ // Load native addon
4
+ function loadAddon(): any {
5
+ const possiblePaths = [
6
+ `../prebuilds/${process.platform}-${process.arch}/safeserial_node.node`,
7
+ "../build/Release/safeserial_node.node",
8
+ ];
9
+
10
+ for (const p of possiblePaths) {
11
+ try {
12
+ return require(path.join(__dirname, p));
13
+ } catch {
14
+ continue;
15
+ }
16
+ }
17
+ throw new Error("Failed to load native addon. Run `npm run build` first.");
18
+ }
19
+
20
+ const addon = loadAddon();
21
+
22
+ export interface SerialPort {
23
+ open(port: string, baud: number, callback: (data: Buffer) => void): boolean;
24
+ write(data: Buffer): number;
25
+ close(): boolean;
26
+ isOpen(): boolean;
27
+ }
28
+
29
+ export const SerialPort: {
30
+ new (): SerialPort;
31
+ } = addon.SerialPort;
32
+
33
+ export interface NativeDataBridge {
34
+ open(port: string, baud?: number): boolean;
35
+ close(): boolean;
36
+ send(
37
+ data: Buffer | string,
38
+ ackTimeoutMs?: number,
39
+ maxRetries?: number,
40
+ fragmentSize?: number,
41
+ ): number;
42
+ isOpen(): boolean;
43
+ onData(callback: (data: Buffer) => void): void;
44
+ }
45
+
46
+ export const NativeDataBridge: {
47
+ new (): NativeDataBridge;
48
+ } = addon.DataBridge;
49
+
50
+ export interface NativeResilientDataBridge {
51
+ open(port: string): boolean;
52
+ close(): boolean;
53
+ send(data: Buffer | string): number;
54
+ isConnected(): boolean;
55
+ queueLength(): number;
56
+ onData(callback: (data: Buffer) => void): void;
57
+ onError(callback: (message: string) => void): void;
58
+ onDisconnect(callback: () => void): void;
59
+ onReconnecting(callback: (attempt: number, delay: number) => void): void;
60
+ onReconnected(callback: () => void): void;
61
+ onClose(callback: () => void): void;
62
+ }
63
+
64
+ export const NativeResilientDataBridge: {
65
+ new (options?: any): NativeResilientDataBridge;
66
+ } = addon.ResilientDataBridge;
67
+
68
+ export interface Frame {
69
+ valid: boolean;
70
+ header: {
71
+ type: number;
72
+ seq_id: number;
73
+ fragment_id: number;
74
+ total_frags: number;
75
+ payload_len: number;
76
+ crc32: number;
77
+ };
78
+ payload: Buffer;
79
+ }
80
+
81
+ export interface PacketHelper {
82
+ serialize(
83
+ type: number,
84
+ seq: number,
85
+ payload: string | Buffer,
86
+ frag_id?: number,
87
+ total_frags?: number,
88
+ ): Buffer;
89
+ deserialize(buffer: Buffer): { frame: Frame; remaining: Buffer };
90
+ TYPE_DATA: number;
91
+ TYPE_ACK: number;
92
+ TYPE_NACK: number;
93
+ TYPE_SYN: number;
94
+ }
95
+
96
+ export const Packet: PacketHelper = addon.Packet;
97
+
98
+ export interface Reassembler {
99
+ processFragment(frame: Frame): boolean;
100
+ isComplete(frame: Frame): boolean;
101
+ getData(): Buffer;
102
+ isDuplicate(frame: Frame): boolean;
103
+ getBufferedSize(): number;
104
+ }
105
+
106
+ export const Reassembler: {
107
+ new (): Reassembler;
108
+ } = addon.Reassembler;
@@ -0,0 +1,94 @@
1
+ import { EventEmitter } from "events";
2
+ import { NativeDataBridge } from "./native";
3
+
4
+ export interface DataBridgeOptions {
5
+ baudRate?: number;
6
+ maxRetries?: number;
7
+ ackTimeoutMs?: number;
8
+ fragmentSize?: number;
9
+ }
10
+
11
+ /**
12
+ * ReliableDataBridge
13
+ *
14
+ * Thin wrapper around the native C++ DataBridge implementation.
15
+ */
16
+ export class ReliableDataBridge extends EventEmitter {
17
+ private bridge: NativeDataBridge;
18
+ private isOpen_ = false;
19
+
20
+ // Config
21
+ private options: Required<DataBridgeOptions>;
22
+
23
+ constructor(options: DataBridgeOptions = {}) {
24
+ super();
25
+ this.bridge = new NativeDataBridge();
26
+
27
+ this.options = {
28
+ baudRate: options.baudRate ?? 115200,
29
+ maxRetries: options.maxRetries ?? 10,
30
+ ackTimeoutMs: options.ackTimeoutMs ?? 500,
31
+ fragmentSize: options.fragmentSize ?? 200,
32
+ };
33
+
34
+ this.bridge.onData((data) => {
35
+ this.emit("data", data);
36
+ });
37
+ }
38
+
39
+ static async open(
40
+ port: string,
41
+ baudOrOptions?: number | DataBridgeOptions,
42
+ cb?: (data: Buffer) => void,
43
+ ): Promise<ReliableDataBridge> {
44
+ let opts: DataBridgeOptions = {};
45
+ if (typeof baudOrOptions === "number") {
46
+ opts.baudRate = baudOrOptions;
47
+ } else if (baudOrOptions) {
48
+ opts = baudOrOptions;
49
+ }
50
+
51
+ const bridge = new ReliableDataBridge(opts);
52
+ if (cb) bridge.on("data", cb);
53
+
54
+ await bridge.open(port);
55
+ return bridge;
56
+ }
57
+
58
+ async open(port: string, baud?: number): Promise<boolean> {
59
+ if (this.isOpen_) return true;
60
+
61
+ // Backward compatibility: override baud from options if provided
62
+ const baudRate = baud ?? this.options.baudRate;
63
+
64
+ const success = this.bridge.open(port, baudRate);
65
+ if (!success) {
66
+ throw new Error(`Failed to open ${port}`);
67
+ }
68
+ this.isOpen_ = true;
69
+ return true;
70
+ }
71
+
72
+ async close(): Promise<void> {
73
+ if (this.isOpen_) {
74
+ this.bridge.close();
75
+ this.isOpen_ = false;
76
+ this.emit("close");
77
+ }
78
+ }
79
+
80
+ get isOpen(): boolean {
81
+ return this.isOpen_;
82
+ }
83
+
84
+ async send(data: string | Buffer): Promise<void> {
85
+ if (!this.isOpen_) throw new Error("Port not open");
86
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
87
+ this.bridge.send(
88
+ buf,
89
+ this.options.ackTimeoutMs,
90
+ this.options.maxRetries,
91
+ this.options.fragmentSize,
92
+ );
93
+ }
94
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ResilientDataBridge - Connection-resilient wrapper
3
+ *
4
+ * Thin wrapper around the native C++ ResilientDataBridge implementation.
5
+ */
6
+
7
+ import { EventEmitter } from "events";
8
+ import { DataBridgeOptions } from "./reliable";
9
+ import { NativeResilientDataBridge } from "./native";
10
+
11
+ export interface ResilientOptions extends DataBridgeOptions {
12
+ /** Enable auto-reconnection (default: true) */
13
+ reconnect?: boolean;
14
+ /** Initial reconnect delay in ms (default: 1000) */
15
+ reconnectDelay?: number;
16
+ /** Maximum reconnect delay in ms (default: 30000) */
17
+ maxReconnectDelay?: number;
18
+ /** Maximum queue size before dropping old messages (default: 1000) */
19
+ maxQueueSize?: number;
20
+ }
21
+
22
+ export interface ResilientEvents {
23
+ data: (data: Buffer) => void;
24
+ error: (error: Error) => void;
25
+ close: () => void;
26
+ disconnect: () => void;
27
+ reconnecting: (attempt: number, delay: number) => void;
28
+ reconnected: () => void;
29
+ }
30
+
31
+ export class ResilientDataBridge extends EventEmitter {
32
+ private bridge: NativeResilientDataBridge;
33
+ private port: string;
34
+ private options: ResilientOptions;
35
+
36
+ private constructor(port: string, options: ResilientOptions) {
37
+ super();
38
+ this.port = port;
39
+ this.options = options;
40
+
41
+ this.bridge = new NativeResilientDataBridge(options);
42
+ this.bridge.onData((data) => this.emit("data", data));
43
+ this.bridge.onError((message) => this.emit("error", new Error(message)));
44
+ this.bridge.onDisconnect(() => this.emit("disconnect"));
45
+ this.bridge.onReconnecting((attempt, delay) =>
46
+ this.emit("reconnecting", attempt, delay),
47
+ );
48
+ this.bridge.onReconnected(() => this.emit("reconnected"));
49
+ this.bridge.onClose(() => this.emit("close"));
50
+ }
51
+
52
+ /**
53
+ * Open a resilient serial connection.
54
+ *
55
+ * @param port - Serial port path
56
+ * @param options - Configuration including reconnection settings
57
+ */
58
+ static async open(
59
+ port: string,
60
+ options: ResilientOptions = {},
61
+ ): Promise<ResilientDataBridge> {
62
+ const instance = new ResilientDataBridge(port, options);
63
+ await instance.open();
64
+ return instance;
65
+ }
66
+
67
+ async open(): Promise<boolean> {
68
+ return this.bridge.open(this.port);
69
+ }
70
+
71
+ /**
72
+ * Send data with guaranteed delivery.
73
+ *
74
+ * @param data - Data to send
75
+ * @returns Promise that resolves when data is acknowledged
76
+ */
77
+ async send(data: Buffer | string): Promise<void> {
78
+ const buffer = typeof data === "string" ? Buffer.from(data) : data;
79
+ this.bridge.send(buffer);
80
+ }
81
+
82
+ /**
83
+ * Close the connection permanently.
84
+ */
85
+ async close(): Promise<void> {
86
+ this.bridge.close();
87
+ }
88
+
89
+ /** Current connection state */
90
+ get connected(): boolean {
91
+ return this.bridge.isConnected();
92
+ }
93
+
94
+ /** Number of messages waiting in queue */
95
+ get queueLength(): number {
96
+ return this.bridge.queueLength();
97
+ }
98
+
99
+ // Type-safe event emitter
100
+ on<K extends keyof ResilientEvents>(
101
+ event: K,
102
+ listener: ResilientEvents[K],
103
+ ): this {
104
+ return super.on(event, listener);
105
+ }
106
+
107
+ once<K extends keyof ResilientEvents>(
108
+ event: K,
109
+ listener: ResilientEvents[K],
110
+ ): this {
111
+ return super.once(event, listener);
112
+ }
113
+
114
+ emit<K extends keyof ResilientEvents>(
115
+ event: K,
116
+ ...args: Parameters<ResilientEvents[K]>
117
+ ): boolean {
118
+ return super.emit(event, ...args);
119
+ }
120
+ }
121
+
122
+ export default ResilientDataBridge;
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@technoculture/safeserial",
3
+ "version": "0.1.0",
4
+ "description": "Guaranteed reliable serial communication for Node.js and Electron",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "install": "node scripts/copy_deps.js && (prebuild-install || cmake-js compile)",
17
+ "build": "npm run build:native && npm run build:ts",
18
+ "build:native": "cmake-js compile",
19
+ "build:ts": "tsup lib/index.ts --format cjs,esm --dts",
20
+ "prebuild": "cmake-js compile -O prebuilds/$(node -p \"process.platform + '-' + process.arch\")",
21
+ "prepack": "node scripts/copy_deps.js && npm run build:ts",
22
+ "prepublishOnly": "npm run build:ts",
23
+ "test": "VITE_CJS_IGNORE_WARNING=1 vitest run",
24
+ "clean": "cmake-js clean && rm -rf dist prebuilds deps"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "prebuilds/**/*.node",
29
+ "deps",
30
+ "src",
31
+ "lib",
32
+ "scripts",
33
+ "CMakeLists.txt"
34
+ ],
35
+ "dependencies": {
36
+ "node-addon-api": "^7.0.0",
37
+ "prebuild-install": "^7.1.1",
38
+ "cmake-js": "^7.3.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20.10.0",
42
+ "tsup": "^8.0.0",
43
+ "typescript": "^5.3.0",
44
+ "vitest": "^1.0.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "os": [
50
+ "win32",
51
+ "linux",
52
+ "darwin"
53
+ ],
54
+ "cpu": [
55
+ "x64",
56
+ "arm64",
57
+ "ia32"
58
+ ],
59
+ "binary": {
60
+ "napi_versions": [
61
+ 8
62
+ ]
63
+ },
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "https://github.com/technoculture/safeserial"
67
+ },
68
+ "keywords": [
69
+ "serial",
70
+ "uart",
71
+ "reliable",
72
+ "medical",
73
+ "embedded",
74
+ "electron",
75
+ "napi"
76
+ ],
77
+ "license": "MIT"
78
+ }
@@ -0,0 +1,44 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const PROJECT_ROOT = path.resolve(__dirname, '../../..');
5
+ const PKG_ROOT = path.resolve(__dirname, '..');
6
+ const DEPS_DIR = path.join(PKG_ROOT, 'deps');
7
+
8
+ function copyDir(src, dest) {
9
+ fs.mkdirSync(dest, { recursive: true });
10
+ let entries = fs.readdirSync(src, { withFileTypes: true });
11
+
12
+ for (let entry of entries) {
13
+ let srcPath = path.join(src, entry.name);
14
+ let destPath = path.join(dest, entry.name);
15
+
16
+ if (entry.isDirectory()) {
17
+ copyDir(srcPath, destPath);
18
+ } else {
19
+ fs.copyFileSync(srcPath, destPath);
20
+ }
21
+ }
22
+ }
23
+
24
+ console.log('[copy_deps] Bundling C++ core files...');
25
+
26
+ // Clean deps
27
+ if (fs.existsSync(DEPS_DIR)) {
28
+ fs.rmSync(DEPS_DIR, { recursive: true, force: true });
29
+ }
30
+ fs.mkdirSync(DEPS_DIR);
31
+
32
+ // Copy src
33
+ const srcDir = path.join(PROJECT_ROOT, 'src');
34
+ const destSrc = path.join(DEPS_DIR, 'src');
35
+ console.log(`Copying ${srcDir} -> ${destSrc}`);
36
+ copyDir(srcDir, destSrc);
37
+
38
+ // Copy include
39
+ const incDir = path.join(PROJECT_ROOT, 'include');
40
+ const destInc = path.join(DEPS_DIR, 'include');
41
+ console.log(`Copying ${incDir} -> ${destInc}`);
42
+ copyDir(incDir, destInc);
43
+
44
+ console.log('[copy_deps] Done.');
@@ -0,0 +1,37 @@
1
+ const { ResilientDataBridge } = require('../dist/index.js');
2
+
3
+ async function main() {
4
+ const port = process.argv[2];
5
+
6
+ // We don't strictly need item count here for logic, but helpful for knowing when to stop logging?
7
+ // The python runner kills us, so we just log what we get.
8
+
9
+ if (!port) {
10
+ console.error("Usage: node receiver.js <port>");
11
+ process.exit(1);
12
+ }
13
+
14
+ console.log(`[RECEIVER] Connecting to ${port}...`);
15
+ const bridge = await ResilientDataBridge.open(port, {
16
+ baudRate: 115200,
17
+ reconnect: true
18
+ });
19
+
20
+ let receivedCount = 0;
21
+
22
+ bridge.on('data', (data) => {
23
+ const str = data.toString();
24
+ receivedCount++;
25
+ console.log(`[RECEIVER] Got: ${str} (Total: ${receivedCount})`);
26
+ });
27
+
28
+ console.log("[RECEIVER] Listening...");
29
+
30
+ // Keep alive until killed
31
+ await new Promise(() => { });
32
+ }
33
+
34
+ main().catch(err => {
35
+ console.error("[RECEIVER] Fatal:", err);
36
+ process.exit(1);
37
+ });
@@ -0,0 +1,48 @@
1
+ const { ResilientDataBridge } = require('../dist/index.js');
2
+
3
+ async function main() {
4
+ const port = process.argv[2];
5
+ const itemCount = parseInt(process.argv[3] || '20', 10);
6
+ const baudRate = 115200; // Match Chaos Monkey defaults
7
+
8
+ if (!port) {
9
+ console.error("Usage: node sender.js <port> [item_count]");
10
+ process.exit(1);
11
+ }
12
+
13
+ console.log(`[SENDER] Connecting to ${port}...`);
14
+ const bridge = await ResilientDataBridge.open(port, {
15
+ baudRate,
16
+ reconnect: true
17
+ });
18
+
19
+ console.log(`[SENDER] Starting transmission of ${itemCount} items...`);
20
+
21
+ for (let i = 0; i < itemCount; i++) {
22
+ const msg = `Packet-${i}`;
23
+ try {
24
+ await bridge.send(msg);
25
+ console.log(`[SENDER] Sent: ${msg}`);
26
+ } catch (err) {
27
+ console.error(`[SENDER] Error sending ${msg}:`, err);
28
+ }
29
+
30
+ // Small delay to prevent overwhelming the chaos monkey's internal buffer too fast
31
+ // if we want to simulate realistic traffic, though the bridge handles backpressure.
32
+ await new Promise(r => setTimeout(r, 50));
33
+ }
34
+
35
+ // Give time for final ACKs
36
+ await new Promise(r => setTimeout(r, 2000));
37
+
38
+ console.log("[SENDER] TEST COMPLETE");
39
+
40
+ // Clean exit
41
+ await bridge.close();
42
+ process.exit(0);
43
+ }
44
+
45
+ main().catch(err => {
46
+ console.error("[SENDER] Fatal:", err);
47
+ process.exit(1);
48
+ });