@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.
Files changed (157) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +78 -0
  3. package/dist/api/exchange-context.d.ts +34 -0
  4. package/dist/api/exchange-context.d.ts.map +1 -0
  5. package/dist/api/exchange-context.js +2 -0
  6. package/dist/api/exchange-context.js.map +1 -0
  7. package/dist/api/headers.d.ts +10 -0
  8. package/dist/api/headers.d.ts.map +1 -0
  9. package/dist/api/headers.js +69 -0
  10. package/dist/api/headers.js.map +1 -0
  11. package/dist/api/index.d.ts +5 -0
  12. package/dist/api/index.d.ts.map +1 -0
  13. package/dist/api/index.js +4 -0
  14. package/dist/api/index.js.map +1 -0
  15. package/dist/api/request.d.ts +11 -0
  16. package/dist/api/request.d.ts.map +1 -0
  17. package/dist/api/request.js +49 -0
  18. package/dist/api/request.js.map +1 -0
  19. package/dist/api/response.d.ts +11 -0
  20. package/dist/api/response.d.ts.map +1 -0
  21. package/dist/api/response.js +19 -0
  22. package/dist/api/response.js.map +1 -0
  23. package/dist/blockwise/block1-assembler.d.ts +26 -0
  24. package/dist/blockwise/block1-assembler.d.ts.map +1 -0
  25. package/dist/blockwise/block1-assembler.js +94 -0
  26. package/dist/blockwise/block1-assembler.js.map +1 -0
  27. package/dist/blockwise/block2-chunker.d.ts +23 -0
  28. package/dist/blockwise/block2-chunker.d.ts.map +1 -0
  29. package/dist/blockwise/block2-chunker.js +99 -0
  30. package/dist/blockwise/block2-chunker.js.map +1 -0
  31. package/dist/blockwise/index.d.ts +8 -0
  32. package/dist/blockwise/index.d.ts.map +1 -0
  33. package/dist/blockwise/index.js +5 -0
  34. package/dist/blockwise/index.js.map +1 -0
  35. package/dist/blockwise/middleware.d.ts +7 -0
  36. package/dist/blockwise/middleware.d.ts.map +1 -0
  37. package/dist/blockwise/middleware.js +120 -0
  38. package/dist/blockwise/middleware.js.map +1 -0
  39. package/dist/blockwise/option.d.ts +12 -0
  40. package/dist/blockwise/option.d.ts.map +1 -0
  41. package/dist/blockwise/option.js +55 -0
  42. package/dist/blockwise/option.js.map +1 -0
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +6 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/message/index.d.ts +21 -0
  48. package/dist/message/index.d.ts.map +1 -0
  49. package/dist/message/index.js +79 -0
  50. package/dist/message/index.js.map +1 -0
  51. package/dist/observability.d.ts +44 -0
  52. package/dist/observability.d.ts.map +1 -0
  53. package/dist/observability.js +71 -0
  54. package/dist/observability.js.map +1 -0
  55. package/dist/observe-notification.d.ts +21 -0
  56. package/dist/observe-notification.d.ts.map +1 -0
  57. package/dist/observe-notification.js +87 -0
  58. package/dist/observe-notification.js.map +1 -0
  59. package/dist/observe.d.ts +10 -0
  60. package/dist/observe.d.ts.map +1 -0
  61. package/dist/observe.js +3 -0
  62. package/dist/observe.js.map +1 -0
  63. package/dist/option-parser/index.d.ts +30 -0
  64. package/dist/option-parser/index.d.ts.map +1 -0
  65. package/dist/option-parser/index.js +125 -0
  66. package/dist/option-parser/index.js.map +1 -0
  67. package/dist/router/app.d.ts +8 -0
  68. package/dist/router/app.d.ts.map +1 -0
  69. package/dist/router/app.js +25 -0
  70. package/dist/router/app.js.map +1 -0
  71. package/dist/router/content-format.d.ts +7 -0
  72. package/dist/router/content-format.d.ts.map +1 -0
  73. package/dist/router/content-format.js +23 -0
  74. package/dist/router/content-format.js.map +1 -0
  75. package/dist/router/index.d.ts +9 -0
  76. package/dist/router/index.d.ts.map +1 -0
  77. package/dist/router/index.js +5 -0
  78. package/dist/router/index.js.map +1 -0
  79. package/dist/router/radix.d.ts +16 -0
  80. package/dist/router/radix.d.ts.map +1 -0
  81. package/dist/router/radix.js +69 -0
  82. package/dist/router/radix.js.map +1 -0
  83. package/dist/router/router.d.ts +38 -0
  84. package/dist/router/router.d.ts.map +1 -0
  85. package/dist/router/router.js +111 -0
  86. package/dist/router/router.js.map +1 -0
  87. package/dist/router.d.ts +2 -0
  88. package/dist/router.d.ts.map +1 -0
  89. package/dist/router.js +2 -0
  90. package/dist/router.js.map +1 -0
  91. package/dist/security.d.ts +3 -0
  92. package/dist/security.d.ts.map +1 -0
  93. package/dist/security.js +2 -0
  94. package/dist/security.js.map +1 -0
  95. package/dist/store/index.d.ts +26 -0
  96. package/dist/store/index.d.ts.map +1 -0
  97. package/dist/store/index.js +68 -0
  98. package/dist/store/index.js.map +1 -0
  99. package/dist/store/sqlite-store.d.ts +10 -0
  100. package/dist/store/sqlite-store.d.ts.map +1 -0
  101. package/dist/store/sqlite-store.js +75 -0
  102. package/dist/store/sqlite-store.js.map +1 -0
  103. package/dist/store.d.ts +5 -0
  104. package/dist/store.d.ts.map +1 -0
  105. package/dist/store.js +3 -0
  106. package/dist/store.js.map +1 -0
  107. package/dist/transport/dtls.d.ts +38 -0
  108. package/dist/transport/dtls.d.ts.map +1 -0
  109. package/dist/transport/dtls.js +151 -0
  110. package/dist/transport/dtls.js.map +1 -0
  111. package/dist/transport/events.d.ts +34 -0
  112. package/dist/transport/events.d.ts.map +1 -0
  113. package/dist/transport/events.js +2 -0
  114. package/dist/transport/events.js.map +1 -0
  115. package/dist/transport/index.d.ts +10 -0
  116. package/dist/transport/index.d.ts.map +1 -0
  117. package/dist/transport/index.js +5 -0
  118. package/dist/transport/index.js.map +1 -0
  119. package/dist/transport/tcp-connection.d.ts +18 -0
  120. package/dist/transport/tcp-connection.d.ts.map +1 -0
  121. package/dist/transport/tcp-connection.js +72 -0
  122. package/dist/transport/tcp-connection.js.map +1 -0
  123. package/dist/transport/tcp-frame.d.ts +17 -0
  124. package/dist/transport/tcp-frame.d.ts.map +1 -0
  125. package/dist/transport/tcp-frame.js +112 -0
  126. package/dist/transport/tcp-frame.js.map +1 -0
  127. package/dist/transport/tcp-stream.d.ts +21 -0
  128. package/dist/transport/tcp-stream.d.ts.map +1 -0
  129. package/dist/transport/tcp-stream.js +52 -0
  130. package/dist/transport/tcp-stream.js.map +1 -0
  131. package/dist/transport/udp.d.ts +97 -0
  132. package/dist/transport/udp.d.ts.map +1 -0
  133. package/dist/transport/udp.js +994 -0
  134. package/dist/transport/udp.js.map +1 -0
  135. package/dist/transport.d.ts +3 -0
  136. package/dist/transport.d.ts.map +1 -0
  137. package/dist/transport.js +2 -0
  138. package/dist/transport.js.map +1 -0
  139. package/docs/api-reference.md +536 -0
  140. package/docs/blockwise.md +117 -0
  141. package/docs/observability.md +206 -0
  142. package/docs/observe.md +203 -0
  143. package/docs/releasing.md +106 -0
  144. package/docs/router.md +170 -0
  145. package/docs/store.md +212 -0
  146. package/docs/tcp-transport.md +89 -0
  147. package/docs/udp-transport.md +81 -0
  148. package/examples/slice-1.ts +12 -0
  149. package/examples/slice-2.ts +96 -0
  150. package/examples/slice-3-psk.ts +70 -0
  151. package/examples/slice-3-x509.ts +85 -0
  152. package/examples/slice-4.ts +45 -0
  153. package/examples/slice-5-firmware.ts +207 -0
  154. package/examples/slice-6-temperature.ts +163 -0
  155. package/examples/slice-7-file-store.ts +92 -0
  156. package/examples/slice-7-otel.ts +123 -0
  157. 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