@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,117 @@
|
|
|
1
|
+
# Block-wise transfers — RFC 7959
|
|
2
|
+
|
|
3
|
+
Block-wise transfers let CoAP exchange payloads larger than a single UDP datagram. The `blockwiseMiddleware` handles all fragmentation and reassembly transparently: route handlers see a normal `req.body` stream and call `ctx.respond()` once, regardless of whether the payload is 10 bytes or 10 megabytes.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
handler registration: router.use(blockwiseMiddleware());
|
|
9
|
+
|
|
10
|
+
PUT /firmware handler: receives the fully-assembled body, responds 2.04
|
|
11
|
+
GET /manifest handler: returns a Buffer, chunked automatically by Block2
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The middleware intercepts CoAP `Block1` (upload) and `Block2` (download) option headers and manages the multi-round-trip exchange before the application handler is ever called.
|
|
15
|
+
|
|
16
|
+
## Block size
|
|
17
|
+
|
|
18
|
+
| Parameter | Value |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Default block size | 512 bytes (SZX=5) |
|
|
21
|
+
| Hard cap | 1 024 bytes (SZX=6) |
|
|
22
|
+
| Client-negotiated | Honored if ≤ hard cap; larger client SZX silently capped to 1 024 |
|
|
23
|
+
|
|
24
|
+
The block size is selected by the client. If the client requests SZX=6 (1 024 bytes), the server uses 1 024-byte blocks. If the client requests SZX=3 (128 bytes), the server uses 128-byte blocks.
|
|
25
|
+
|
|
26
|
+
## `ctx.continue()`
|
|
27
|
+
|
|
28
|
+
For Block1 uploads the middleware calls `ctx.continue()` automatically for each non-final chunk. You do **not** need to call it yourself. Understanding what it does is useful for debugging:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
ctx.continue() ──► sends CoAP 2.31 Continue (ACK with code 2.31)
|
|
32
|
+
echoing the Block1 option back to the client
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If you need to implement your own block-wise logic (e.g., partial validation mid-stream), you can call `ctx.continue()` yourself from a middleware positioned before `blockwiseMiddleware`:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// Example: log progress without blocking the transfer
|
|
39
|
+
router.use(async (req, ctx, next) => {
|
|
40
|
+
const block1 = ctx.options.find(o => o.number === 27);
|
|
41
|
+
if (block1) {
|
|
42
|
+
console.log('block received, num=…');
|
|
43
|
+
}
|
|
44
|
+
return next();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
router.use(blockwiseMiddleware());
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Cancellation
|
|
51
|
+
|
|
52
|
+
A block transfer can be abandoned in three ways:
|
|
53
|
+
|
|
54
|
+
| Mechanism | Who triggers it | Effect |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| Idle timeout | Server (automatic) | Aborts assembler/chunker after 60 s of silence; emits `'block-transfer-aborted'` |
|
|
57
|
+
| `ctx.cancel()` | Route handler | Sends CoAP RST to the peer and frees state |
|
|
58
|
+
| Client RST | Client | Transport closes the exchange; middleware cleans up on next request |
|
|
59
|
+
|
|
60
|
+
The idle timeout default (60 s) can be changed:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
router.use(blockwiseMiddleware({ idleTimeoutMs: 10_000 })); // 10 s
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Size cap
|
|
67
|
+
|
|
68
|
+
The default maximum assembled body size is **16 MiB**. Exceeding it causes the middleware to respond `4.13 Request Entity Too Large` and abort the transfer.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
router.use(blockwiseMiddleware({ maxBytes: 4 * 1024 * 1024 })); // 4 MiB cap
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Events
|
|
75
|
+
|
|
76
|
+
Both `Block1Assembler` and `Block2Chunker` extend `EventEmitter` and emit:
|
|
77
|
+
|
|
78
|
+
| Event | Payload | When |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `'block-transfer-complete'` | — | Final block processed successfully |
|
|
81
|
+
| `'block-transfer-aborted'` | `{ reason: string }` | Transfer abandoned (timeout, too-large, out-of-order, explicit) |
|
|
82
|
+
|
|
83
|
+
The middleware removes assemblers/chunkers from its internal maps on both events, so memory is released promptly.
|
|
84
|
+
|
|
85
|
+
## Example
|
|
86
|
+
|
|
87
|
+
See [`examples/slice-5-firmware.ts`](../examples/slice-5-firmware.ts) for a full working demo:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { createServer } from '@sirena-lwm2m/coap';
|
|
91
|
+
import { createApp } from '@sirena-lwm2m/coap/router';
|
|
92
|
+
import { blockwiseMiddleware } from '@sirena-lwm2m/coap/blockwise';
|
|
93
|
+
|
|
94
|
+
const server = createServer({ listen: [{ host: '127.0.0.1', port: 5685 }] });
|
|
95
|
+
const router = createApp({ server });
|
|
96
|
+
|
|
97
|
+
router.use(blockwiseMiddleware());
|
|
98
|
+
|
|
99
|
+
router.add({
|
|
100
|
+
path: '/firmware',
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
handler: async (req, ctx) => {
|
|
103
|
+
// req.body is a fully-assembled ReadableStream regardless of payload size
|
|
104
|
+
let bytes = 0;
|
|
105
|
+
const reader = req.body!.getReader();
|
|
106
|
+
for (let r = await reader.read(); !r.done; r = await reader.read()) {
|
|
107
|
+
bytes += r.value.length;
|
|
108
|
+
}
|
|
109
|
+
console.log(`firmware bytes received: ${bytes}`);
|
|
110
|
+
await ctx.respond(new Response(stringToCode('2.04')));
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await server.start();
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The example also includes a self-test client that uploads an 8 MiB firmware image via Block1 and retrieves a manifest via Block2, asserting both transfers succeed.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Observability — @sirena-lwm2m/coap
|
|
2
|
+
|
|
3
|
+
The library emits OpenTelemetry metrics with zero cost when the `@opentelemetry/api` package is absent. No OTel SDK dependency is bundled; metrics are wired up lazily at runtime.
|
|
4
|
+
|
|
5
|
+
## Import path
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { bindOtel, tryBindOtel, createNoopMetrics } from '@sirena-lwm2m/coap/observability';
|
|
9
|
+
import type { CoapMetrics, Counter, UpDownCounter, Histogram } from '@sirena-lwm2m/coap/observability';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Available instruments
|
|
15
|
+
|
|
16
|
+
All instruments use the `coap.*` namespace per the OpenTelemetry semantic conventions draft for CoAP.
|
|
17
|
+
|
|
18
|
+
| Instrument | Type | Unit | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `coap.requests` | Counter | `{request}` | Incremented on every incoming request (CON or NON). |
|
|
21
|
+
| `coap.exchanges.active` | UpDownCounter | `{exchange}` | Number of currently in-flight exchanges (running + queued handlers). |
|
|
22
|
+
| `coap.observe.registrations` | UpDownCounter | `{registration}` | Number of active Observe registrations. |
|
|
23
|
+
| `coap.block.complete` | Counter | `{transfer}` | Number of block-wise transfers that completed successfully. |
|
|
24
|
+
| `coap.block.aborted` | Counter | `{transfer}` | Number of block-wise transfers that were aborted (timeout, size cap, out-of-order). |
|
|
25
|
+
| `coap.dedup.hits` | Counter | `{hit}` | Number of CON retransmissions served from the dedup store. |
|
|
26
|
+
| `coap.transport.errors` | Counter | `{error}` | Number of transport-level errors (decode failures, socket errors). |
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## `CoapMetrics`
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
interface CoapMetrics {
|
|
34
|
+
requestRate: Counter;
|
|
35
|
+
activeExchanges: UpDownCounter;
|
|
36
|
+
observeRegistrations: UpDownCounter;
|
|
37
|
+
blockTransferComplete: Counter;
|
|
38
|
+
blockTransferAborted: Counter;
|
|
39
|
+
dedupHits: Counter;
|
|
40
|
+
transportErrors: Counter;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pass a `CoapMetrics` instance to `createServer()` via `ServerOptions.metrics`:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const server = createServer({
|
|
48
|
+
listen: [{ host: '0.0.0.0', port: 5683 }],
|
|
49
|
+
metrics: myMetrics,
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
When `metrics` is omitted the server uses no-op implementations automatically — no null checks needed anywhere in your code.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Instrument types
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
interface Counter {
|
|
61
|
+
add(value: number, attributes?: Record<string, string | number | boolean>): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface UpDownCounter {
|
|
65
|
+
add(value: number, attributes?: Record<string, string | number | boolean>): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Histogram {
|
|
69
|
+
record(value: number, attributes?: Record<string, string | number | boolean>): void;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
These are intentionally a minimal subset of `@opentelemetry/api` — any OTel-compliant SDK will satisfy them. The library never imports from `@opentelemetry/api` at module load time.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Setup options
|
|
78
|
+
|
|
79
|
+
### Option A — caller supplies the Meter (`bindOtel`)
|
|
80
|
+
|
|
81
|
+
Use this when your application already bootstraps an OTel SDK and has a `Meter` instance:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { bindOtel } from '@sirena-lwm2m/coap/observability';
|
|
85
|
+
|
|
86
|
+
// Obtain a Meter from your OTel SDK setup
|
|
87
|
+
const meter = otelSdk.getMeter('my-coap-service');
|
|
88
|
+
|
|
89
|
+
const metrics = bindOtel(meter);
|
|
90
|
+
|
|
91
|
+
const server = createServer({
|
|
92
|
+
listen: [{ host: '0.0.0.0', port: 5683 }],
|
|
93
|
+
metrics,
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`bindOtel` is synchronous. It calls the standard OTel Meter factory methods and returns a `CoapMetrics` object wired to the provided `Meter`.
|
|
98
|
+
|
|
99
|
+
### Option B — auto-detect OTel (`tryBindOtel`)
|
|
100
|
+
|
|
101
|
+
Use this when you want metrics if the SDK is present, but zero-cost no-ops otherwise:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { tryBindOtel } from '@sirena-lwm2m/coap/observability';
|
|
105
|
+
|
|
106
|
+
const metrics = await tryBindOtel('my-coap-service');
|
|
107
|
+
|
|
108
|
+
const server = createServer({
|
|
109
|
+
listen: [{ host: '0.0.0.0', port: 5683 }],
|
|
110
|
+
metrics,
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`tryBindOtel` dynamically imports `@opentelemetry/api`. If the package is not installed it silently returns no-op metrics. The `meterName` parameter is passed to `api.metrics.getMeter()`.
|
|
115
|
+
|
|
116
|
+
### Option C — explicit no-ops (`createNoopMetrics`)
|
|
117
|
+
|
|
118
|
+
Use this in tests or when you want to be explicit that metrics are disabled:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { createNoopMetrics } from '@sirena-lwm2m/coap/observability';
|
|
122
|
+
|
|
123
|
+
const server = createServer({
|
|
124
|
+
listen: [{ host: '0.0.0.0', port: 5683 }],
|
|
125
|
+
metrics: createNoopMetrics(),
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Full example with `@opentelemetry/sdk-node`
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
135
|
+
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
|
|
136
|
+
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
|
137
|
+
import { createServer, Response, stringToBytes } from '@sirena-lwm2m/coap';
|
|
138
|
+
import { bindOtel } from '@sirena-lwm2m/coap/observability';
|
|
139
|
+
import { metrics } from '@opentelemetry/api';
|
|
140
|
+
|
|
141
|
+
// 1. Bootstrap OTel SDK
|
|
142
|
+
const sdk = new NodeSDK({
|
|
143
|
+
metricReader: new PeriodicExportingMetricReader({
|
|
144
|
+
exporter: new OTLPMetricExporter({
|
|
145
|
+
url: 'http://localhost:4318/v1/metrics',
|
|
146
|
+
}),
|
|
147
|
+
exportIntervalMillis: 10_000,
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
sdk.start();
|
|
151
|
+
|
|
152
|
+
// 2. Bind metrics to the CoAP server
|
|
153
|
+
const meter = metrics.getMeter('my-coap-service', '0.7.0');
|
|
154
|
+
const coapMetrics = bindOtel(meter);
|
|
155
|
+
|
|
156
|
+
const server = createServer({
|
|
157
|
+
listen: [{ host: '0.0.0.0', port: 5683 }],
|
|
158
|
+
metrics: coapMetrics,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
server.on('request', async (req, ctx) => {
|
|
162
|
+
await ctx.respond(Response.ok(stringToBytes('hello')));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await server.start();
|
|
166
|
+
|
|
167
|
+
// 3. Graceful shutdown
|
|
168
|
+
process.on('SIGTERM', async () => {
|
|
169
|
+
await server.stop();
|
|
170
|
+
await sdk.shutdown();
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Required packages
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm install @opentelemetry/sdk-node \
|
|
178
|
+
@opentelemetry/sdk-metrics \
|
|
179
|
+
@opentelemetry/exporter-metrics-otlp-http \
|
|
180
|
+
@opentelemetry/api
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`@opentelemetry/api` is declared as an optional peer dependency in `@sirena-lwm2m/coap`. Install it explicitly alongside the SDK.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Peer dependency
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
"peerDependencies": {
|
|
191
|
+
"@opentelemetry/api": "*"
|
|
192
|
+
},
|
|
193
|
+
"peerDependenciesMeta": {
|
|
194
|
+
"@opentelemetry/api": {
|
|
195
|
+
"optional": true
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The library is tested against all current major versions of `@opentelemetry/api` (1.x). No other OTel package is required at runtime.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Zero-cost guarantee
|
|
205
|
+
|
|
206
|
+
When neither `bindOtel` nor `tryBindOtel` is called, the server uses `createNoopMetrics()` internally. No-op methods are empty arrow functions — they compile to a single unconditional branch prediction miss at most. There is no dynamic dispatch, no weak-ref check, and no import of `@opentelemetry/api` at module load time.
|
package/docs/observe.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Observe — RFC 7641
|
|
2
|
+
|
|
3
|
+
The Observe extension lets a CoAP server push notifications to a registered client whenever a resource changes, replacing polling with a reactive push model. The library implements the full RFC 7641 lifecycle: registration, notification delivery with CON/ACK flow control, conflation, block-wise chunking for large payloads, and RST-triggered or explicit cancellation.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
client server
|
|
9
|
+
| |
|
|
10
|
+
|--- GET /temperature + Observe:0 ->| register
|
|
11
|
+
|<-- 2.05 Content + Observe:0 ------| initial response (seq 0)
|
|
12
|
+
| |
|
|
13
|
+
|<-- CON 2.05 + Observe:1 ---------| notification seq 1
|
|
14
|
+
|--- ACK ----------------------->| |
|
|
15
|
+
|<-- CON 2.05 + Observe:2 ---------| notification seq 2
|
|
16
|
+
|--- ACK ----------------------->| |
|
|
17
|
+
| |
|
|
18
|
+
|--- GET /temperature + Observe:1 ->| deregister
|
|
19
|
+
|<-- 2.05 Content ------------------| final response (no Observe option)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## `ctx.observe()`
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
ctx.observe(observable: Observable<unknown>, opts?: ObserveOptions): void
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Called from a route handler (or the `server.on('request', …)` listener) to attach an `Observable` to the current exchange. The server will subscribe to it and deliver each emitted value as a CON notification to the registered client.
|
|
29
|
+
|
|
30
|
+
- Only takes effect when the incoming request carries `Observe: 0` (a registration request). Calling it on a plain GET is a no-op.
|
|
31
|
+
- Must be called after `ctx.respond()` returns so the initial 2.05 response is sent first.
|
|
32
|
+
- Ownership of the subscription is held by the server; the application does not manage the lifecycle.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
router.add({
|
|
36
|
+
path: '/temperature',
|
|
37
|
+
method: 'GET',
|
|
38
|
+
handler: async (_req, ctx) => {
|
|
39
|
+
await ctx.respond(Response.ok(encodeTemperature(currentTemp)));
|
|
40
|
+
ctx.observe(temperatureObservable);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### ObserveOptions
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
interface ObserveOptions {
|
|
49
|
+
notifyEveryValue?: boolean;
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Option | Default | Description |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `notifyEveryValue` | `false` | When `false`, conflation is enabled (see below). When `true`, every emitted value is delivered as a separate notification, even if a previous CON is still awaiting ACK. |
|
|
56
|
+
|
|
57
|
+
## Types
|
|
58
|
+
|
|
59
|
+
### `Observable<T>`
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
interface Observable<T> {
|
|
63
|
+
subscribe(observer: Observer<T>): Unsubscribe;
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Any object with a `subscribe` method that matches this shape can be passed to `ctx.observe()`. This is intentionally compatible with RxJS `Observable` and the TC39 Observable proposal, but the library has no runtime dependency on either.
|
|
68
|
+
|
|
69
|
+
### `Observer<T>`
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
interface Observer<T> {
|
|
73
|
+
next(value: T): void;
|
|
74
|
+
error?(err: unknown): void;
|
|
75
|
+
complete?(): void;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The server creates an observer internally and subscribes to the provided observable. Calling `complete()` or `error()` on the subject tears down the registration cleanly.
|
|
80
|
+
|
|
81
|
+
### `Unsubscribe`
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
type Unsubscribe = () => void;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The return value of `Observable.subscribe()`. The server calls this when the registration is cancelled to release resources.
|
|
88
|
+
|
|
89
|
+
## Conflation
|
|
90
|
+
|
|
91
|
+
When `notifyEveryValue` is `false` (the default), the notification loop uses conflation to avoid unbounded queuing under fast sources:
|
|
92
|
+
|
|
93
|
+
- Each notification is sent as a CON message. The loop waits for an ACK before sending the next one.
|
|
94
|
+
- If the observable emits while the previous CON is still awaiting ACK, the new value is held in a single-slot queue, replacing any earlier queued value.
|
|
95
|
+
- When the ACK arrives, the queued value (if any) is sent immediately.
|
|
96
|
+
|
|
97
|
+
This means a slow client never receives more packets than it ACKs, and a fast source never causes unbounded memory growth — only the latest value is retained.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
observable: ─── v1 ─── v2 ─── v3 ─── v4 ───
|
|
101
|
+
(v1 in-flight waiting for ACK)
|
|
102
|
+
delivered: ─── v1 ─────────────── v4 ─── (v2, v3 conflated away)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Set `notifyEveryValue: true` to disable conflation and deliver every value regardless of ACK state. Use this only when every intermediate value is meaningful (e.g., event logs, counters).
|
|
106
|
+
|
|
107
|
+
## Sequence numbers
|
|
108
|
+
|
|
109
|
+
Each notification carries a 24-bit Observe sequence number (RFC 7641 §4.4):
|
|
110
|
+
|
|
111
|
+
- The initial 2.05 registration response carries sequence number `0`.
|
|
112
|
+
- Subsequent notifications carry `1`, `2`, …, wrapping at `0xFFFFFF` back to `0`.
|
|
113
|
+
- Clients use the sequence number to detect out-of-order delivery and discard stale notifications.
|
|
114
|
+
|
|
115
|
+
The library manages sequence numbers automatically; applications do not need to handle them.
|
|
116
|
+
|
|
117
|
+
## Cancellation triggers
|
|
118
|
+
|
|
119
|
+
A registration is cancelled when any of the following occurs:
|
|
120
|
+
|
|
121
|
+
| Trigger | Description |
|
|
122
|
+
|---|---|
|
|
123
|
+
| Client sends `GET + Observe:1` | Explicit deregistration per RFC 7641 §3.6 |
|
|
124
|
+
| Client sends a plain `GET` (no Observe option) | Implicit deregistration per RFC 7641 §3.6 |
|
|
125
|
+
| Client sends RST to a CON notification | RST-triggered cancellation per RFC 7641 §4.2 |
|
|
126
|
+
| `Observable` calls `complete()` or `error()` | Application-driven teardown |
|
|
127
|
+
|
|
128
|
+
On cancellation the server unsubscribes from the observable and emits `'observe-cancelled'` on the server event emitter. Memory is released immediately.
|
|
129
|
+
|
|
130
|
+
## Server events
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
server.on('observe-registered', (reg: ObserveRegistration) => { … });
|
|
134
|
+
server.on('observe-cancelled', (reg: ObserveRegistration) => { … });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
interface ObserveRegistration {
|
|
139
|
+
remote: string; // peer address, e.g. "127.0.0.1:12345"
|
|
140
|
+
token: Uint8Array; // CoAP token identifying the registration
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Block-wise notifications
|
|
145
|
+
|
|
146
|
+
When a notification payload exceeds the negotiated block size (default 1 024 bytes), the server chunks it automatically using `Block2`. The client receives the full payload across multiple CON/ACK round-trips.
|
|
147
|
+
|
|
148
|
+
The block size is negotiated from the `Block2` option the client sends with its registration GET:
|
|
149
|
+
- If the client includes `Block2: NUM=0, SZX=N`, the server uses block size `2^(N+4)` (capped at 1 024 bytes).
|
|
150
|
+
- If the client sends no `Block2` option, the server defaults to 1 024-byte blocks.
|
|
151
|
+
|
|
152
|
+
Each block in a block-wise notification carries the same Observe sequence number, so the client can correlate blocks to the same notification event.
|
|
153
|
+
|
|
154
|
+
Applications do not need to do anything special to enable block-wise notifications — passing a large payload via `Observable.next()` is sufficient.
|
|
155
|
+
|
|
156
|
+
## Example
|
|
157
|
+
|
|
158
|
+
See [`examples/slice-6-temperature.ts`](../examples/slice-6-temperature.ts) for a complete working demo.
|
|
159
|
+
|
|
160
|
+
The example runs a server that publishes `/temperature` as an observable resource, with a fake sensor emitting a new reading every 500 ms. The in-process client registers, logs 5 notifications, then deregisters cleanly via `GET + Observe:1`.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { createServer, Response } from '@sirena-lwm2m/coap';
|
|
164
|
+
import { createApp } from '@sirena-lwm2m/coap/router';
|
|
165
|
+
import type { Observable, Observer, Unsubscribe } from '@sirena-lwm2m/coap';
|
|
166
|
+
|
|
167
|
+
// Minimal observable subject — works with any push source
|
|
168
|
+
function makeSubject<T>() {
|
|
169
|
+
const subscribers = new Set<Observer<T>>();
|
|
170
|
+
return {
|
|
171
|
+
observable: {
|
|
172
|
+
subscribe(o: Observer<T>): Unsubscribe {
|
|
173
|
+
subscribers.add(o);
|
|
174
|
+
return () => { subscribers.delete(o); };
|
|
175
|
+
},
|
|
176
|
+
} satisfies Observable<T>,
|
|
177
|
+
next(v: T) { for (const obs of subscribers) obs.next(v); },
|
|
178
|
+
complete() { for (const obs of subscribers) obs.complete?.(); subscribers.clear(); },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const server = createServer({ listen: [{ host: '127.0.0.1', port: 5686 }] });
|
|
183
|
+
const router = createApp({ server });
|
|
184
|
+
const tempSubject = makeSubject<Uint8Array>();
|
|
185
|
+
|
|
186
|
+
router.add({
|
|
187
|
+
path: '/temperature',
|
|
188
|
+
method: 'GET',
|
|
189
|
+
handler: async (_req, ctx) => {
|
|
190
|
+
await ctx.respond(Response.ok(currentReading()));
|
|
191
|
+
ctx.observe(tempSubject.observable);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await server.start();
|
|
196
|
+
|
|
197
|
+
// Push a new reading whenever the sensor fires
|
|
198
|
+
sensorEmitter.on('reading', (celsius: number) => {
|
|
199
|
+
const buf = Buffer.alloc(4);
|
|
200
|
+
buf.writeFloatBE(celsius, 0);
|
|
201
|
+
tempSubject.next(buf);
|
|
202
|
+
});
|
|
203
|
+
```
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Releasing `@sirena-lwm2m/coap`
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Node.js ≥ 24
|
|
6
|
+
- npm account with write access to the `@sirena-lwm2m` org
|
|
7
|
+
- Authenticated npm session: `npm login` (prompts for username, password, OTP)
|
|
8
|
+
|
|
9
|
+
## Pre-publish checklist
|
|
10
|
+
|
|
11
|
+
### 1. Confirm `package.json` is correct
|
|
12
|
+
|
|
13
|
+
The `exports` map must point to built artefacts:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts"
|
|
20
|
+
},
|
|
21
|
+
...
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The `"files"` array must be explicit so that `src/`, `test/`, `tsconfig.json`, and any stale `.tgz` files are never shipped:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"docs",
|
|
31
|
+
"examples",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> **Note:** without a `"files"` array, npm falls back to `.gitignore` for exclusion. This is fragile — always keep `"files"` explicit.
|
|
37
|
+
|
|
38
|
+
### 2. Dry-run pack inspection
|
|
39
|
+
|
|
40
|
+
Run from the package root (`packages/coap/`):
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm pack --dry-run
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Verify the tarball contents:
|
|
47
|
+
|
|
48
|
+
| Must be present | Must NOT be present |
|
|
49
|
+
|-----------------|---------------------|
|
|
50
|
+
| `dist/index.js` | `src/` |
|
|
51
|
+
| `dist/index.d.ts` | `test/` |
|
|
52
|
+
| `docs/` | `node_modules/` |
|
|
53
|
+
| `examples/` | `tsconfig.json` |
|
|
54
|
+
| `LICENSE` | `*.tgz` |
|
|
55
|
+
|
|
56
|
+
### 3. Final build and tests
|
|
57
|
+
|
|
58
|
+
From the package root:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npm run build
|
|
62
|
+
npm test
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Both must exit 0 on a clean checkout (`git clone` + `npm install`).
|
|
66
|
+
|
|
67
|
+
## Tagging and publishing
|
|
68
|
+
|
|
69
|
+
### Tag the release
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
git tag v0.1.0
|
|
73
|
+
git push origin v0.1.0
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use annotated tags for production releases:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
git tag -a v0.1.0 -m "chore: release 0.1.0"
|
|
80
|
+
git push origin v0.1.0
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Publish to npm
|
|
84
|
+
|
|
85
|
+
This step is **manual and human-gated**. Run it only after reviewing the dry-run output above.
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
cd packages/coap
|
|
89
|
+
npm publish --access public
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Confirm the package is live:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
https://www.npmjs.com/package/@sirena-lwm2m/coap
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Version bumping for future releases
|
|
99
|
+
|
|
100
|
+
1. Update `"version"` in `packages/coap/package.json` following semver
|
|
101
|
+
2. Repeat the checklist above
|
|
102
|
+
3. Tag as `vX.Y.Z` and publish
|
|
103
|
+
|
|
104
|
+
## No automated CI publish
|
|
105
|
+
|
|
106
|
+
There is intentionally no automated publish step in CI. All releases go through this manual process to keep a human in the loop before anything lands on the public registry.
|