@sirena-lwm2m/coap 0.8.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/LICENSE +17 -0
- package/README.md +78 -0
- package/dist/api/exchange-context.d.ts +34 -0
- package/dist/api/exchange-context.d.ts.map +1 -0
- package/dist/api/exchange-context.js +2 -0
- package/dist/api/exchange-context.js.map +1 -0
- package/dist/api/headers.d.ts +10 -0
- package/dist/api/headers.d.ts.map +1 -0
- package/dist/api/headers.js +69 -0
- package/dist/api/headers.js.map +1 -0
- package/dist/api/index.d.ts +5 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +4 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/request.d.ts +11 -0
- package/dist/api/request.d.ts.map +1 -0
- package/dist/api/request.js +49 -0
- package/dist/api/request.js.map +1 -0
- package/dist/api/response.d.ts +11 -0
- package/dist/api/response.d.ts.map +1 -0
- package/dist/api/response.js +19 -0
- package/dist/api/response.js.map +1 -0
- package/dist/blockwise/block1-assembler.d.ts +26 -0
- package/dist/blockwise/block1-assembler.d.ts.map +1 -0
- package/dist/blockwise/block1-assembler.js +94 -0
- package/dist/blockwise/block1-assembler.js.map +1 -0
- package/dist/blockwise/block2-chunker.d.ts +23 -0
- package/dist/blockwise/block2-chunker.d.ts.map +1 -0
- package/dist/blockwise/block2-chunker.js +99 -0
- package/dist/blockwise/block2-chunker.js.map +1 -0
- package/dist/blockwise/index.d.ts +8 -0
- package/dist/blockwise/index.d.ts.map +1 -0
- package/dist/blockwise/index.js +5 -0
- package/dist/blockwise/index.js.map +1 -0
- package/dist/blockwise/middleware.d.ts +7 -0
- package/dist/blockwise/middleware.d.ts.map +1 -0
- package/dist/blockwise/middleware.js +120 -0
- package/dist/blockwise/middleware.js.map +1 -0
- package/dist/blockwise/option.d.ts +12 -0
- package/dist/blockwise/option.d.ts.map +1 -0
- package/dist/blockwise/option.js +55 -0
- package/dist/blockwise/option.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/message/index.d.ts +21 -0
- package/dist/message/index.d.ts.map +1 -0
- package/dist/message/index.js +79 -0
- package/dist/message/index.js.map +1 -0
- package/dist/observability.d.ts +44 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +71 -0
- package/dist/observability.js.map +1 -0
- package/dist/observe-notification.d.ts +21 -0
- package/dist/observe-notification.d.ts.map +1 -0
- package/dist/observe-notification.js +87 -0
- package/dist/observe-notification.js.map +1 -0
- package/dist/observe.d.ts +10 -0
- package/dist/observe.d.ts.map +1 -0
- package/dist/observe.js +3 -0
- package/dist/observe.js.map +1 -0
- package/dist/option-parser/index.d.ts +30 -0
- package/dist/option-parser/index.d.ts.map +1 -0
- package/dist/option-parser/index.js +125 -0
- package/dist/option-parser/index.js.map +1 -0
- package/dist/router/app.d.ts +8 -0
- package/dist/router/app.d.ts.map +1 -0
- package/dist/router/app.js +25 -0
- package/dist/router/app.js.map +1 -0
- package/dist/router/content-format.d.ts +7 -0
- package/dist/router/content-format.d.ts.map +1 -0
- package/dist/router/content-format.js +23 -0
- package/dist/router/content-format.js.map +1 -0
- package/dist/router/index.d.ts +9 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +5 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/radix.d.ts +16 -0
- package/dist/router/radix.d.ts.map +1 -0
- package/dist/router/radix.js +69 -0
- package/dist/router/radix.js.map +1 -0
- package/dist/router/router.d.ts +38 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +111 -0
- package/dist/router/router.js.map +1 -0
- package/dist/router.d.ts +2 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +2 -0
- package/dist/router.js.map +1 -0
- package/dist/security.d.ts +3 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +2 -0
- package/dist/security.js.map +1 -0
- package/dist/store/index.d.ts +26 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +68 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/sqlite-store.d.ts +10 -0
- package/dist/store/sqlite-store.d.ts.map +1 -0
- package/dist/store/sqlite-store.js +75 -0
- package/dist/store/sqlite-store.js.map +1 -0
- package/dist/store.d.ts +5 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +3 -0
- package/dist/store.js.map +1 -0
- package/dist/transport/dtls.d.ts +38 -0
- package/dist/transport/dtls.d.ts.map +1 -0
- package/dist/transport/dtls.js +151 -0
- package/dist/transport/dtls.js.map +1 -0
- package/dist/transport/events.d.ts +34 -0
- package/dist/transport/events.d.ts.map +1 -0
- package/dist/transport/events.js +2 -0
- package/dist/transport/events.js.map +1 -0
- package/dist/transport/index.d.ts +10 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +5 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/tcp-connection.d.ts +18 -0
- package/dist/transport/tcp-connection.d.ts.map +1 -0
- package/dist/transport/tcp-connection.js +72 -0
- package/dist/transport/tcp-connection.js.map +1 -0
- package/dist/transport/tcp-frame.d.ts +17 -0
- package/dist/transport/tcp-frame.d.ts.map +1 -0
- package/dist/transport/tcp-frame.js +112 -0
- package/dist/transport/tcp-frame.js.map +1 -0
- package/dist/transport/tcp-stream.d.ts +21 -0
- package/dist/transport/tcp-stream.d.ts.map +1 -0
- package/dist/transport/tcp-stream.js +52 -0
- package/dist/transport/tcp-stream.js.map +1 -0
- package/dist/transport/udp.d.ts +97 -0
- package/dist/transport/udp.d.ts.map +1 -0
- package/dist/transport/udp.js +994 -0
- package/dist/transport/udp.js.map +1 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +2 -0
- package/dist/transport.js.map +1 -0
- package/docs/api-reference.md +536 -0
- package/docs/blockwise.md +117 -0
- package/docs/observability.md +206 -0
- package/docs/observe.md +203 -0
- package/docs/releasing.md +106 -0
- package/docs/router.md +170 -0
- package/docs/store.md +212 -0
- package/docs/tcp-transport.md +89 -0
- package/docs/udp-transport.md +81 -0
- package/examples/slice-1.ts +12 -0
- package/examples/slice-2.ts +96 -0
- package/examples/slice-3-psk.ts +70 -0
- package/examples/slice-3-x509.ts +85 -0
- package/examples/slice-4.ts +45 -0
- package/examples/slice-5-firmware.ts +207 -0
- package/examples/slice-6-temperature.ts +163 -0
- package/examples/slice-7-file-store.ts +92 -0
- package/examples/slice-7-otel.ts +123 -0
- package/package.json +73 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import dgram from 'node:dgram';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import tls from 'node:tls';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { decodeMessage, encodeMessage, stringToCode } from '../message/index.js';
|
|
6
|
+
import { createInMemoryStore, EXCHANGE_LIFETIME } from '../store/index.js';
|
|
7
|
+
import { Request } from '../api/index.js';
|
|
8
|
+
import { TcpConnectionManager } from './tcp-connection.js';
|
|
9
|
+
import { TcpFrameStream } from './tcp-stream.js';
|
|
10
|
+
import { encodeTcpFrame } from './tcp-frame.js';
|
|
11
|
+
import { createDtlsSocket } from './dtls.js';
|
|
12
|
+
import { OptionNumber, bytesToUint, uintToBytes, bytesToString } from '../option-parser/index.js';
|
|
13
|
+
import { startNotificationLoop } from '../observe-notification.js';
|
|
14
|
+
import { Block2Chunker, encodeBlockOption, decodeBlockOption } from '../blockwise/index.js';
|
|
15
|
+
import { createNoopMetrics } from '../observability.js';
|
|
16
|
+
// RFC 8323 §5.3 — signaling codes (7.xx class, bits: 0xe0..0xff)
|
|
17
|
+
const TCP_SIGNALING_CLASS = 0xe0;
|
|
18
|
+
const CODE_CSM = 0xe1; // 7.01 Capabilities and Settings
|
|
19
|
+
const CODE_PING = 0xe2; // 7.02 Ping
|
|
20
|
+
const CODE_PONG = 0xe3; // 7.03 Pong
|
|
21
|
+
const CODE_RELEASE = 0xe4; // 7.04 Release
|
|
22
|
+
const CODE_ABORT = 0xe5; // 7.05 Abort
|
|
23
|
+
// Minimal CSM frame: Len=0, TKL=0, Code=7.01
|
|
24
|
+
const CSM_FRAME = new Uint8Array([0x00, CODE_CSM]);
|
|
25
|
+
const noopLogger = {
|
|
26
|
+
debug: () => { },
|
|
27
|
+
info: () => { },
|
|
28
|
+
warn: () => { },
|
|
29
|
+
error: () => { },
|
|
30
|
+
};
|
|
31
|
+
export function parseListenAddress(addr) {
|
|
32
|
+
if (typeof addr === 'object') {
|
|
33
|
+
return { host: addr.host, port: addr.port, transport: 'udp' };
|
|
34
|
+
}
|
|
35
|
+
const url = new URL(addr);
|
|
36
|
+
if (url.protocol === 'coap:') {
|
|
37
|
+
return { host: url.hostname, port: Number(url.port), transport: 'udp' };
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol === 'coap+tcp:') {
|
|
40
|
+
return { host: url.hostname, port: Number(url.port), transport: 'tcp' };
|
|
41
|
+
}
|
|
42
|
+
if (url.protocol === 'coaps:') {
|
|
43
|
+
return { host: url.hostname, port: Number(url.port), transport: 'dtls' };
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol === 'coaps+tcp:') {
|
|
46
|
+
return { host: url.hostname, port: Number(url.port), transport: 'tls' };
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Unsupported listen address scheme: ${url.protocol}`);
|
|
49
|
+
}
|
|
50
|
+
class UdpServer extends EventEmitter {
|
|
51
|
+
options;
|
|
52
|
+
isDefaultUdpStore;
|
|
53
|
+
tcpStore;
|
|
54
|
+
log;
|
|
55
|
+
metrics;
|
|
56
|
+
sockets = [];
|
|
57
|
+
dtlsSockets = [];
|
|
58
|
+
tcpServers = [];
|
|
59
|
+
tlsServers = [];
|
|
60
|
+
ready = false;
|
|
61
|
+
inflight = 0;
|
|
62
|
+
warningEmitted = false;
|
|
63
|
+
endpointState = new Map();
|
|
64
|
+
tcpConnectionManager;
|
|
65
|
+
tlsPskIdentityBySocket = new WeakMap();
|
|
66
|
+
pendingTlsUpdate = null;
|
|
67
|
+
observeRegistrations = new Map();
|
|
68
|
+
activeLoops = new Map();
|
|
69
|
+
activeNotifChunkers = new Map();
|
|
70
|
+
notifMidCounter = 0x1000; // message ID counter for server-initiated CON notifications
|
|
71
|
+
constructor(options) {
|
|
72
|
+
super();
|
|
73
|
+
const resolved = options.listen.map(addr => parseListenAddress(addr));
|
|
74
|
+
const needsSecurity = resolved.some(a => a.transport === 'dtls' || a.transport === 'tls');
|
|
75
|
+
if (needsSecurity && options.security === undefined) {
|
|
76
|
+
throw new Error('security option is required when using coaps:// (DTLS) or coaps+tcp:// (TLS) listen addresses');
|
|
77
|
+
}
|
|
78
|
+
if (options.security?.mode === 'x509' && options.security.requestClientCert && options.security.ca === undefined) {
|
|
79
|
+
throw new Error('ca is required when requestClientCert is true (x509 mode)');
|
|
80
|
+
}
|
|
81
|
+
this.isDefaultUdpStore = options.store === undefined;
|
|
82
|
+
this.log = options.logger ?? noopLogger;
|
|
83
|
+
this.metrics = options.metrics ?? createNoopMetrics();
|
|
84
|
+
this.options = {
|
|
85
|
+
listen: options.listen,
|
|
86
|
+
security: options.security,
|
|
87
|
+
store: options.store ?? createInMemoryStore(),
|
|
88
|
+
logger: this.log,
|
|
89
|
+
metrics: this.metrics,
|
|
90
|
+
queueSize: options.queueSize ?? 1024,
|
|
91
|
+
drainTimeoutMs: options.drainTimeoutMs ?? 5000,
|
|
92
|
+
ackTimeoutMs: options.ackTimeoutMs ?? 2000,
|
|
93
|
+
ackRandomFactor: options.ackRandomFactor ?? 1.5,
|
|
94
|
+
maxRetransmit: options.maxRetransmit ?? 4,
|
|
95
|
+
nonTimeoutMs: options.nonTimeoutMs ?? 145_000,
|
|
96
|
+
nstart: options.nstart ?? 1,
|
|
97
|
+
perEndpointLruCap: options.perEndpointLruCap ?? 64,
|
|
98
|
+
};
|
|
99
|
+
this.tcpStore = createInMemoryStore();
|
|
100
|
+
this.tcpConnectionManager = new TcpConnectionManager({
|
|
101
|
+
idleTimeoutMs: this.options.drainTimeoutMs,
|
|
102
|
+
logger: this.log,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async start() {
|
|
106
|
+
const resolved = this.options.listen.map(addr => parseListenAddress(addr));
|
|
107
|
+
const udpAddrs = resolved.filter(a => a.transport === 'udp');
|
|
108
|
+
const dtlsAddrs = resolved.filter(a => a.transport === 'dtls');
|
|
109
|
+
const tcpAddrs = resolved.filter(a => a.transport === 'tcp');
|
|
110
|
+
const tlsAddrs = resolved.filter(a => a.transport === 'tls');
|
|
111
|
+
const udpBindings = udpAddrs.map(addr => this._bindSocket(addr));
|
|
112
|
+
const dtlsBindings = dtlsAddrs.map(addr => this._bindDtlsSocket(addr));
|
|
113
|
+
const tcpBindings = tcpAddrs.map(addr => this._bindTcpServer(addr));
|
|
114
|
+
const tlsBindings = tlsAddrs.map(addr => this._bindTlsServer(addr));
|
|
115
|
+
let boundSockets;
|
|
116
|
+
let boundDtlsSockets;
|
|
117
|
+
let boundTcpServers;
|
|
118
|
+
let boundTlsServers;
|
|
119
|
+
try {
|
|
120
|
+
[boundSockets, boundDtlsSockets, boundTcpServers, boundTlsServers] = await Promise.all([
|
|
121
|
+
Promise.all(udpBindings),
|
|
122
|
+
Promise.all(dtlsBindings),
|
|
123
|
+
Promise.all(tcpBindings),
|
|
124
|
+
Promise.all(tlsBindings),
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
await Promise.allSettled([
|
|
129
|
+
...this.sockets.map(s => this._closeSocket(s.socket)),
|
|
130
|
+
...this.dtlsSockets.map(s => s.dtlsSocket.close()),
|
|
131
|
+
...this.tcpServers.map(s => this._closeTcpServer(s.server)),
|
|
132
|
+
...this.tlsServers.map(s => this._closeTlsServer(s.server)),
|
|
133
|
+
]);
|
|
134
|
+
this.sockets = [];
|
|
135
|
+
this.dtlsSockets = [];
|
|
136
|
+
this.tcpServers = [];
|
|
137
|
+
this.tlsServers = [];
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
this.sockets = boundSockets;
|
|
141
|
+
this.dtlsSockets = boundDtlsSockets;
|
|
142
|
+
this.tcpServers = boundTcpServers;
|
|
143
|
+
this.tlsServers = boundTlsServers;
|
|
144
|
+
this.ready = true;
|
|
145
|
+
this.log.debug('server started', { listeners: this.listenerSummary() });
|
|
146
|
+
if (!this.warningEmitted && this.isDefaultUdpStore) {
|
|
147
|
+
const allAddrs = [
|
|
148
|
+
...this.sockets.map(s => s.address),
|
|
149
|
+
...this.dtlsSockets.map(s => s.address),
|
|
150
|
+
...this.tcpServers.map(s => s.address),
|
|
151
|
+
...this.tlsServers.map(s => s.address),
|
|
152
|
+
];
|
|
153
|
+
for (const addr of allAddrs) {
|
|
154
|
+
if (!this._isLoopback(addr.host)) {
|
|
155
|
+
process.emitWarning('CoAP server bound to non-loopback address with default in-memory store — not suitable for production', { code: 'SIRENA_COAP_DEFAULT_STORE' });
|
|
156
|
+
this.warningEmitted = true;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async stop(opts) {
|
|
163
|
+
this.ready = false;
|
|
164
|
+
const deadline = opts?.drainTimeoutMs ?? this.options.drainTimeoutMs;
|
|
165
|
+
this.log.debug('server stopping', { drainTimeoutMs: deadline });
|
|
166
|
+
if (this.inflight > 0) {
|
|
167
|
+
await Promise.race([
|
|
168
|
+
this._waitForDrain(),
|
|
169
|
+
new Promise(res => setTimeout(res, deadline)),
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
await Promise.allSettled([
|
|
173
|
+
...this.sockets.map(s => this._closeSocket(s.socket)),
|
|
174
|
+
...this.dtlsSockets.map(s => s.dtlsSocket.close()),
|
|
175
|
+
...this.tcpServers.map(s => this._closeTcpServer(s.server)),
|
|
176
|
+
...this.tlsServers.map(s => this._closeTlsServer(s.server)),
|
|
177
|
+
]);
|
|
178
|
+
this.sockets = [];
|
|
179
|
+
this.dtlsSockets = [];
|
|
180
|
+
this.tcpServers = [];
|
|
181
|
+
this.tlsServers = [];
|
|
182
|
+
this.endpointState.clear();
|
|
183
|
+
for (const entry of this.activeLoops.values()) {
|
|
184
|
+
entry.loop.cancel();
|
|
185
|
+
}
|
|
186
|
+
this.activeLoops.clear();
|
|
187
|
+
this.activeNotifChunkers.clear();
|
|
188
|
+
this.observeRegistrations.clear();
|
|
189
|
+
this.log.debug('server stopped');
|
|
190
|
+
}
|
|
191
|
+
isReady() {
|
|
192
|
+
return this.ready;
|
|
193
|
+
}
|
|
194
|
+
activeExchanges() {
|
|
195
|
+
return this.inflight;
|
|
196
|
+
}
|
|
197
|
+
listenerSummary() {
|
|
198
|
+
return [
|
|
199
|
+
...this.sockets.map(s => ({ ...s.address })),
|
|
200
|
+
...this.dtlsSockets.map(s => ({ ...s.address })),
|
|
201
|
+
...this.tcpServers.map(s => ({ ...s.address })),
|
|
202
|
+
...this.tlsServers.map(s => ({ ...s.address })),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
storeSummary() {
|
|
206
|
+
const resolved = this.options.listen.map(a => parseListenAddress(a));
|
|
207
|
+
const hasUdp = resolved.some(a => a.transport === 'udp');
|
|
208
|
+
const hasTcp = resolved.some(a => a.transport === 'tcp');
|
|
209
|
+
const result = [];
|
|
210
|
+
if (hasUdp)
|
|
211
|
+
result.push({ transport: 'udp', isDefault: this.isDefaultUdpStore });
|
|
212
|
+
if (hasTcp)
|
|
213
|
+
result.push({ transport: 'tcp', isDefault: true });
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
_bindSocket(addr) {
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
const sock = dgram.createSocket('udp4');
|
|
219
|
+
sock.once('error', reject);
|
|
220
|
+
sock.bind(addr.port, addr.host, () => {
|
|
221
|
+
sock.removeListener('error', reject);
|
|
222
|
+
const actual = sock.address();
|
|
223
|
+
const bound = {
|
|
224
|
+
socket: sock,
|
|
225
|
+
address: { host: actual.address, port: actual.port, transport: 'udp' },
|
|
226
|
+
};
|
|
227
|
+
this.sockets.push(bound);
|
|
228
|
+
sock.on('message', (msg, rinfo) => this._handleDatagram(sock, bound.address, msg, rinfo));
|
|
229
|
+
sock.on('error', err => {
|
|
230
|
+
this.log.error('socket error', { err: err.message });
|
|
231
|
+
this.emit('exchange-error', err);
|
|
232
|
+
});
|
|
233
|
+
resolve(bound);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async _bindDtlsSocket(addr) {
|
|
238
|
+
const security = this.options.security;
|
|
239
|
+
if (!security) {
|
|
240
|
+
throw new Error('security option required for DTLS transport');
|
|
241
|
+
}
|
|
242
|
+
const dtlsSocket = createDtlsSocket(addr, security, { logger: this.log });
|
|
243
|
+
dtlsSocket.on('exchange-error', (err) => {
|
|
244
|
+
this.log.warn('dtls exchange error', { err: err.message });
|
|
245
|
+
this.emit('exchange-error', err);
|
|
246
|
+
});
|
|
247
|
+
dtlsSocket.on('dtls-handshake', (payload) => {
|
|
248
|
+
this.emit('dtls-handshake', { peer: payload.peer, durationMs: payload.durationMs });
|
|
249
|
+
});
|
|
250
|
+
dtlsSocket.on('message', (msg, rinfo, dtlsPeer) => {
|
|
251
|
+
this._handleDtlsDatagram(dtlsSocket, addr, msg, rinfo, dtlsPeer);
|
|
252
|
+
});
|
|
253
|
+
dtlsSocket.on('error', (err) => {
|
|
254
|
+
this.log.error('dtls socket error', { err: err.message });
|
|
255
|
+
this.emit('exchange-error', err);
|
|
256
|
+
});
|
|
257
|
+
const actual = await dtlsSocket.bind();
|
|
258
|
+
const state = {
|
|
259
|
+
dtlsSocket,
|
|
260
|
+
address: { host: actual.host, port: actual.port, transport: 'dtls' },
|
|
261
|
+
};
|
|
262
|
+
this.dtlsSockets.push(state);
|
|
263
|
+
return state;
|
|
264
|
+
}
|
|
265
|
+
_handleDtlsDatagram(dtlsSocket, local, msg, rinfo, dtlsPeer) {
|
|
266
|
+
if (!this.ready)
|
|
267
|
+
return;
|
|
268
|
+
const remote = `${rinfo.address}:${rinfo.port}`;
|
|
269
|
+
let decoded;
|
|
270
|
+
try {
|
|
271
|
+
decoded = decodeMessage(new Uint8Array(msg));
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
275
|
+
this.log.warn('dtls decode error', { remote, err: error.message });
|
|
276
|
+
this.metrics.transportErrors.add(1);
|
|
277
|
+
this.emit('exchange-error', error);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const { store } = this.options;
|
|
281
|
+
const isCon = decoded.type === 'CON';
|
|
282
|
+
const key = isCon
|
|
283
|
+
? { remote, discriminant: 'mid', mid: decoded.messageId }
|
|
284
|
+
: { remote, discriminant: 'token', token: Buffer.from(decoded.token).toString('hex') };
|
|
285
|
+
const existing = store.get(key);
|
|
286
|
+
if (existing) {
|
|
287
|
+
this.metrics.dedupHits.add(1);
|
|
288
|
+
if (existing.responsePayload) {
|
|
289
|
+
dtlsSocket.send(existing.responsePayload, rinfo.address, rinfo.port);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (this.inflight >= this.options.queueSize) {
|
|
294
|
+
const reason = `queue full (size=${this.options.queueSize})`;
|
|
295
|
+
this.log.warn('dtls message dropped', { remote, reason });
|
|
296
|
+
this.emit('message-dropped', reason);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const ep = this._getEndpointState(remote);
|
|
300
|
+
const epTotal = ep.inflight + ep.queue.length;
|
|
301
|
+
if (epTotal >= this.options.perEndpointLruCap) {
|
|
302
|
+
const reason = `per-endpoint cap exceeded (cap=${this.options.perEndpointLruCap}, remote=${remote})`;
|
|
303
|
+
this.log.warn('dtls message dropped', { remote, reason });
|
|
304
|
+
this.emit('message-dropped', reason);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const lifetime = isCon ? EXCHANGE_LIFETIME : 250_000;
|
|
308
|
+
store.set(key, { expiresAt: Date.now() + lifetime });
|
|
309
|
+
this.metrics.requestRate.add(1);
|
|
310
|
+
this.metrics.activeExchanges.add(1);
|
|
311
|
+
this.inflight++;
|
|
312
|
+
const req = new Request(decoded);
|
|
313
|
+
const ctx = {
|
|
314
|
+
remote,
|
|
315
|
+
local: `${local.host}:${local.port}`,
|
|
316
|
+
transport: 'udp',
|
|
317
|
+
token: decoded.token,
|
|
318
|
+
messageId: decoded.messageId,
|
|
319
|
+
options: decoded.options,
|
|
320
|
+
peer: dtlsPeer,
|
|
321
|
+
respond: async (res) => {
|
|
322
|
+
const responseMsg = encodeMessage({
|
|
323
|
+
version: 1,
|
|
324
|
+
type: isCon ? 'ACK' : 'NON',
|
|
325
|
+
token: decoded.token,
|
|
326
|
+
code: res.statusCode,
|
|
327
|
+
messageId: decoded.messageId,
|
|
328
|
+
options: res.headers.toOptions(),
|
|
329
|
+
payload: res.body ?? new Uint8Array(0),
|
|
330
|
+
});
|
|
331
|
+
store.set(key, { expiresAt: Date.now() + lifetime, responsePayload: responseMsg });
|
|
332
|
+
dtlsSocket.send(responseMsg, rinfo.address, rinfo.port);
|
|
333
|
+
this.emit('response', ctx);
|
|
334
|
+
},
|
|
335
|
+
continue: async () => {
|
|
336
|
+
const continueMsg = encodeMessage({
|
|
337
|
+
version: 1,
|
|
338
|
+
type: isCon ? 'ACK' : 'NON',
|
|
339
|
+
token: decoded.token,
|
|
340
|
+
code: 0x5f, // 2.31 Continue
|
|
341
|
+
messageId: decoded.messageId,
|
|
342
|
+
options: decoded.options.filter(o => o.number === 27), // echo Block1
|
|
343
|
+
payload: new Uint8Array(0),
|
|
344
|
+
});
|
|
345
|
+
dtlsSocket.send(continueMsg, rinfo.address, rinfo.port);
|
|
346
|
+
},
|
|
347
|
+
cancel: () => { },
|
|
348
|
+
abort: (_code) => { },
|
|
349
|
+
observeSeq: 0,
|
|
350
|
+
observe: (_obs) => { },
|
|
351
|
+
};
|
|
352
|
+
const runHandler = () => {
|
|
353
|
+
ep.inflight++;
|
|
354
|
+
return Promise.resolve().then(async () => {
|
|
355
|
+
this.log.debug('dispatching dtls request', { remote, messageId: decoded.messageId });
|
|
356
|
+
const listeners = this.rawListeners('request');
|
|
357
|
+
const results = listeners.map(fn => fn(req, ctx));
|
|
358
|
+
await Promise.allSettled(results.map(r => r instanceof Promise ? r : Promise.resolve(r)));
|
|
359
|
+
}).catch(err => {
|
|
360
|
+
this.log.error('dtls exchange error', { remote, err: err.message });
|
|
361
|
+
this.emit('exchange-error', err instanceof Error ? err : new Error(String(err)));
|
|
362
|
+
}).finally(() => {
|
|
363
|
+
this.inflight--;
|
|
364
|
+
ep.inflight--;
|
|
365
|
+
this.metrics.activeExchanges.add(-1);
|
|
366
|
+
const next = ep.queue.shift();
|
|
367
|
+
if (next) {
|
|
368
|
+
void next();
|
|
369
|
+
}
|
|
370
|
+
else if (ep.inflight === 0 && ep.queue.length === 0) {
|
|
371
|
+
this.endpointState.delete(remote);
|
|
372
|
+
}
|
|
373
|
+
this.emit('_drain');
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
if (ep.inflight >= this.options.nstart) {
|
|
377
|
+
ep.queue.push(runHandler);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
void runHandler();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
_bindTcpServer(addr) {
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
const srv = net.createServer();
|
|
386
|
+
srv.once('error', reject);
|
|
387
|
+
srv.listen(addr.port, addr.host, () => {
|
|
388
|
+
srv.removeListener('error', reject);
|
|
389
|
+
const actual = srv.address();
|
|
390
|
+
const state = {
|
|
391
|
+
server: srv,
|
|
392
|
+
address: { host: actual.address, port: actual.port, transport: 'tcp' },
|
|
393
|
+
};
|
|
394
|
+
this.tcpServers.push(state);
|
|
395
|
+
srv.on('connection', (socket) => this._handleTcpConnection(socket, state.address));
|
|
396
|
+
srv.on('error', err => {
|
|
397
|
+
this.log.error('tcp server error', { err: err.message });
|
|
398
|
+
this.emit('exchange-error', err);
|
|
399
|
+
});
|
|
400
|
+
resolve(state);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
_buildTlsOptions() {
|
|
405
|
+
const security = this.options.security;
|
|
406
|
+
if (!security) {
|
|
407
|
+
throw new Error('security option required for TLS transport');
|
|
408
|
+
}
|
|
409
|
+
const tlsOpts = {
|
|
410
|
+
sessionTimeout: 300,
|
|
411
|
+
};
|
|
412
|
+
if (security.mode === 'psk') {
|
|
413
|
+
const pskFn = security.psk;
|
|
414
|
+
tlsOpts.pskCallback = (socket, identity) => {
|
|
415
|
+
const psk = pskFn(identity);
|
|
416
|
+
if (psk)
|
|
417
|
+
this.tlsPskIdentityBySocket.set(socket, identity);
|
|
418
|
+
return psk;
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
const pending = this.pendingTlsUpdate;
|
|
423
|
+
tlsOpts.key = pending ? pending.key : security.key;
|
|
424
|
+
tlsOpts.cert = pending ? pending.cert : security.cert;
|
|
425
|
+
if (security.ca)
|
|
426
|
+
tlsOpts.ca = security.ca;
|
|
427
|
+
if (security.requestClientCert) {
|
|
428
|
+
tlsOpts.requestCert = true;
|
|
429
|
+
tlsOpts.rejectUnauthorized = true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return tlsOpts;
|
|
433
|
+
}
|
|
434
|
+
updateTls(opts) {
|
|
435
|
+
this.pendingTlsUpdate = opts;
|
|
436
|
+
for (const state of this.tlsServers) {
|
|
437
|
+
state.server.setSecureContext({ cert: opts.cert, key: opts.key });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
_bindTlsServer(addr) {
|
|
441
|
+
return new Promise((resolve, reject) => {
|
|
442
|
+
const tlsOpts = this._buildTlsOptions();
|
|
443
|
+
const srv = tls.createServer(tlsOpts);
|
|
444
|
+
srv.once('error', reject);
|
|
445
|
+
srv.on('tlsClientError', (err) => {
|
|
446
|
+
this.log.warn('tls handshake failure', { err: err.message });
|
|
447
|
+
this.emit('exchange-error', new Error(`TLS handshake failed: ${err.message}`));
|
|
448
|
+
});
|
|
449
|
+
srv.listen(addr.port, addr.host, () => {
|
|
450
|
+
srv.removeListener('error', reject);
|
|
451
|
+
const actual = srv.address();
|
|
452
|
+
const state = {
|
|
453
|
+
server: srv,
|
|
454
|
+
address: { host: actual.address, port: actual.port, transport: 'tls' },
|
|
455
|
+
};
|
|
456
|
+
this.tlsServers.push(state);
|
|
457
|
+
srv.on('secureConnection', (tlsSock) => {
|
|
458
|
+
const handshakeStart = Date.now();
|
|
459
|
+
const durationMs = Date.now() - handshakeStart;
|
|
460
|
+
let peer;
|
|
461
|
+
const security = this.options.security;
|
|
462
|
+
if (security?.mode === 'psk') {
|
|
463
|
+
peer = { mode: 'psk', identity: this.tlsPskIdentityBySocket.get(tlsSock) ?? 'unknown' };
|
|
464
|
+
}
|
|
465
|
+
else if (security?.mode === 'x509') {
|
|
466
|
+
const certChain = [];
|
|
467
|
+
let cert = tlsSock.getPeerX509Certificate();
|
|
468
|
+
while (cert) {
|
|
469
|
+
certChain.push(cert);
|
|
470
|
+
cert = cert.issuerCertificate;
|
|
471
|
+
}
|
|
472
|
+
peer = { mode: 'x509', certChain };
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
peer = { mode: 'anonymous' };
|
|
476
|
+
}
|
|
477
|
+
this.log.debug('tls handshake complete', { remote: `${tlsSock.remoteAddress}:${tlsSock.remotePort}`, durationMs });
|
|
478
|
+
this.emit('tls-handshake', { peer, durationMs });
|
|
479
|
+
this._handleTcpConnection(tlsSock, state.address, peer);
|
|
480
|
+
});
|
|
481
|
+
srv.on('error', err => {
|
|
482
|
+
this.log.error('tls server error', { err: err.message });
|
|
483
|
+
this.emit('exchange-error', err);
|
|
484
|
+
});
|
|
485
|
+
resolve(state);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
_closeTlsServer(srv) {
|
|
490
|
+
return new Promise(res => {
|
|
491
|
+
try {
|
|
492
|
+
srv.close(() => res());
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
res();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
_handleTcpConnection(socket, localAddr, connectionPeer = { mode: 'anonymous' }) {
|
|
500
|
+
if (!this.ready) {
|
|
501
|
+
socket.destroy();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.tcpConnectionManager.track(socket);
|
|
505
|
+
const remote = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
506
|
+
const local = `${localAddr.host}:${localAddr.port}`;
|
|
507
|
+
const frameStream = new TcpFrameStream({ socket, logger: this.log });
|
|
508
|
+
socket.once('close', () => {
|
|
509
|
+
this.tcpConnectionManager.untrack(socket);
|
|
510
|
+
});
|
|
511
|
+
frameStream.on('frame', (frame) => {
|
|
512
|
+
if (!this.ready)
|
|
513
|
+
return;
|
|
514
|
+
// RFC 8323 §5 — handle signaling codes without dispatching to application
|
|
515
|
+
if ((frame.code & TCP_SIGNALING_CLASS) === TCP_SIGNALING_CLASS) {
|
|
516
|
+
if (frame.code === CODE_CSM) {
|
|
517
|
+
// Respond with our own minimal CSM
|
|
518
|
+
socket.write(Buffer.from(CSM_FRAME));
|
|
519
|
+
}
|
|
520
|
+
else if (frame.code === CODE_PING) {
|
|
521
|
+
// Respond with Pong (same token)
|
|
522
|
+
socket.write(Buffer.from(encodeTcpFrame({ code: CODE_PONG, token: frame.token, options: [], payload: new Uint8Array(0) })));
|
|
523
|
+
}
|
|
524
|
+
else if (frame.code === CODE_RELEASE || frame.code === CODE_ABORT) {
|
|
525
|
+
socket.destroy();
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const tokenKey = {
|
|
530
|
+
remote,
|
|
531
|
+
discriminant: 'token',
|
|
532
|
+
token: Buffer.from(frame.token).toString('hex'),
|
|
533
|
+
};
|
|
534
|
+
const existing = this.tcpStore.get(tokenKey);
|
|
535
|
+
if (existing) {
|
|
536
|
+
if (existing.responsePayload) {
|
|
537
|
+
socket.write(existing.responsePayload);
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
this.tcpStore.set(tokenKey, { expiresAt: Date.now() + EXCHANGE_LIFETIME });
|
|
542
|
+
frameStream.registerExchange(frame.token);
|
|
543
|
+
const syntheticMsg = {
|
|
544
|
+
version: 1,
|
|
545
|
+
type: 'CON',
|
|
546
|
+
token: frame.token,
|
|
547
|
+
code: frame.code,
|
|
548
|
+
messageId: 0,
|
|
549
|
+
options: frame.options,
|
|
550
|
+
payload: frame.payload,
|
|
551
|
+
};
|
|
552
|
+
const req = new Request(syntheticMsg);
|
|
553
|
+
const ctx = {
|
|
554
|
+
remote,
|
|
555
|
+
local,
|
|
556
|
+
transport: 'tcp',
|
|
557
|
+
token: frame.token,
|
|
558
|
+
messageId: 0,
|
|
559
|
+
options: frame.options,
|
|
560
|
+
peer: connectionPeer,
|
|
561
|
+
respond: async (res) => {
|
|
562
|
+
const responseFrame = encodeTcpFrame({
|
|
563
|
+
code: res.statusCode,
|
|
564
|
+
token: frame.token,
|
|
565
|
+
options: res.headers.toOptions(),
|
|
566
|
+
payload: res.body ?? new Uint8Array(0),
|
|
567
|
+
});
|
|
568
|
+
const responseBytes = Buffer.from(responseFrame);
|
|
569
|
+
this.tcpStore.set(tokenKey, { expiresAt: Date.now() + EXCHANGE_LIFETIME, responsePayload: responseBytes });
|
|
570
|
+
socket.write(responseBytes);
|
|
571
|
+
frameStream.resolveExchange(frame.token);
|
|
572
|
+
this.inflight--;
|
|
573
|
+
this.emit('response', ctx);
|
|
574
|
+
this.emit('_drain');
|
|
575
|
+
},
|
|
576
|
+
continue: async () => {
|
|
577
|
+
const continueFrame = encodeTcpFrame({
|
|
578
|
+
code: 0x5f, // 2.31 Continue
|
|
579
|
+
token: frame.token,
|
|
580
|
+
options: frame.options.filter(o => o.number === 27), // echo Block1
|
|
581
|
+
payload: new Uint8Array(0),
|
|
582
|
+
});
|
|
583
|
+
socket.write(Buffer.from(continueFrame));
|
|
584
|
+
},
|
|
585
|
+
cancel: () => { },
|
|
586
|
+
abort: (_code) => { },
|
|
587
|
+
observeSeq: 0,
|
|
588
|
+
observe: (_obs) => { },
|
|
589
|
+
};
|
|
590
|
+
this.inflight++;
|
|
591
|
+
Promise.resolve()
|
|
592
|
+
.then(async () => {
|
|
593
|
+
this.log.debug('dispatching tcp request', { remote });
|
|
594
|
+
const listeners = this.rawListeners('request');
|
|
595
|
+
const results = listeners.map(fn => fn(req, ctx));
|
|
596
|
+
const settled = await Promise.allSettled(results.map(r => r instanceof Promise ? r : Promise.resolve(r)));
|
|
597
|
+
const rejected = settled.find(s => s.status === 'rejected');
|
|
598
|
+
if (rejected) {
|
|
599
|
+
throw rejected.reason;
|
|
600
|
+
}
|
|
601
|
+
})
|
|
602
|
+
.catch((err) => {
|
|
603
|
+
this.inflight--;
|
|
604
|
+
this.tcpStore.delete(tokenKey);
|
|
605
|
+
frameStream.resolveExchange(frame.token);
|
|
606
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
607
|
+
this.log.error('tcp exchange error', { remote, err: error.message });
|
|
608
|
+
this.emit('exchange-error', error);
|
|
609
|
+
this.emit('_drain');
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
frameStream.on('frame-error', (err) => {
|
|
613
|
+
this.log.error('tcp frame error', { remote, err: err.message });
|
|
614
|
+
this.emit('exchange-error', err);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
_closeTcpServer(srv) {
|
|
618
|
+
return new Promise(res => {
|
|
619
|
+
try {
|
|
620
|
+
srv.close(() => res());
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
res();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
_closeSocket(sock) {
|
|
628
|
+
return new Promise(res => {
|
|
629
|
+
try {
|
|
630
|
+
sock.close(() => res());
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
res();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
_getEndpointState(remote) {
|
|
638
|
+
let state = this.endpointState.get(remote);
|
|
639
|
+
if (!state) {
|
|
640
|
+
state = { inflight: 0, queue: [] };
|
|
641
|
+
this.endpointState.set(remote, state);
|
|
642
|
+
}
|
|
643
|
+
return state;
|
|
644
|
+
}
|
|
645
|
+
_nextNotifMid() {
|
|
646
|
+
this.notifMidCounter = (this.notifMidCounter + 1) & 0xffff;
|
|
647
|
+
return this.notifMidCounter;
|
|
648
|
+
}
|
|
649
|
+
_handleDatagram(sock, local, msg, rinfo) {
|
|
650
|
+
if (!this.ready)
|
|
651
|
+
return;
|
|
652
|
+
const remote = `${rinfo.address}:${rinfo.port}`;
|
|
653
|
+
let decoded;
|
|
654
|
+
try {
|
|
655
|
+
decoded = decodeMessage(new Uint8Array(msg));
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
659
|
+
this.log.warn('decode error', { remote, err: error.message });
|
|
660
|
+
this.metrics.transportErrors.add(1);
|
|
661
|
+
this.emit('exchange-error', error);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// ACK for an outstanding notification CON: advance the loop
|
|
665
|
+
if (decoded.type === 'ACK' && decoded.code === 0x00) {
|
|
666
|
+
const tokenHex = Buffer.from(decoded.token).toString('hex');
|
|
667
|
+
const regKey = `${remote}:${tokenHex}`;
|
|
668
|
+
const entry = this.activeLoops.get(regKey);
|
|
669
|
+
if (entry && entry.lastMid === decoded.messageId) {
|
|
670
|
+
entry.lastMid = null;
|
|
671
|
+
entry.loop.ackReceived();
|
|
672
|
+
}
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// RST for an outstanding notification CON: cancel the Observe registration (RFC 7641 §4.2)
|
|
676
|
+
if (decoded.type === 'RST') {
|
|
677
|
+
const tokenHex = Buffer.from(decoded.token).toString('hex');
|
|
678
|
+
const regKey = `${remote}:${tokenHex}`;
|
|
679
|
+
const entry = this.activeLoops.get(regKey);
|
|
680
|
+
if (entry && entry.lastMid === decoded.messageId) {
|
|
681
|
+
entry.loop.cancel();
|
|
682
|
+
this.activeLoops.delete(regKey);
|
|
683
|
+
const reg = this.observeRegistrations.get(regKey);
|
|
684
|
+
if (reg) {
|
|
685
|
+
this.observeRegistrations.delete(regKey);
|
|
686
|
+
this.metrics.observeRegistrations.add(-1);
|
|
687
|
+
this.emit('observe-cancelled', reg);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
// Block2 continuation GET for an active notification chunker
|
|
693
|
+
const isGetReq = decoded.code === 0x01;
|
|
694
|
+
if (isGetReq && (decoded.type === 'CON' || decoded.type === 'NON')) {
|
|
695
|
+
const block2ContinueOpt = decoded.options.find(o => o.number === OptionNumber.Block2);
|
|
696
|
+
const hasNoObserveOpt = !decoded.options.find(o => o.number === OptionNumber.Observe);
|
|
697
|
+
if (block2ContinueOpt && hasNoObserveOpt) {
|
|
698
|
+
const tokenHexC = Buffer.from(decoded.token).toString('hex');
|
|
699
|
+
const regKeyC = `${remote}:${tokenHexC}`;
|
|
700
|
+
// Fall back to URI-path key for clients (e.g. libcoap) that use a different token
|
|
701
|
+
// on continuation GETs than the original observe registration.
|
|
702
|
+
const contPathParts = decoded.options
|
|
703
|
+
.filter(o => o.number === OptionNumber.UriPath)
|
|
704
|
+
.map(o => bytesToString(o.value));
|
|
705
|
+
const contUriPath = '/' + contPathParts.join('/');
|
|
706
|
+
const pathKeyC = `${remote}:path:${contUriPath}`;
|
|
707
|
+
const chunker = this.activeNotifChunkers.get(regKeyC) ?? this.activeNotifChunkers.get(pathKeyC);
|
|
708
|
+
const chunkerKey = this.activeNotifChunkers.has(regKeyC) ? regKeyC : pathKeyC;
|
|
709
|
+
if (chunker) {
|
|
710
|
+
const block = decodeBlockOption(block2ContinueOpt.value);
|
|
711
|
+
void chunker.getBlock(block.num).then(({ payload, m, etag }) => {
|
|
712
|
+
const responseMsg = encodeMessage({
|
|
713
|
+
version: 1,
|
|
714
|
+
type: decoded.type === 'CON' ? 'ACK' : 'NON',
|
|
715
|
+
token: decoded.token,
|
|
716
|
+
code: stringToCode('2.05'),
|
|
717
|
+
messageId: decoded.messageId,
|
|
718
|
+
options: [
|
|
719
|
+
{ number: 4 /* ETag */, value: etag },
|
|
720
|
+
{ number: OptionNumber.Block2, value: encodeBlockOption({ num: block.num, szx: block.szx, m }) },
|
|
721
|
+
],
|
|
722
|
+
payload,
|
|
723
|
+
});
|
|
724
|
+
sock.send(responseMsg, rinfo.port, rinfo.address);
|
|
725
|
+
if (!m) {
|
|
726
|
+
this.activeNotifChunkers.delete(chunkerKey);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const { store } = this.options;
|
|
734
|
+
const isCon = decoded.type === 'CON';
|
|
735
|
+
const key = isCon
|
|
736
|
+
? { remote, discriminant: 'mid', mid: decoded.messageId }
|
|
737
|
+
: { remote, discriminant: 'token', token: Buffer.from(decoded.token).toString('hex') };
|
|
738
|
+
const existing = store.get(key);
|
|
739
|
+
if (existing) {
|
|
740
|
+
this.metrics.dedupHits.add(1);
|
|
741
|
+
if (existing.responsePayload) {
|
|
742
|
+
sock.send(existing.responsePayload, rinfo.port, rinfo.address);
|
|
743
|
+
}
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (this.inflight >= this.options.queueSize) {
|
|
747
|
+
const reason = `queue full (size=${this.options.queueSize})`;
|
|
748
|
+
this.log.warn('message dropped', { remote, reason });
|
|
749
|
+
this.emit('message-dropped', reason);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const ep = this._getEndpointState(remote);
|
|
753
|
+
// Per-endpoint LRU cap check (counts inflight + queued)
|
|
754
|
+
const epTotal = ep.inflight + ep.queue.length;
|
|
755
|
+
if (epTotal >= this.options.perEndpointLruCap) {
|
|
756
|
+
const reason = `per-endpoint cap exceeded (cap=${this.options.perEndpointLruCap}, remote=${remote})`;
|
|
757
|
+
this.log.warn('message dropped', { remote, reason });
|
|
758
|
+
this.emit('message-dropped', reason);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
// Store a placeholder immediately so concurrent duplicates don't slip through
|
|
762
|
+
const lifetime = isCon ? EXCHANGE_LIFETIME : 250_000;
|
|
763
|
+
store.set(key, { expiresAt: Date.now() + lifetime });
|
|
764
|
+
this.metrics.requestRate.add(1);
|
|
765
|
+
this.metrics.activeExchanges.add(1);
|
|
766
|
+
// Global inflight covers both running and queued exchanges (so drain-on-stop works)
|
|
767
|
+
this.inflight++;
|
|
768
|
+
// RFC 7641 §3.1 / §3.6 — Observe registration tracking
|
|
769
|
+
const isGet = decoded.code === 0x01; // 0.01 GET
|
|
770
|
+
const tokenHex = Buffer.from(decoded.token).toString('hex');
|
|
771
|
+
const observeOpt = decoded.options.find(o => o.number === OptionNumber.Observe);
|
|
772
|
+
const isObserveRegister = isGet && observeOpt !== undefined && bytesToUint(observeOpt.value) === 0;
|
|
773
|
+
const regKey = `${remote}:${tokenHex}`;
|
|
774
|
+
// RFC 7641 §3.6: deregister via Observe:1 or plain GET (no Observe option)
|
|
775
|
+
const isObserveDeregister = isGet && observeOpt !== undefined && bytesToUint(observeOpt.value) === 1;
|
|
776
|
+
if (isObserveRegister) {
|
|
777
|
+
// Cancel any existing loop for this (remote, token) before replacing
|
|
778
|
+
const prevLoop = this.activeLoops.get(regKey);
|
|
779
|
+
if (prevLoop) {
|
|
780
|
+
prevLoop.loop.cancel();
|
|
781
|
+
this.activeLoops.delete(regKey);
|
|
782
|
+
}
|
|
783
|
+
const reg = { remote, token: decoded.token };
|
|
784
|
+
this.observeRegistrations.set(regKey, reg);
|
|
785
|
+
this.metrics.observeRegistrations.add(1);
|
|
786
|
+
this.emit('observe-registered', reg);
|
|
787
|
+
}
|
|
788
|
+
else if (isObserveDeregister || (isGet && observeOpt === undefined)) {
|
|
789
|
+
const existingLoop = this.activeLoops.get(regKey);
|
|
790
|
+
if (existingLoop) {
|
|
791
|
+
// loop.cancel() triggers onCancel, which deletes the registration and emits observe-cancelled
|
|
792
|
+
existingLoop.loop.cancel();
|
|
793
|
+
this.activeLoops.delete(regKey);
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
// No active loop — cancel any lingering registration directly
|
|
797
|
+
const existingReg = this.observeRegistrations.get(regKey);
|
|
798
|
+
if (existingReg) {
|
|
799
|
+
this.observeRegistrations.delete(regKey);
|
|
800
|
+
this.metrics.observeRegistrations.add(-1);
|
|
801
|
+
this.emit('observe-cancelled', existingReg);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const req = new Request(decoded);
|
|
806
|
+
const ctx = {
|
|
807
|
+
remote,
|
|
808
|
+
local: `${local.host}:${local.port}`,
|
|
809
|
+
transport: 'udp',
|
|
810
|
+
token: decoded.token,
|
|
811
|
+
messageId: decoded.messageId,
|
|
812
|
+
options: decoded.options,
|
|
813
|
+
peer: { mode: 'anonymous' },
|
|
814
|
+
respond: async (res) => {
|
|
815
|
+
const responseOptions = res.headers.toOptions();
|
|
816
|
+
// Echo Observe:0 in initial registration response (RFC 7641 §3.1)
|
|
817
|
+
if (isObserveRegister) {
|
|
818
|
+
const insertIdx = responseOptions.findIndex(o => o.number >= OptionNumber.Observe);
|
|
819
|
+
const observeEntry = { number: OptionNumber.Observe, value: new Uint8Array(0) };
|
|
820
|
+
if (insertIdx === -1) {
|
|
821
|
+
responseOptions.push(observeEntry);
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
responseOptions.splice(insertIdx, 0, observeEntry);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const responseMsg = encodeMessage({
|
|
828
|
+
version: 1,
|
|
829
|
+
type: isCon ? 'ACK' : 'NON',
|
|
830
|
+
token: decoded.token,
|
|
831
|
+
code: res.statusCode,
|
|
832
|
+
messageId: decoded.messageId,
|
|
833
|
+
options: responseOptions,
|
|
834
|
+
payload: res.body ?? new Uint8Array(0),
|
|
835
|
+
});
|
|
836
|
+
store.set(key, { expiresAt: Date.now() + lifetime, responsePayload: responseMsg });
|
|
837
|
+
sock.send(responseMsg, rinfo.port, rinfo.address);
|
|
838
|
+
this.emit('response', ctx);
|
|
839
|
+
},
|
|
840
|
+
continue: async () => {
|
|
841
|
+
const continueMsg = encodeMessage({
|
|
842
|
+
version: 1,
|
|
843
|
+
type: isCon ? 'ACK' : 'NON',
|
|
844
|
+
token: decoded.token,
|
|
845
|
+
code: 0x5f, // 2.31 Continue
|
|
846
|
+
messageId: decoded.messageId,
|
|
847
|
+
options: decoded.options.filter(o => o.number === 27), // echo Block1
|
|
848
|
+
payload: new Uint8Array(0),
|
|
849
|
+
});
|
|
850
|
+
sock.send(continueMsg, rinfo.port, rinfo.address);
|
|
851
|
+
},
|
|
852
|
+
cancel: () => { },
|
|
853
|
+
abort: (code) => {
|
|
854
|
+
const errorMsg = encodeMessage({
|
|
855
|
+
version: 1,
|
|
856
|
+
type: isCon ? 'ACK' : 'NON',
|
|
857
|
+
token: decoded.token,
|
|
858
|
+
code,
|
|
859
|
+
messageId: decoded.messageId,
|
|
860
|
+
options: [],
|
|
861
|
+
payload: new Uint8Array(0),
|
|
862
|
+
});
|
|
863
|
+
store.set(key, { expiresAt: Date.now() + lifetime, responsePayload: errorMsg });
|
|
864
|
+
sock.send(errorMsg, rinfo.port, rinfo.address);
|
|
865
|
+
this.emit('response', ctx);
|
|
866
|
+
},
|
|
867
|
+
observeSeq: 0,
|
|
868
|
+
observe: (obs, opts) => {
|
|
869
|
+
if (!isObserveRegister)
|
|
870
|
+
return; // only valid on Observe-register requests
|
|
871
|
+
// Negotiate block size from the observe GET's Block2 option, defaulting to 1024
|
|
872
|
+
const block2Opt = decoded.options.find(o => o.number === OptionNumber.Block2);
|
|
873
|
+
const notifBlockSzx = block2Opt ? decodeBlockOption(block2Opt.value).szx : 6;
|
|
874
|
+
const notifBlockSize = 1 << (notifBlockSzx + 4);
|
|
875
|
+
// Path-based key for clients that use a different token on Block2 continuation GETs
|
|
876
|
+
const regPathParts = decoded.options
|
|
877
|
+
.filter(o => o.number === OptionNumber.UriPath)
|
|
878
|
+
.map(o => bytesToString(o.value));
|
|
879
|
+
const regUriPath = '/' + regPathParts.join('/');
|
|
880
|
+
const regPathKey = `${remote}:path:${regUriPath}`;
|
|
881
|
+
const entry = { loop: null, lastMid: null };
|
|
882
|
+
const loop = startNotificationLoop(obs, (value) => value instanceof Uint8Array ? value : new Uint8Array(0), (seq, payload) => {
|
|
883
|
+
const mid = this._nextNotifMid();
|
|
884
|
+
entry.lastMid = mid;
|
|
885
|
+
if (payload.length > notifBlockSize) {
|
|
886
|
+
// Replace any previous chunker for this registration with a fresh one
|
|
887
|
+
const chunker = new Block2Chunker(Buffer.from(payload), { blockSize: notifBlockSize });
|
|
888
|
+
this.activeNotifChunkers.set(regKey, chunker);
|
|
889
|
+
this.activeNotifChunkers.set(regPathKey, chunker);
|
|
890
|
+
chunker.once('block-transfer-complete', () => {
|
|
891
|
+
this.emit('block-transfer-complete', { remote, token: decoded.token });
|
|
892
|
+
});
|
|
893
|
+
chunker.once('block-transfer-aborted', (ev) => {
|
|
894
|
+
this.emit('block-transfer-aborted', { remote, token: decoded.token, reason: ev.reason });
|
|
895
|
+
});
|
|
896
|
+
// Send block 0 — carries the Observe sequence number
|
|
897
|
+
// Options must be in ascending numeric order per RFC 7252
|
|
898
|
+
void chunker.getBlock(0).then(({ payload: block0, m, etag }) => {
|
|
899
|
+
const notifMsg = encodeMessage({
|
|
900
|
+
version: 1,
|
|
901
|
+
type: 'CON',
|
|
902
|
+
token: decoded.token,
|
|
903
|
+
code: stringToCode('2.05'),
|
|
904
|
+
messageId: mid,
|
|
905
|
+
options: [
|
|
906
|
+
{ number: 4 /* ETag */, value: etag },
|
|
907
|
+
{ number: OptionNumber.Observe, value: uintToBytes(seq) },
|
|
908
|
+
{ number: OptionNumber.Block2, value: encodeBlockOption({ num: 0, szx: notifBlockSzx, m }) },
|
|
909
|
+
],
|
|
910
|
+
payload: block0,
|
|
911
|
+
});
|
|
912
|
+
sock.send(notifMsg, rinfo.port, rinfo.address);
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
const notifMsg = encodeMessage({
|
|
917
|
+
version: 1,
|
|
918
|
+
type: 'CON',
|
|
919
|
+
token: decoded.token,
|
|
920
|
+
code: stringToCode('2.05'),
|
|
921
|
+
messageId: mid,
|
|
922
|
+
options: [{ number: OptionNumber.Observe, value: uintToBytes(seq) }],
|
|
923
|
+
payload,
|
|
924
|
+
});
|
|
925
|
+
sock.send(notifMsg, rinfo.port, rinfo.address);
|
|
926
|
+
}
|
|
927
|
+
}, () => {
|
|
928
|
+
// Observable completed/errored — tear down registration
|
|
929
|
+
const existingReg = this.observeRegistrations.get(regKey);
|
|
930
|
+
if (existingReg) {
|
|
931
|
+
this.observeRegistrations.delete(regKey);
|
|
932
|
+
this.activeLoops.delete(regKey);
|
|
933
|
+
this.activeNotifChunkers.delete(regKey);
|
|
934
|
+
this.activeNotifChunkers.delete(regPathKey);
|
|
935
|
+
this.metrics.observeRegistrations.add(-1);
|
|
936
|
+
this.emit('observe-cancelled', existingReg);
|
|
937
|
+
}
|
|
938
|
+
}, opts);
|
|
939
|
+
entry.loop = loop;
|
|
940
|
+
this.activeLoops.set(regKey, entry);
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
const runHandler = () => {
|
|
944
|
+
ep.inflight++;
|
|
945
|
+
return Promise.resolve().then(async () => {
|
|
946
|
+
this.log.debug('dispatching request', { remote, messageId: decoded.messageId });
|
|
947
|
+
const listeners = this.rawListeners('request');
|
|
948
|
+
const results = listeners.map(fn => fn(req, ctx));
|
|
949
|
+
await Promise.allSettled(results.map(r => r instanceof Promise ? r : Promise.resolve(r)));
|
|
950
|
+
}).catch(err => {
|
|
951
|
+
this.log.error('exchange error', { remote, err: err.message });
|
|
952
|
+
this.emit('exchange-error', err instanceof Error ? err : new Error(String(err)));
|
|
953
|
+
}).finally(() => {
|
|
954
|
+
this.inflight--;
|
|
955
|
+
ep.inflight--;
|
|
956
|
+
this.metrics.activeExchanges.add(-1);
|
|
957
|
+
// NSTART: dispatch next queued request for this endpoint
|
|
958
|
+
const next = ep.queue.shift();
|
|
959
|
+
if (next) {
|
|
960
|
+
void next();
|
|
961
|
+
}
|
|
962
|
+
else if (ep.inflight === 0 && ep.queue.length === 0) {
|
|
963
|
+
this.endpointState.delete(remote);
|
|
964
|
+
}
|
|
965
|
+
this.emit('_drain');
|
|
966
|
+
});
|
|
967
|
+
};
|
|
968
|
+
// NSTART enforcement: if this endpoint is already running nstart handlers, queue
|
|
969
|
+
if (ep.inflight >= this.options.nstart) {
|
|
970
|
+
ep.queue.push(runHandler);
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
void runHandler();
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
_waitForDrain() {
|
|
977
|
+
return new Promise(res => {
|
|
978
|
+
const check = () => {
|
|
979
|
+
if (this.inflight === 0) {
|
|
980
|
+
this.removeListener('_drain', check);
|
|
981
|
+
res();
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
this.on('_drain', check);
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
_isLoopback(host) {
|
|
988
|
+
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
export function createServer(options) {
|
|
992
|
+
return new UdpServer(options);
|
|
993
|
+
}
|
|
994
|
+
//# sourceMappingURL=udp.js.map
|