@digta/network 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.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @digta/network
2
+
3
+ Network toolkit for Node: a **TCP / WebSocket** client with **autoreconnect**, plus a tiny **HTTP API** client with retries. Same package, one import.
4
+
5
+ ```bash
6
+ npm install @digta/network
7
+ ```
8
+
9
+ ## Connection — TCP / WebSocket + autoreconnect
10
+
11
+ One API for both transports. Pick it with the URL scheme: `tcp://`, `ws://`, or `wss://`.
12
+
13
+ ```js
14
+ import { createConnection } from "@digta/network";
15
+ // or: const { createConnection } = require("@digta/network");
16
+
17
+ const conn = createConnection({
18
+ url: "tcp://192.168.1.10:502", // or "ws://host:8080/path"
19
+ reconnect: true, // on by default
20
+ backoff: { min: 500, max: 30000, factor: 2 },
21
+ });
22
+
23
+ conn.on("connect", () => console.log("connected"));
24
+ conn.on("message", (data) => console.log("in:", data));
25
+ conn.on("reconnecting", (attempt, delay) => console.log(`retry #${attempt} in ${delay}ms`));
26
+ conn.on("close", () => console.log("closed"));
27
+ conn.on("error", (err) => console.error(err));
28
+
29
+ conn.connect();
30
+ conn.send("hello");
31
+ // conn.close(); // intentional — stops reconnecting
32
+ ```
33
+
34
+ **Autoreconnect** uses exponential backoff with jitter and resets on success. Call `close()` to stop for good.
35
+
36
+ ### Heartbeat & latency (optional)
37
+
38
+ Set `heartbeat` (ms) to ping and measure round-trip time:
39
+
40
+ ```js
41
+ const conn = createConnection({ url: "ws://host:8080", heartbeat: 5000 });
42
+ conn.on("latency", (ms) => console.log(`${ms}ms`));
43
+ conn.on("timeout", () => console.log("no pong — dropping + reconnecting"));
44
+ ```
45
+
46
+ - **WebSocket**: works out of the box (native ping/pong frames).
47
+ - **Raw TCP**: has no ping frame. Provide your protocol's `ping` bytes and an `isPong(data)` matcher, or rely on OS keepalive (always on) for dead-link detection.
48
+
49
+ ### Events
50
+
51
+ `connect` · `message(data)` · `close` · `error(err)` · `reconnecting(attempt, delay)` · `reconnect_failed(attempts)` · `latency(ms)` · `timeout`
52
+
53
+ > TCP is a raw byte stream — it has **no message framing**. `message` fires per chunk; handle your own boundaries. WebSocket messages are framed for you.
54
+
55
+ ## API — HTTP client with retries
56
+
57
+ ```js
58
+ import { createApi } from "@digta/network";
59
+
60
+ const api = createApi({
61
+ baseURL: "https://api.example.com",
62
+ headers: { authorization: "Bearer …" },
63
+ timeout: 10000,
64
+ retries: 3, // retries network errors, timeouts, 429, and 5xx (exp backoff)
65
+ });
66
+
67
+ const { data, status } = await api.get("/users", { query: { page: 2 } });
68
+ await api.post("/users", { name: "digta" }); // JSON body auto-serialized
69
+ await api.get("/thing", { retries: 0, timeout: 2000 }); // per-request overrides
70
+ ```
71
+
72
+ - JSON bodies are serialized and `Content-Type` set automatically; JSON responses are parsed.
73
+ - `4xx`/`5xx` **reject** with an error carrying `.status` and `.response`.
74
+ - Follows redirects (up to 5 hops).
75
+ - Zero HTTP dependency — built on Node's `http`/`https`, works on Node 14+.
76
+
77
+ For one-off absolute-URL calls, use the ready `api`:
78
+
79
+ ```js
80
+ import { api } from "@digta/network";
81
+ const res = await api.get("https://example.com/health");
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT
package/api.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict'
2
+
3
+ const http = require('http')
4
+ const https = require('https')
5
+
6
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
7
+
8
+ function buildUrl(base, path, query) {
9
+ let url = /^https?:\/\//i.test(path)
10
+ ? path
11
+ : base
12
+ ? base.replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, '')
13
+ : path
14
+ if (query && Object.keys(query).length) {
15
+ const qs = new URLSearchParams(query).toString()
16
+ url += (url.includes('?') ? '&' : '?') + qs
17
+ }
18
+ return url
19
+ }
20
+
21
+ function retriable(err) {
22
+ if (err.status) return err.status >= 500 || err.status === 429
23
+ return true // network error or timeout
24
+ }
25
+
26
+ // One request, following up to 5 redirects (301/302/303 → GET, 307/308 keep method+body).
27
+ function once(method, url, payload, headers, timeout, hops = 0) {
28
+ return new Promise((resolve, reject) => {
29
+ const u = new URL(url)
30
+ const lib = u.protocol === 'https:' ? https : http
31
+ const req = lib.request(u, { method, headers }, (res) => {
32
+ const { statusCode: status, headers: resHeaders } = res
33
+ if (resHeaders.location && status >= 300 && status < 400 && hops < 5) {
34
+ res.resume()
35
+ const next = new URL(resHeaders.location, url).toString()
36
+ let m = method, p = payload, h = headers
37
+ if (status === 303 || ((status === 301 || status === 302) && method === 'POST')) {
38
+ m = 'GET'; p = null
39
+ h = { ...headers }; delete h['content-length']; delete h['content-type']
40
+ }
41
+ return resolve(once(m, next, p, h, timeout, hops + 1))
42
+ }
43
+ const chunks = []
44
+ res.on('data', (c) => chunks.push(c))
45
+ res.on('end', () => {
46
+ const text = Buffer.concat(chunks).toString('utf8')
47
+ const ct = resHeaders['content-type'] || ''
48
+ let data = text
49
+ if (ct.includes('application/json') && text) { try { data = JSON.parse(text) } catch { /* keep text */ } }
50
+ const result = { status, headers: resHeaders, data }
51
+ if (status >= 400) {
52
+ const e = new Error(`HTTP ${status} ${method} ${url}`)
53
+ e.status = status; e.response = result
54
+ return reject(e)
55
+ }
56
+ resolve(result)
57
+ })
58
+ })
59
+ req.on('error', reject)
60
+ req.setTimeout(timeout, () => req.destroy(new Error(`@digta/network: request timed out after ${timeout}ms`)))
61
+ if (payload != null) req.write(payload)
62
+ req.end()
63
+ })
64
+ }
65
+
66
+ function createApi(opts = {}) {
67
+ const base = opts.baseURL || opts.baseUrl || ''
68
+ const cfg = {
69
+ timeout: opts.timeout ?? 30000,
70
+ retries: opts.retries ?? 0,
71
+ retryDelay: opts.retryDelay ?? 500,
72
+ headers: opts.headers || {},
73
+ }
74
+
75
+ function request(method, path, { body, headers, query, timeout, retries } = {}) {
76
+ const url = buildUrl(base, path, query)
77
+ const isJsonBody = body != null && typeof body === 'object' && !Buffer.isBuffer(body)
78
+ const payload = body == null ? null : isJsonBody ? JSON.stringify(body) : body
79
+ const finalHeaders = {
80
+ ...cfg.headers,
81
+ ...headers,
82
+ ...(isJsonBody ? { 'content-type': 'application/json' } : {}),
83
+ ...(payload != null ? { 'content-length': Buffer.byteLength(payload) } : {}),
84
+ }
85
+ const maxRetries = retries ?? cfg.retries
86
+ const to = timeout ?? cfg.timeout
87
+
88
+ const attempt = (n) =>
89
+ once(method, url, payload, finalHeaders, to).catch((err) => {
90
+ if (n < maxRetries && retriable(err)) return sleep(cfg.retryDelay * 2 ** n).then(() => attempt(n + 1))
91
+ throw err
92
+ })
93
+ return attempt(0)
94
+ }
95
+
96
+ return {
97
+ request,
98
+ get: (p, o) => request('GET', p, o),
99
+ delete: (p, o) => request('DELETE', p, o),
100
+ head: (p, o) => request('HEAD', p, o),
101
+ post: (p, body, o) => request('POST', p, { ...o, body }),
102
+ put: (p, body, o) => request('PUT', p, { ...o, body }),
103
+ patch: (p, body, o) => request('PATCH', p, { ...o, body }),
104
+ }
105
+ }
106
+
107
+ module.exports = { createApi }
package/connection.js ADDED
@@ -0,0 +1,154 @@
1
+ 'use strict'
2
+
3
+ const EventEmitter = require('events')
4
+
5
+ let _WS
6
+ function getWs() {
7
+ if (_WS) return _WS
8
+ try { _WS = require('ws') }
9
+ catch { throw new Error('@digta/network: WebSocket support needs ws — run `npm i ws`') }
10
+ return _WS
11
+ }
12
+
13
+ function openTcp(conn) {
14
+ const net = require('net')
15
+ const socket = net.connect({ host: conn.host, port: conn.port })
16
+ socket.setKeepAlive(true, conn.opts.heartbeat || 10000) // OS-level dead-link detection
17
+ socket.on('connect', () => conn._onOpen())
18
+ socket.on('data', (d) => conn._onData(d))
19
+ socket.on('close', () => conn._onClose())
20
+ socket.on('error', (e) => conn._onError(e))
21
+ return {
22
+ supportsPing: false, // raw TCP has no ping frame — see _onData / opts.ping
23
+ send: (data) => socket.write(data),
24
+ ping: (payload) => { if (payload) socket.write(payload) },
25
+ close: () => socket.destroy(),
26
+ }
27
+ }
28
+
29
+ function openWs(conn) {
30
+ const WebSocket = getWs()
31
+ const ws = new WebSocket(conn.url)
32
+ ws.on('open', () => conn._onOpen())
33
+ ws.on('message', (d) => conn._onData(d))
34
+ ws.on('pong', () => conn._onPong())
35
+ ws.on('close', () => conn._onClose())
36
+ ws.on('error', (e) => conn._onError(e))
37
+ return {
38
+ supportsPing: true, // native ping/pong frames → heartbeat + latency for free
39
+ send: (data) => ws.send(data),
40
+ ping: () => ws.ping(),
41
+ close: () => ws.close(),
42
+ }
43
+ }
44
+
45
+ class Connection extends EventEmitter {
46
+ constructor(opts = {}) {
47
+ super()
48
+ this.opts = { heartbeat: 0, heartbeatTimeout: 10000, reconnect: true, maxRetries: Infinity, ...opts }
49
+ this.backoff = { min: 500, max: 30000, factor: 2, ...(opts.backoff || {}) }
50
+
51
+ if (opts.url) {
52
+ const u = new URL(opts.url)
53
+ this.transport = u.protocol === 'tcp:' ? 'tcp' : 'ws'
54
+ this.host = u.hostname
55
+ this.port = Number(u.port)
56
+ this.url = opts.url
57
+ } else {
58
+ this.transport = opts.transport || 'tcp'
59
+ this.host = opts.host
60
+ this.port = opts.port
61
+ if (this.transport === 'ws') this.url = opts.url || `ws://${this.host}:${this.port}`
62
+ }
63
+
64
+ this.connected = false
65
+ this.latency = null
66
+ this._attempt = 0
67
+ this._intentional = false
68
+ this._raw = null
69
+ }
70
+
71
+ connect() {
72
+ if (this.connected) return this
73
+ this._intentional = false
74
+ clearTimeout(this._reconnectTimer)
75
+ this._raw = this.transport === 'ws' ? openWs(this) : openTcp(this)
76
+ return this
77
+ }
78
+
79
+ send(data) {
80
+ if (!this.connected) throw new Error('@digta/network: not connected')
81
+ this._raw.send(data)
82
+ return this
83
+ }
84
+
85
+ close() {
86
+ this._intentional = true
87
+ clearTimeout(this._reconnectTimer)
88
+ this._stopHeartbeat()
89
+ if (this._raw) this._raw.close()
90
+ this.connected = false
91
+ return this
92
+ }
93
+
94
+ _onOpen() {
95
+ this.connected = true
96
+ this._attempt = 0
97
+ this.emit('connect')
98
+ this._startHeartbeat()
99
+ }
100
+
101
+ _onData(data) {
102
+ // TCP has no ping frame: if the caller taught us to recognize a pong, use it for latency.
103
+ if (!this._raw.supportsPing && this.opts.isPong && this.opts.isPong(data)) return this._onPong()
104
+ this.emit('message', data)
105
+ }
106
+
107
+ _onClose() {
108
+ this._stopHeartbeat()
109
+ this.connected = false
110
+ this.emit('close')
111
+ if (!this._intentional) this._scheduleReconnect()
112
+ }
113
+
114
+ _onError(err) {
115
+ // Swallow when unheard: reconnect recovers, and an unhandled 'error' would crash the process.
116
+ if (this.listenerCount('error')) this.emit('error', err)
117
+ }
118
+
119
+ _scheduleReconnect() {
120
+ if (!this.opts.reconnect) return
121
+ if (this._attempt >= this.opts.maxRetries) return this.emit('reconnect_failed', this._attempt)
122
+ const { min, max, factor } = this.backoff
123
+ const base = Math.min(max, min * Math.pow(factor, this._attempt))
124
+ const delay = Math.round(base / 2 + Math.random() * (base / 2)) // exponential backoff + full jitter
125
+ this._attempt++
126
+ this.emit('reconnecting', this._attempt, delay)
127
+ this._reconnectTimer = setTimeout(() => this.connect(), delay)
128
+ }
129
+
130
+ _startHeartbeat() {
131
+ if (!this.opts.heartbeat) return
132
+ if (!this._raw.supportsPing && !this.opts.ping) return // TCP heartbeat needs a ping payload
133
+ this._hbTimer = setInterval(() => this._ping(), this.opts.heartbeat)
134
+ }
135
+
136
+ _ping() {
137
+ if (!this.connected) return
138
+ this._pingAt = Date.now()
139
+ try { this._raw.ping(this.opts.ping) } catch { return }
140
+ this._pongTimer = setTimeout(() => { this.emit('timeout'); this._raw.close() }, this.opts.heartbeatTimeout)
141
+ }
142
+
143
+ _onPong() {
144
+ clearTimeout(this._pongTimer)
145
+ if (this._pingAt) { this.latency = Date.now() - this._pingAt; this.emit('latency', this.latency) }
146
+ }
147
+
148
+ _stopHeartbeat() {
149
+ clearInterval(this._hbTimer)
150
+ clearTimeout(this._pongTimer)
151
+ }
152
+ }
153
+
154
+ module.exports = { Connection, createConnection: (opts) => new Connection(opts) }
package/index.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { EventEmitter } from 'events'
2
+
3
+ // ── Connection (TCP / WebSocket + autoreconnect) ──────────────────────────
4
+
5
+ export interface BackoffOptions {
6
+ /** First retry delay in ms. Default 500. */
7
+ min?: number
8
+ /** Cap on retry delay in ms. Default 30000. */
9
+ max?: number
10
+ /** Multiplier per attempt. Default 2. */
11
+ factor?: number
12
+ }
13
+
14
+ export interface ConnectionOptions {
15
+ /** `tcp://host:port`, `ws://host:port/path`, or `wss://…`. Sets transport + address. */
16
+ url?: string
17
+ /** Used instead of `url`. */
18
+ transport?: 'tcp' | 'ws'
19
+ host?: string
20
+ port?: number
21
+ /** Auto-reconnect on drop. Default true. */
22
+ reconnect?: boolean
23
+ /** Max reconnect attempts. Default Infinity. */
24
+ maxRetries?: number
25
+ /** Reconnect backoff schedule. */
26
+ backoff?: BackoffOptions
27
+ /** Ping interval in ms (0 = off). WS uses native ping/pong; TCP needs `ping`+`isPong`. */
28
+ heartbeat?: number
29
+ /** Drop + reconnect if no pong within this many ms. Default 10000. */
30
+ heartbeatTimeout?: number
31
+ /** TCP only: bytes to send as a heartbeat ping. */
32
+ ping?: string | Buffer
33
+ /** TCP only: returns true if the received data is the pong to our ping. */
34
+ isPong?: (data: Buffer) => boolean
35
+ }
36
+
37
+ /**
38
+ * Events: `connect`, `message(data)`, `close`, `error(err)`,
39
+ * `reconnecting(attempt, delayMs)`, `reconnect_failed(attempts)`,
40
+ * `latency(ms)`, `timeout`.
41
+ */
42
+ export class Connection extends EventEmitter {
43
+ constructor(options?: ConnectionOptions)
44
+ readonly connected: boolean
45
+ /** Last measured round-trip in ms (heartbeat), or null. */
46
+ readonly latency: number | null
47
+ readonly transport: 'tcp' | 'ws'
48
+ connect(): this
49
+ send(data: string | Buffer | ArrayBufferView): this
50
+ /** Close intentionally — no reconnect. */
51
+ close(): this
52
+ }
53
+
54
+ export function createConnection(options?: ConnectionOptions): Connection
55
+
56
+ // ── API (HTTP client with retries) ────────────────────────────────────────
57
+
58
+ export interface ApiOptions {
59
+ baseURL?: string
60
+ headers?: Record<string, string>
61
+ /** Per-request timeout in ms. Default 30000. */
62
+ timeout?: number
63
+ /** Retry count for network errors, timeouts, 429, and 5xx. Default 0. */
64
+ retries?: number
65
+ /** Base retry delay in ms (doubles each attempt). Default 500. */
66
+ retryDelay?: number
67
+ }
68
+
69
+ export interface RequestOptions {
70
+ body?: unknown
71
+ headers?: Record<string, string>
72
+ query?: Record<string, string | number | boolean>
73
+ timeout?: number
74
+ retries?: number
75
+ }
76
+
77
+ export interface Response<T = any> {
78
+ status: number
79
+ headers: Record<string, string | string[] | undefined>
80
+ data: T
81
+ }
82
+
83
+ export interface Api {
84
+ request<T = any>(method: string, path: string, options?: RequestOptions): Promise<Response<T>>
85
+ get<T = any>(path: string, options?: RequestOptions): Promise<Response<T>>
86
+ delete<T = any>(path: string, options?: RequestOptions): Promise<Response<T>>
87
+ head<T = any>(path: string, options?: RequestOptions): Promise<Response<T>>
88
+ post<T = any>(path: string, body?: unknown, options?: RequestOptions): Promise<Response<T>>
89
+ put<T = any>(path: string, body?: unknown, options?: RequestOptions): Promise<Response<T>>
90
+ patch<T = any>(path: string, body?: unknown, options?: RequestOptions): Promise<Response<T>>
91
+ }
92
+
93
+ export function createApi(options?: ApiOptions): Api
94
+
95
+ /** Ready-to-use client for absolute URLs. */
96
+ export const api: Api
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ 'use strict'
2
+
3
+ const { Connection, createConnection } = require('./connection')
4
+ const { createApi } = require('./api')
5
+
6
+ module.exports = {
7
+ Connection,
8
+ createConnection, // TCP / WebSocket client with autoreconnect
9
+ createApi, // HTTP client with retries
10
+ api: createApi(), // ready-to-use HTTP client for absolute URLs
11
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@digta/network",
3
+ "version": "0.1.0",
4
+ "description": "TCP + WebSocket client with autoreconnect, plus a tiny HTTP API client with retries. One toolkit.",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "type": "commonjs",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "connection.js",
17
+ "api.js",
18
+ "index.d.ts"
19
+ ],
20
+ "scripts": {
21
+ "test": "node test.js"
22
+ },
23
+ "keywords": [
24
+ "tcp",
25
+ "websocket",
26
+ "ws",
27
+ "reconnect",
28
+ "autoreconnect",
29
+ "http",
30
+ "api",
31
+ "client",
32
+ "retry"
33
+ ],
34
+ "dependencies": {
35
+ "ws": "^8.18.0"
36
+ },
37
+ "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "engines": {
42
+ "node": ">=14"
43
+ }
44
+ }