@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
package/docs/router.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# `@sirena-lwm2m/coap/router` — Router API
|
|
2
|
+
|
|
3
|
+
## `createApp(opts)`
|
|
4
|
+
|
|
5
|
+
Creates a `Router` wired to a `Server` instance and returns it.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createApp } from '@sirena-lwm2m/coap/router';
|
|
9
|
+
|
|
10
|
+
const router = createApp({ server, discovery: true });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Options (`AppOptions`):**
|
|
14
|
+
|
|
15
|
+
| Field | Type | Default | Description |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| `server` | `Server` | — | The CoAP server whose `request` events are dispatched through the router. |
|
|
18
|
+
| `discovery` | `boolean` | `false` | When `true`, automatically handles `GET /.well-known/core` and returns all registered paths in CoRE Link Format (RFC 6690). |
|
|
19
|
+
|
|
20
|
+
**Return value:** a `Router` instance.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## `router.use(middleware)`
|
|
25
|
+
|
|
26
|
+
Appends a middleware function to the chain. Middlewares run in registration order before the matched route handler.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
router.use((req, ctx, next) => {
|
|
30
|
+
if (!authorized(ctx)) {
|
|
31
|
+
ctx.abort(stringToCode('4.01'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
return next();
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Signature:**
|
|
39
|
+
```ts
|
|
40
|
+
use(middleware: Middleware): void
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
where `Middleware` is:
|
|
44
|
+
```ts
|
|
45
|
+
(req: Request, ctx: RouteContext, next: NextFn) => void | Promise<void>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Execution order:** middlewares are called in the order `use()` was called, followed by the route handler. Each middleware must either call `next()` to continue, call `ctx.respond()` / `ctx.abort()` to short-circuit, or return without calling `next()` to silently drop the exchange.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## `router.add(route)` / `router.remove(route)`
|
|
53
|
+
|
|
54
|
+
Hot-reload: add or remove a route without restarting the server. The routing table is rebuilt atomically; in-flight exchanges on a removed route complete normally.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const route = { path: '/3/{iid}', method: 'GET' as const, handler };
|
|
58
|
+
|
|
59
|
+
router.add(route);
|
|
60
|
+
// later…
|
|
61
|
+
router.remove(route);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**`Route` interface:**
|
|
65
|
+
```ts
|
|
66
|
+
interface Route {
|
|
67
|
+
path: string; // radix-tree path, e.g. '/3/{iid}/{rid}'
|
|
68
|
+
method: CoapMethod; // 'GET' | 'POST' | 'PUT' | 'DELETE' | …
|
|
69
|
+
handler: Handler; // (req, ctx) => void | Promise<void>
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Path parameters are enclosed in `{}` and exposed on `ctx.params`:
|
|
74
|
+
```ts
|
|
75
|
+
router.add({
|
|
76
|
+
path: '/rd/{ep}',
|
|
77
|
+
method: 'GET',
|
|
78
|
+
handler: (_req, ctx) => {
|
|
79
|
+
console.log(ctx.params.ep); // e.g. "my-device"
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## `contentFormat(registry)`
|
|
87
|
+
|
|
88
|
+
Middleware factory that negotiates `Content-Format` and `Accept` CoAP options against a registry. Calls `ctx.abort(4.06 Not Acceptable)` when negotiation fails.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { contentFormat } from '@sirena-lwm2m/coap/router';
|
|
92
|
+
import type { ContentFormatRegistry } from '@sirena-lwm2m/coap/router';
|
|
93
|
+
|
|
94
|
+
const registry: ContentFormatRegistry = {
|
|
95
|
+
accepts: (cf) => cf === 110, // application/senml+json
|
|
96
|
+
negotiate: (accepts) => accepts.includes(110) ? 110 : null,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
router.use(contentFormat(registry));
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**`ContentFormatRegistry` interface:**
|
|
103
|
+
```ts
|
|
104
|
+
interface ContentFormatRegistry {
|
|
105
|
+
accepts(contentFormat: number): boolean;
|
|
106
|
+
negotiate(accept: number[]): number | null;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `contentFormat` middleware is intentionally free of LwM2M-specific logic; the registry is provided by the caller, keeping codec knowledge outside this package.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## `RouterOptions.discovery`
|
|
115
|
+
|
|
116
|
+
When `discovery: true` is passed to `createApp`, the router registers a built-in handler for `GET /.well-known/core`. The response body is a CoRE Link Format string assembled from all currently registered paths:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
</3/{iid}>;rt="core.rd-lookup-res",</3/{iid}/{rid}>;rt="core.rd-lookup-res"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Content-Format `40` (application/link-format) is set automatically.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## End-to-end example
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { createServer, stringToCode, Response, stringToBytes } from '@sirena-lwm2m/coap';
|
|
130
|
+
import { createApp } from '@sirena-lwm2m/coap/router';
|
|
131
|
+
import type { Middleware } from '@sirena-lwm2m/coap/router';
|
|
132
|
+
|
|
133
|
+
const OPTION_AUTHORIZATION = 65000;
|
|
134
|
+
|
|
135
|
+
const server = createServer({
|
|
136
|
+
listen: [{ host: '127.0.0.1', port: 5683 }],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const router = createApp({ server, discovery: true });
|
|
140
|
+
|
|
141
|
+
const authMiddleware: Middleware = (req, ctx, next) => {
|
|
142
|
+
const authOpt = ctx.options.find(o => o.number === OPTION_AUTHORIZATION);
|
|
143
|
+
if (authOpt === undefined) {
|
|
144
|
+
ctx.abort(stringToCode('4.01'));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
return next();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
router.use(authMiddleware);
|
|
151
|
+
|
|
152
|
+
router.add({
|
|
153
|
+
path: '/3/{iid}',
|
|
154
|
+
method: 'GET',
|
|
155
|
+
handler: (_req, ctx) => {
|
|
156
|
+
ctx.respond(new Response(stringToCode('2.05'), stringToBytes('device-object')));
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
router.add({
|
|
161
|
+
path: '/3/{iid}/{rid}',
|
|
162
|
+
method: 'PUT',
|
|
163
|
+
handler: (_req, ctx) => {
|
|
164
|
+
ctx.respond(new Response(stringToCode('2.04')));
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await server.start();
|
|
169
|
+
console.log('Listening on:', server.listenerSummary());
|
|
170
|
+
```
|
package/docs/store.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Transaction Store — @sirena-lwm2m/coap
|
|
2
|
+
|
|
3
|
+
The transaction store is the deduplication layer for CoAP exchanges. RFC 7252 requires servers to detect and replay responses for retransmitted CON messages. The store also suppresses duplicate NON messages within their lifetime.
|
|
4
|
+
|
|
5
|
+
## Import path
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createInMemoryStore, createSqliteStore } from '@sirena-lwm2m/coap/store';
|
|
9
|
+
import type { TransactionStore, StoreKey, StoreEntry, SqliteStore, SqliteStoreOptions } from '@sirena-lwm2m/coap/store';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The `createServer()` function accepts any `TransactionStore` implementation via `ServerOptions.store`. When no store is provided, an `InMemoryStore` with default settings is created automatically.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## `TransactionStore` interface
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
interface TransactionStore {
|
|
20
|
+
set(key: StoreKey, entry: StoreEntry): void;
|
|
21
|
+
get(key: StoreKey): StoreEntry | undefined;
|
|
22
|
+
delete(key: StoreKey): void;
|
|
23
|
+
prune(): void;
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Method | Description |
|
|
28
|
+
|--------|-------------|
|
|
29
|
+
| `set(key, entry)` | Insert or update an entry. Must be idempotent — calling it twice with the same key replaces the entry. |
|
|
30
|
+
| `get(key)` | Look up an entry. Returns `undefined` if the key is not present or has been evicted. |
|
|
31
|
+
| `delete(key)` | Remove an entry immediately. Called after a reliable response is delivered and acknowledged. |
|
|
32
|
+
| `prune()` | Evict all entries whose `expiresAt` timestamp is in the past. Called periodically by the server. |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## `StoreKey`
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
type StoreKey =
|
|
40
|
+
| { remote: string; discriminant: 'mid'; mid: number }
|
|
41
|
+
| { remote: string; discriminant: 'token'; token: string };
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Two discriminants cover the two deduplication strategies:
|
|
45
|
+
|
|
46
|
+
| Discriminant | Used for | Key fields |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `'mid'` | CON message deduplication (RFC 7252 §4.5) | `remote` + `mid` |
|
|
49
|
+
| `'token'` | NON message deduplication (RFC 7252 §4.6) | `remote` + `token` |
|
|
50
|
+
|
|
51
|
+
`remote` is the peer address string in `"ip:port"` form.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## `StoreEntry`
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
interface StoreEntry {
|
|
59
|
+
responsePayload?: Uint8Array;
|
|
60
|
+
expiresAt: number; // Unix timestamp in milliseconds
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Field | Description |
|
|
65
|
+
|-------|-------------|
|
|
66
|
+
| `responsePayload` | The encoded response to replay on retransmission. `undefined` for NON entries where no response is cached. |
|
|
67
|
+
| `expiresAt` | When this entry should be swept by `prune()`. Typically `Date.now() + EXCHANGE_LIFETIME` for CON or `Date.now() + NON_LIFETIME` for NON. |
|
|
68
|
+
|
|
69
|
+
### Lifetime constants
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const EXCHANGE_LIFETIME: number; // 247 000 ms — RFC 7252 §4.8.2
|
|
73
|
+
const NON_LIFETIME: number; // 250 000 ms — RFC 7252 §4.8.2
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## `InMemoryStore`
|
|
79
|
+
|
|
80
|
+
A per-remote LRU map backed by a plain `Map`. Entries are evicted either by TTL (`prune()`) or by per-remote LRU cap when a single remote endpoint exceeds the cap.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
function createInMemoryStore(options?: InMemoryStoreOptions): TransactionStore
|
|
84
|
+
|
|
85
|
+
interface InMemoryStoreOptions {
|
|
86
|
+
lruCap?: number; // Max entries per remote endpoint. Default: 64.
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Characteristics
|
|
91
|
+
|
|
92
|
+
| Property | Value |
|
|
93
|
+
|----------|-------|
|
|
94
|
+
| Persistence | None — lost on process restart |
|
|
95
|
+
| Concurrency | Single-process, synchronous |
|
|
96
|
+
| Memory | O(n) where n is total live entries across all remotes |
|
|
97
|
+
| Sweep | Caller-driven — the server calls `prune()` periodically |
|
|
98
|
+
| LRU eviction | Oldest-first per remote when `lruCap` is exceeded |
|
|
99
|
+
|
|
100
|
+
### Example
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createServer } from '@sirena-lwm2m/coap';
|
|
104
|
+
import { createInMemoryStore } from '@sirena-lwm2m/coap/store';
|
|
105
|
+
|
|
106
|
+
const server = createServer({
|
|
107
|
+
listen: [{ host: '0.0.0.0', port: 5683 }],
|
|
108
|
+
store: createInMemoryStore({ lruCap: 128 }),
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## `SqliteStore`
|
|
115
|
+
|
|
116
|
+
A WAL-mode SQLite-backed store for persistence across restarts and multi-process deployments.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
function createSqliteStore(options?: SqliteStoreOptions): Promise<SqliteStore>
|
|
120
|
+
|
|
121
|
+
interface SqliteStoreOptions {
|
|
122
|
+
path?: string; // File path. Default: ':memory:' (in-process, no persistence).
|
|
123
|
+
sweepIntervalMs?: number; // Background sweep interval. Default: 5 000 ms.
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface SqliteStore extends TransactionStore {
|
|
127
|
+
close(): void; // Stop the background sweep timer and close the database.
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`createSqliteStore` is `async` because it dynamically imports `better-sqlite3`, which is an optional peer dependency. If `better-sqlite3` is not installed the promise rejects with an actionable error message.
|
|
132
|
+
|
|
133
|
+
### Peer dependency
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm install better-sqlite3
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`better-sqlite3` is declared as an optional peer dependency. It is only required if you use `SqliteStore`.
|
|
140
|
+
|
|
141
|
+
### Characteristics
|
|
142
|
+
|
|
143
|
+
| Property | Value |
|
|
144
|
+
|----------|-------|
|
|
145
|
+
| Persistence | Durable — survives process restarts when `path` points to a file |
|
|
146
|
+
| Concurrency | Safe for multiple readers, single writer (WAL mode) |
|
|
147
|
+
| Sweep | Background timer (default 5 s), plus caller-driven `prune()` |
|
|
148
|
+
| `close()` | Required — stops the timer and releases the SQLite connection |
|
|
149
|
+
|
|
150
|
+
### Schema
|
|
151
|
+
|
|
152
|
+
```sql
|
|
153
|
+
CREATE TABLE IF NOT EXISTS dedup (
|
|
154
|
+
key TEXT PRIMARY KEY,
|
|
155
|
+
expires INTEGER NOT NULL,
|
|
156
|
+
payload BLOB
|
|
157
|
+
);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
One row per store entry. The `key` column is a `\x00`-separated composite of discriminant, remote address, and MID or token.
|
|
161
|
+
|
|
162
|
+
### Example
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { createServer } from '@sirena-lwm2m/coap';
|
|
166
|
+
import { createSqliteStore } from '@sirena-lwm2m/coap/store';
|
|
167
|
+
|
|
168
|
+
const store = await createSqliteStore({
|
|
169
|
+
path: '/var/lib/myapp/coap-dedup.db',
|
|
170
|
+
sweepIntervalMs: 10_000,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const server = createServer({
|
|
174
|
+
listen: [{ host: '0.0.0.0', port: 5683 }],
|
|
175
|
+
store,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await server.start();
|
|
179
|
+
|
|
180
|
+
// Graceful shutdown
|
|
181
|
+
process.on('SIGTERM', async () => {
|
|
182
|
+
await server.stop();
|
|
183
|
+
store.close();
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### In-memory SQLite (testing)
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const store = await createSqliteStore(); // path defaults to ':memory:'
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
This is useful in tests where you want the SQLite code path exercised without a filesystem dependency.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Custom store
|
|
198
|
+
|
|
199
|
+
Implement `TransactionStore` directly to integrate with any backend:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import type { TransactionStore, StoreKey, StoreEntry } from '@sirena-lwm2m/coap/store';
|
|
203
|
+
|
|
204
|
+
class RedisStore implements TransactionStore {
|
|
205
|
+
set(key: StoreKey, entry: StoreEntry): void { /* … */ }
|
|
206
|
+
get(key: StoreKey): StoreEntry | undefined { /* … */ }
|
|
207
|
+
delete(key: StoreKey): void { /* … */ }
|
|
208
|
+
prune(): void { /* … */ }
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The server calls `prune()` on a regular interval derived from `EXCHANGE_LIFETIME`. Custom stores should expire entries lazily in `get()` as well to avoid serving stale cached responses after a restart.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# TCP Transport (RFC 8323)
|
|
2
|
+
|
|
3
|
+
`@sirena-lwm2m/coap` supports CoAP over TCP as specified in [RFC 8323](https://www.rfc-editor.org/rfc/rfc8323). Bind a TCP listener alongside UDP by including a `coap+tcp://` URI in the `listen` array.
|
|
4
|
+
|
|
5
|
+
## RFC 8323 framing
|
|
6
|
+
|
|
7
|
+
CoAP over TCP replaces the fixed 4-byte UDP header with a variable-length framing scheme:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
0 1 2 3
|
|
11
|
+
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
12
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
13
|
+
| Len | TKL | Code | Token (TKL bytes) ... |
|
|
14
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
15
|
+
| Options (if any) ... |
|
|
16
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
17
|
+
|1 1 1 1 1 1 1 1| Payload (if any) ... |
|
|
18
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **Len** — 4-bit length indicator. Values 0–12 encode the remaining message length directly. Values 13, 14, and 15 signal 1-byte, 2-byte, and 4-byte extended length fields respectively.
|
|
22
|
+
- There is no `Version` or `Type` field — TCP's reliability removes the need for ACK/RST message types.
|
|
23
|
+
- The `messageId` field is absent; deduplication uses the token.
|
|
24
|
+
|
|
25
|
+
## Connection multiplexing
|
|
26
|
+
|
|
27
|
+
A single TCP connection can carry concurrent CoAP exchanges. Each exchange is identified by its token. The server maintains a per-connection token-keyed map; a response is written back on the same socket that delivered the request.
|
|
28
|
+
|
|
29
|
+
Connection management lifecycle:
|
|
30
|
+
1. Client connects.
|
|
31
|
+
2. Server sends a CSM (Capabilities and Settings Message, code 7.01) immediately on accept.
|
|
32
|
+
3. Client may send its own CSM; the server logs and ignores unknown options.
|
|
33
|
+
4. Exchanges proceed. Multiple requests may be in flight simultaneously on the same connection.
|
|
34
|
+
5. Either side may close the connection; the server treats a closed socket as exchange cancellation.
|
|
35
|
+
|
|
36
|
+
## Idle timeout
|
|
37
|
+
|
|
38
|
+
There is currently no configurable idle-connection timeout. Connections remain open until the client closes them or a network error is detected. A future release will expose an `idleTimeoutMs` option on `ServerOptions`.
|
|
39
|
+
|
|
40
|
+
## Per-transport transaction store
|
|
41
|
+
|
|
42
|
+
UDP and TCP exchanges use separate deduplication stores. This prevents a retransmitted UDP CON message from being confused with a TCP exchange carrying the same token, and vice versa.
|
|
43
|
+
|
|
44
|
+
By default, both stores are in-memory. Pass a custom `store` to `createServer` to replace the default UDP store; the TCP store is always a separate in-memory instance.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const server = createServer({
|
|
48
|
+
listen: [
|
|
49
|
+
'coap://0.0.0.0:5683',
|
|
50
|
+
'coap+tcp://0.0.0.0:5684',
|
|
51
|
+
],
|
|
52
|
+
// optional: custom UDP deduplication store
|
|
53
|
+
store: myCustomStore,
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`server.storeSummary()` returns an entry for each active store:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
[
|
|
61
|
+
{ transport: 'udp', isDefault: true },
|
|
62
|
+
{ transport: 'tcp', isDefault: true },
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## ExchangeContext.transport
|
|
67
|
+
|
|
68
|
+
Every request handler receives `ctx.transport` set to `'udp'` or `'tcp'`, allowing the handler to branch on the transport that delivered the request:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
server.on('request', async (req, ctx) => {
|
|
72
|
+
console.log(`[${ctx.transport}] ${req.url}`);
|
|
73
|
+
await ctx.respond(Response.ok(stringToBytes(`received via ${ctx.transport}`)));
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Signaling messages
|
|
78
|
+
|
|
79
|
+
The server handles the following RFC 8323 §5.3 signaling codes internally:
|
|
80
|
+
|
|
81
|
+
| Code | Name | Behaviour |
|
|
82
|
+
|------|---------|-----------|
|
|
83
|
+
| 7.01 | CSM | Sent on accept; received CSM is parsed and ignored |
|
|
84
|
+
| 7.02 | Ping | Replied with Pong (7.03) |
|
|
85
|
+
| 7.03 | Pong | Silently dropped |
|
|
86
|
+
| 7.04 | Release | Connection closed gracefully |
|
|
87
|
+
| 7.05 | Abort | Connection destroyed |
|
|
88
|
+
|
|
89
|
+
Application handlers never see signaling messages — they are consumed before the `'request'` event is emitted.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# UDP Transport
|
|
2
|
+
|
|
3
|
+
`createServer(options)` creates a CoAP server over plain UDP (RFC 7252).
|
|
4
|
+
|
|
5
|
+
## ServerOptions
|
|
6
|
+
|
|
7
|
+
| Field | Type | Default | Description |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| `listen` | `ListenAddress[]` | required | One or more `{ host, port }` pairs to bind |
|
|
10
|
+
| `store` | `TransactionStore` | in-memory | Custom deduplication store |
|
|
11
|
+
| `logger` | `Logger` | no-op | Structured logger (`debug`, `info`, `warn`, `error`) |
|
|
12
|
+
| `queueSize` | `number` | `1024` | Max global in-flight + queued exchanges before dropping |
|
|
13
|
+
| `drainTimeoutMs` | `number` | `5000` | How long `stop()` waits for in-flight exchanges |
|
|
14
|
+
| `ackTimeoutMs` | `number` | `2000` | RFC 7252 ACK_TIMEOUT (ms) |
|
|
15
|
+
| `ackRandomFactor` | `number` | `1.5` | RFC 7252 ACK_RANDOM_FACTOR |
|
|
16
|
+
| `maxRetransmit` | `number` | `4` | RFC 7252 MAX_RETRANSMIT |
|
|
17
|
+
| `nonTimeoutMs` | `number` | `145000` | NON_LIFETIME (ms) |
|
|
18
|
+
| `nstart` | `number` | `1` | Max concurrent exchanges per endpoint |
|
|
19
|
+
| `perEndpointLruCap` | `number` | `64` | Max in-flight + queued per remote endpoint |
|
|
20
|
+
|
|
21
|
+
## Server interface
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
interface Server extends EventEmitter {
|
|
25
|
+
start(): Promise<void>;
|
|
26
|
+
stop(opts?: { drainTimeoutMs?: number }): Promise<void>;
|
|
27
|
+
isReady(): boolean;
|
|
28
|
+
activeExchanges(): number;
|
|
29
|
+
listenerSummary(): Array<{ host: string; port: number }>;
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## ExchangeContext
|
|
34
|
+
|
|
35
|
+
Passed as the second argument to every `'request'` handler:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
interface ExchangeContext {
|
|
39
|
+
remote: string; // "ip:port" of the sender
|
|
40
|
+
local: string; // "ip:port" of the receiving socket
|
|
41
|
+
token: Uint8Array; // CoAP token bytes
|
|
42
|
+
messageId: number; // CoAP message ID
|
|
43
|
+
options: CoapOption[]; // raw parsed options
|
|
44
|
+
peer: Peer; // { mode: 'anonymous' } in Slice 1
|
|
45
|
+
respond(res: Response): Promise<void>;
|
|
46
|
+
cancel(): void;
|
|
47
|
+
abort(code: CoapCode): void;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Events
|
|
52
|
+
|
|
53
|
+
| Event | Payload | Emitted when |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `'request'` | `(req: Request, ctx: ExchangeContext)` | A new CoAP request arrives |
|
|
56
|
+
| `'response'` | `(ctx: ExchangeContext)` | A response is sent |
|
|
57
|
+
| `'exchange-error'` | `(err: Error)` | Decode error or handler exception |
|
|
58
|
+
| `'message-dropped'` | `(reason: string)` | Queue or per-endpoint cap exceeded |
|
|
59
|
+
|
|
60
|
+
## Lifecycle
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
createServer(options)
|
|
64
|
+
│
|
|
65
|
+
▼
|
|
66
|
+
server.start() ← binds all sockets; rejects if any bind fails
|
|
67
|
+
│
|
|
68
|
+
▼
|
|
69
|
+
[handling 'request' events]
|
|
70
|
+
│
|
|
71
|
+
▼
|
|
72
|
+
server.stop({ drainTimeoutMs })
|
|
73
|
+
├── waits up to drainTimeoutMs for in-flight exchanges
|
|
74
|
+
└── closes all sockets
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`isReady()` returns `true` between `start()` and `stop()`.
|
|
78
|
+
|
|
79
|
+
`activeExchanges()` returns the count of in-flight (running + queued) exchanges.
|
|
80
|
+
|
|
81
|
+
`listenerSummary()` returns the bound address of each socket.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createServer, Response, stringToBytes } from '@sirena-lwm2m/coap';
|
|
2
|
+
|
|
3
|
+
const server = createServer({
|
|
4
|
+
listen: [{ host: '127.0.0.1', port: 5683 }],
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
server.on('request', async (req, ctx) => {
|
|
8
|
+
await ctx.respond(Response.ok(stringToBytes(req.url)));
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
await server.start();
|
|
12
|
+
console.log('CoAP server listening on:', server.listenerSummary());
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import dgram from 'node:dgram';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { createServer } from '@sirena-lwm2m/coap/transport';
|
|
4
|
+
import { encodeTcpFrame, decodeTcpFrame } from '../dist/transport/index.js';
|
|
5
|
+
import { encodeMessage, decodeMessage, stringToCode } from '@sirena-lwm2m/coap';
|
|
6
|
+
import { Response, stringToBytes } from '@sirena-lwm2m/coap';
|
|
7
|
+
|
|
8
|
+
// --- Server: listens on both UDP (5683) and TCP (5684) ---
|
|
9
|
+
|
|
10
|
+
const server = createServer({
|
|
11
|
+
listen: ['coap://127.0.0.1:5683', 'coap+tcp://127.0.0.1:5684'],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
server.on('request', async (req, ctx) => {
|
|
15
|
+
console.log(`[${ctx.transport}] ${req.url}`);
|
|
16
|
+
await ctx.respond(Response.ok(stringToBytes(`[${ctx.transport}] ${req.url}`)));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
await server.start();
|
|
20
|
+
console.log('Server listening on:', server.listenerSummary());
|
|
21
|
+
|
|
22
|
+
// --- UDP client request ---
|
|
23
|
+
|
|
24
|
+
async function udpGet(host: string, port: number, path: string): Promise<string> {
|
|
25
|
+
const token = new Uint8Array([0x01]);
|
|
26
|
+
const packet = encodeMessage({
|
|
27
|
+
version: 1,
|
|
28
|
+
type: 'CON',
|
|
29
|
+
token,
|
|
30
|
+
code: stringToCode('0.01'),
|
|
31
|
+
messageId: 1,
|
|
32
|
+
options: [],
|
|
33
|
+
payload: new Uint8Array(0),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const sock = dgram.createSocket('udp4');
|
|
38
|
+
const timer = setTimeout(() => { sock.close(); reject(new Error('UDP timeout')); }, 3000);
|
|
39
|
+
|
|
40
|
+
sock.once('message', (msg) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
sock.close();
|
|
43
|
+
const decoded = decodeMessage(msg);
|
|
44
|
+
resolve(Buffer.from(decoded.payload).toString('utf8'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
sock.bind(0, '127.0.0.1', () => {
|
|
48
|
+
sock.send(packet, port, host);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
sock.once('error', (err) => { clearTimeout(timer); sock.close(); reject(err); });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- TCP client request ---
|
|
56
|
+
|
|
57
|
+
async function tcpGet(host: string, port: number, path: string): Promise<string> {
|
|
58
|
+
const token = new Uint8Array([0x02]);
|
|
59
|
+
const frame = encodeTcpFrame({
|
|
60
|
+
code: stringToCode('0.01'),
|
|
61
|
+
token,
|
|
62
|
+
options: [],
|
|
63
|
+
payload: new Uint8Array(0),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const sock = net.createConnection({ host, port }, () => {
|
|
68
|
+
sock.write(Buffer.from(frame));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error('TCP timeout')); }, 3000);
|
|
72
|
+
|
|
73
|
+
let buf = Buffer.alloc(0);
|
|
74
|
+
sock.on('data', (chunk: Buffer) => {
|
|
75
|
+
buf = Buffer.concat([buf, chunk]);
|
|
76
|
+
const result = decodeTcpFrame(buf);
|
|
77
|
+
if (result !== null && result.frame.payload.length > 0) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
sock.destroy();
|
|
80
|
+
resolve(Buffer.from(result.frame.payload).toString('utf8'));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
sock.once('error', (err) => { clearTimeout(timer); reject(err); });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Make one request over each transport ---
|
|
89
|
+
|
|
90
|
+
const udpReply = await udpGet('127.0.0.1', 5683, '/hello');
|
|
91
|
+
console.log('UDP reply:', udpReply);
|
|
92
|
+
|
|
93
|
+
const tcpReply = await tcpGet('127.0.0.1', 5684, '/hello');
|
|
94
|
+
console.log('TCP reply:', tcpReply);
|
|
95
|
+
|
|
96
|
+
await server.stop();
|