@hla4ts/transport 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +386 -0
- package/package.json +30 -0
- package/src/constants.ts +57 -0
- package/src/frame-decoder.ts +136 -0
- package/src/index.ts +82 -0
- package/src/message-header.ts +141 -0
- package/src/message-type.ts +62 -0
- package/src/tcp-transport.ts +154 -0
- package/src/tls-transport.ts +193 -0
- package/src/transport.ts +99 -0
package/README.md
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# @hla4ts/transport
|
|
2
|
+
|
|
3
|
+
**HLA 4 Federate Protocol Transport Layer**
|
|
4
|
+
|
|
5
|
+
This package provides the transport layer for the HLA 4 Federate Protocol, handling TCP/TLS connections, message framing, and low-level communication with HLA 4 RTIs.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
The Federate Protocol uses a binary framing format over TCP or TLS connections. Each message has a 24-byte header followed by an optional payload. This package handles:
|
|
10
|
+
|
|
11
|
+
- **Connection management** - TCP and TLS socket connections
|
|
12
|
+
- **Message framing** - Encoding/decoding the 24-byte header
|
|
13
|
+
- **Stream handling** - Buffering partial messages, extracting multiple messages from a single chunk
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @hla4ts/transport
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import {
|
|
25
|
+
TlsTransport,
|
|
26
|
+
MessageType,
|
|
27
|
+
encodeMessage,
|
|
28
|
+
FEDERATE_PROTOCOL_VERSION,
|
|
29
|
+
} from '@hla4ts/transport';
|
|
30
|
+
|
|
31
|
+
// Create a TLS transport
|
|
32
|
+
const transport = new TlsTransport({
|
|
33
|
+
host: 'rti.example.com',
|
|
34
|
+
port: 15165,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Set up event handlers
|
|
38
|
+
transport.setEventHandlers({
|
|
39
|
+
onMessage: (msg) => {
|
|
40
|
+
console.log('Received:', MessageType[msg.header.messageType]);
|
|
41
|
+
console.log('Payload size:', msg.payload.length);
|
|
42
|
+
},
|
|
43
|
+
onClose: (hadError) => {
|
|
44
|
+
console.log('Connection closed', hadError ? '(with error)' : '');
|
|
45
|
+
},
|
|
46
|
+
onError: (err) => {
|
|
47
|
+
console.error('Transport error:', err);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Connect
|
|
52
|
+
await transport.connect();
|
|
53
|
+
|
|
54
|
+
// Send a CTRL_NEW_SESSION message
|
|
55
|
+
const payload = new Uint8Array(4);
|
|
56
|
+
new DataView(payload.buffer).setUint32(0, FEDERATE_PROTOCOL_VERSION, false);
|
|
57
|
+
|
|
58
|
+
const message = encodeMessage(
|
|
59
|
+
1, // sequence number
|
|
60
|
+
0n, // session ID (0 for new session)
|
|
61
|
+
0, // last received sequence number
|
|
62
|
+
MessageType.CTRL_NEW_SESSION, // message type
|
|
63
|
+
payload // payload
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await transport.send(message);
|
|
67
|
+
|
|
68
|
+
// Later: disconnect
|
|
69
|
+
await transport.disconnect();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Sequence Diagram
|
|
73
|
+
|
|
74
|
+
```mermaid
|
|
75
|
+
sequenceDiagram
|
|
76
|
+
participant RTI
|
|
77
|
+
participant Transport as Tcp/TlsTransport
|
|
78
|
+
participant Decoder as FrameDecoder
|
|
79
|
+
participant App as Federate Code
|
|
80
|
+
RTI-->>Transport: TCP/TLS bytes
|
|
81
|
+
Transport-->>Decoder: push(chunk)
|
|
82
|
+
Decoder-->>App: onMessage(header, payload)
|
|
83
|
+
App->>Transport: send(encoded message)
|
|
84
|
+
Transport-->>RTI: TCP/TLS bytes
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Message Format
|
|
88
|
+
|
|
89
|
+
Every Federate Protocol message has a **24-byte header**:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
93
|
+
│ Offset │ Size │ Field │ Description │
|
|
94
|
+
├────────┼───────┼──────────────────────────┼─────────────────────┤
|
|
95
|
+
│ 0 │ 4 │ packetSize │ Total message size │
|
|
96
|
+
│ 4 │ 4 │ sequenceNumber │ Message sequence # │
|
|
97
|
+
│ 8 │ 8 │ sessionId │ Session identifier │
|
|
98
|
+
│ 16 │ 4 │ lastReceivedSequenceNum │ ACK for flow ctrl │
|
|
99
|
+
│ 20 │ 4 │ messageType │ Type of message │
|
|
100
|
+
├────────┴───────┴──────────────────────────┴─────────────────────┤
|
|
101
|
+
│ 24 │ var │ payload │ Message payload │
|
|
102
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
All integers are **big-endian** (network byte order).
|
|
106
|
+
|
|
107
|
+
## Message Types
|
|
108
|
+
|
|
109
|
+
### Control Messages (Session Management)
|
|
110
|
+
|
|
111
|
+
| Code | Name | Direction | Description |
|
|
112
|
+
|------|------|-----------|-------------|
|
|
113
|
+
| 1 | `CTRL_NEW_SESSION` | C → S | Request new session |
|
|
114
|
+
| 2 | `CTRL_NEW_SESSION_STATUS` | S → C | Session creation result |
|
|
115
|
+
| 3 | `CTRL_HEARTBEAT` | Both | Keep-alive ping |
|
|
116
|
+
| 4 | `CTRL_HEARTBEAT_RESPONSE` | Both | Keep-alive pong |
|
|
117
|
+
| 5 | `CTRL_TERMINATE_SESSION` | C → S | Request session end |
|
|
118
|
+
| 6 | `CTRL_SESSION_TERMINATED` | S → C | Session ended |
|
|
119
|
+
| 10 | `CTRL_RESUME_REQUEST` | C → S | Resume dropped session |
|
|
120
|
+
| 11 | `CTRL_RESUME_STATUS` | S → C | Resume result |
|
|
121
|
+
|
|
122
|
+
### HLA Messages
|
|
123
|
+
|
|
124
|
+
| Code | Name | Direction | Description |
|
|
125
|
+
|------|------|-----------|-------------|
|
|
126
|
+
| 20 | `HLA_CALL_REQUEST` | C → S | RTI service call (protobuf `CallRequest`) |
|
|
127
|
+
| 21 | `HLA_CALL_RESPONSE` | S → C | RTI response (protobuf `CallResponse`) |
|
|
128
|
+
| 22 | `HLA_CALLBACK_REQUEST` | S → C | Federate callback (protobuf `CallbackRequest`) |
|
|
129
|
+
| 23 | `HLA_CALLBACK_RESPONSE` | C → S | Callback ACK (protobuf `CallbackResponse`) |
|
|
130
|
+
|
|
131
|
+
## API Reference
|
|
132
|
+
|
|
133
|
+
### Transports
|
|
134
|
+
|
|
135
|
+
#### `TlsTransport`
|
|
136
|
+
|
|
137
|
+
Secure TLS connection (recommended for production):
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { TlsTransport, Ports } from '@hla4ts/transport';
|
|
141
|
+
|
|
142
|
+
const transport = new TlsTransport({
|
|
143
|
+
host: 'rti.example.com',
|
|
144
|
+
port: Ports.TLS, // 15165 (default)
|
|
145
|
+
connectionTimeout: 10000, // 10 seconds (default)
|
|
146
|
+
noDelay: true, // TCP_NODELAY (default)
|
|
147
|
+
|
|
148
|
+
// TLS options
|
|
149
|
+
rejectUnauthorized: true, // Verify server cert (default)
|
|
150
|
+
ca: '/path/to/ca.pem', // Custom CA (optional)
|
|
151
|
+
cert: '/path/to/client.pem', // Client cert for mTLS (optional)
|
|
152
|
+
key: '/path/to/client-key.pem', // Client key for mTLS (optional)
|
|
153
|
+
serverName: 'rti.example.com', // SNI hostname (optional)
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### `TcpTransport`
|
|
158
|
+
|
|
159
|
+
Plain TCP connection (for development/testing only):
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { TcpTransport, Ports } from '@hla4ts/transport';
|
|
163
|
+
|
|
164
|
+
const transport = new TcpTransport({
|
|
165
|
+
host: 'localhost',
|
|
166
|
+
port: Ports.TCP, // 15164 (default)
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Transport Interface
|
|
171
|
+
|
|
172
|
+
Both transports implement the `Transport` interface:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
interface Transport {
|
|
176
|
+
connect(): Promise<void>;
|
|
177
|
+
disconnect(): Promise<void>;
|
|
178
|
+
isConnected(): boolean;
|
|
179
|
+
send(data: Uint8Array): Promise<void>;
|
|
180
|
+
setEventHandlers(handlers: TransportEvents): void;
|
|
181
|
+
getProtocolName(): string;
|
|
182
|
+
getRemoteAddress(): string | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface TransportEvents {
|
|
186
|
+
onMessage?: (message: ReceivedMessage) => void;
|
|
187
|
+
onClose?: (hadError: boolean) => void;
|
|
188
|
+
onError?: (error: Error) => void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface ReceivedMessage {
|
|
192
|
+
header: MessageHeader;
|
|
193
|
+
payload: Uint8Array;
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Message Header Functions
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
import {
|
|
201
|
+
encodeHeader,
|
|
202
|
+
decodeHeader,
|
|
203
|
+
encodeMessage,
|
|
204
|
+
getPayloadSize,
|
|
205
|
+
HEADER_SIZE, // 24
|
|
206
|
+
NO_SESSION_ID, // 0n
|
|
207
|
+
NO_SEQUENCE_NUMBER, // 0
|
|
208
|
+
} from '@hla4ts/transport';
|
|
209
|
+
|
|
210
|
+
// Encode just the header (24 bytes)
|
|
211
|
+
const header = encodeHeader(
|
|
212
|
+
payloadSize, // payload size in bytes
|
|
213
|
+
sequenceNumber, // sequence number
|
|
214
|
+
sessionId, // session ID (bigint)
|
|
215
|
+
lastReceivedSequenceNum, // ACK
|
|
216
|
+
messageType // MessageType enum
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Encode header + payload
|
|
220
|
+
const message = encodeMessage(
|
|
221
|
+
sequenceNumber,
|
|
222
|
+
sessionId,
|
|
223
|
+
lastReceivedSequenceNum,
|
|
224
|
+
messageType,
|
|
225
|
+
payload // optional Uint8Array
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Decode a header
|
|
229
|
+
const header = decodeHeader(buffer); // throws on invalid data
|
|
230
|
+
console.log(header.packetSize);
|
|
231
|
+
console.log(header.sequenceNumber);
|
|
232
|
+
console.log(header.sessionId);
|
|
233
|
+
console.log(header.messageType);
|
|
234
|
+
|
|
235
|
+
// Get payload size from header
|
|
236
|
+
const payloadSize = getPayloadSize(header);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Frame Decoder
|
|
240
|
+
|
|
241
|
+
For advanced use cases, you can use the `FrameDecoder` directly:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import { FrameDecoder } from '@hla4ts/transport';
|
|
245
|
+
|
|
246
|
+
const decoder = new FrameDecoder();
|
|
247
|
+
|
|
248
|
+
decoder.onMessage = (msg) => {
|
|
249
|
+
console.log('Complete message received');
|
|
250
|
+
console.log('Type:', msg.header.messageType);
|
|
251
|
+
console.log('Payload:', msg.payload);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
decoder.onError = (err) => {
|
|
255
|
+
console.error('Decode error:', err);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Feed data as it arrives (handles fragmentation automatically)
|
|
259
|
+
socket.on('data', (chunk) => {
|
|
260
|
+
decoder.push(new Uint8Array(chunk));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Check buffered data
|
|
264
|
+
console.log('Bytes waiting:', decoder.bufferedBytes);
|
|
265
|
+
|
|
266
|
+
// Reset after connection reset
|
|
267
|
+
decoder.reset();
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Constants
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
import {
|
|
274
|
+
FEDERATE_PROTOCOL_VERSION, // 1
|
|
275
|
+
Ports,
|
|
276
|
+
Protocol,
|
|
277
|
+
NewSessionStatus,
|
|
278
|
+
ResumeStatus,
|
|
279
|
+
} from '@hla4ts/transport';
|
|
280
|
+
|
|
281
|
+
// Default ports
|
|
282
|
+
Ports.TCP // 15164
|
|
283
|
+
Ports.TLS // 15165
|
|
284
|
+
Ports.WS // 80
|
|
285
|
+
Ports.WSS // 443
|
|
286
|
+
|
|
287
|
+
// Protocol names
|
|
288
|
+
Protocol.TCP // "tcp"
|
|
289
|
+
Protocol.TLS // "tls"
|
|
290
|
+
Protocol.WS // "websocket"
|
|
291
|
+
Protocol.WSS // "websocketsecure"
|
|
292
|
+
|
|
293
|
+
// Session status codes
|
|
294
|
+
NewSessionStatus.SUCCESS // 0
|
|
295
|
+
NewSessionStatus.UNSUPPORTED_PROTOCOL_VERSION // 1
|
|
296
|
+
NewSessionStatus.OUT_OF_RESOURCES // 2
|
|
297
|
+
NewSessionStatus.BAD_MESSAGE // 3
|
|
298
|
+
NewSessionStatus.OTHER_ERROR // 99
|
|
299
|
+
|
|
300
|
+
// Resume status codes
|
|
301
|
+
ResumeStatus.SUCCESS // 0
|
|
302
|
+
ResumeStatus.SESSION_NOT_FOUND // 1
|
|
303
|
+
ResumeStatus.NOT_ALLOWED // 2
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Session Protocol Flow
|
|
307
|
+
|
|
308
|
+
```
|
|
309
|
+
┌─────────────┐ ┌─────────────┐
|
|
310
|
+
│ Federate │ │ RTI │
|
|
311
|
+
└──────┬──────┘ └──────┬──────┘
|
|
312
|
+
│ │
|
|
313
|
+
│──── TCP/TLS Connect ──────────────────────>│
|
|
314
|
+
│ │
|
|
315
|
+
│──── CTRL_NEW_SESSION (version=1) ─────────>│
|
|
316
|
+
│ │
|
|
317
|
+
│<─── CTRL_NEW_SESSION_STATUS (sessionId) ───│
|
|
318
|
+
│ │
|
|
319
|
+
│──── HLA_CALL_REQUEST (Connect) ───────────>│
|
|
320
|
+
│<─── HLA_CALL_RESPONSE ─────────────────────│
|
|
321
|
+
│ │
|
|
322
|
+
│──── HLA_CALL_REQUEST (JoinFederation) ────>│
|
|
323
|
+
│<─── HLA_CALL_RESPONSE ─────────────────────│
|
|
324
|
+
│ │
|
|
325
|
+
│ ... HLA calls and callbacks ... │
|
|
326
|
+
│ │
|
|
327
|
+
│<─── HLA_CALLBACK_REQUEST (Discover...) ────│
|
|
328
|
+
│──── HLA_CALLBACK_RESPONSE ────────────────>│
|
|
329
|
+
│ │
|
|
330
|
+
│──── CTRL_HEARTBEAT ───────────────────────>│
|
|
331
|
+
│<─── CTRL_HEARTBEAT_RESPONSE ──────────────│
|
|
332
|
+
│ │
|
|
333
|
+
│──── CTRL_TERMINATE_SESSION ───────────────>│
|
|
334
|
+
│<─── CTRL_SESSION_TERMINATED ──────────────│
|
|
335
|
+
│ │
|
|
336
|
+
│──── TCP/TLS Disconnect ───────────────────>│
|
|
337
|
+
│ │
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Error Handling
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
try {
|
|
344
|
+
await transport.connect();
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (err.message.includes('Connection timeout')) {
|
|
347
|
+
console.error('RTI not reachable');
|
|
348
|
+
} else if (err.message.includes('certificate')) {
|
|
349
|
+
console.error('TLS certificate error');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
transport.setEventHandlers({
|
|
354
|
+
onError: (err) => {
|
|
355
|
+
console.error('Transport error:', err);
|
|
356
|
+
// Attempt reconnection...
|
|
357
|
+
},
|
|
358
|
+
onClose: (hadError) => {
|
|
359
|
+
if (hadError) {
|
|
360
|
+
console.error('Connection lost unexpectedly');
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Testing
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
cd packages/transport
|
|
370
|
+
bun test
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## Related Packages
|
|
374
|
+
|
|
375
|
+
- [`@hla4ts/proto`](../proto) - Protocol buffer types
|
|
376
|
+
- [`@hla4ts/session`](../session) - Session management
|
|
377
|
+
- [`@hla4ts/hla-api`](../hla-api) - High-level HLA API facade
|
|
378
|
+
|
|
379
|
+
## References
|
|
380
|
+
|
|
381
|
+
- [IEEE 1516-2025](https://standards.ieee.org/standard/1516-2025.html) - HLA 4 Standard
|
|
382
|
+
- [Pitch FedProClient](https://github.com/Pitch-Technologies/FedProClient) - Reference implementation
|
|
383
|
+
|
|
384
|
+
## License
|
|
385
|
+
|
|
386
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hla4ts/transport",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "HLA 4 Federate Protocol transport layer with TLS and message framing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"README.md",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
20
|
+
"clean": "rm -rf dist",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@hla4ts/proto": "^0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "^5.3.3"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federate Protocol Constants
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Current Federate Protocol version */
|
|
6
|
+
export const FEDERATE_PROTOCOL_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
/** Default ports for different transport protocols */
|
|
9
|
+
export const Ports = {
|
|
10
|
+
/** Plain TCP (unencrypted) */
|
|
11
|
+
TCP: 15164,
|
|
12
|
+
/** TLS over TCP (encrypted) */
|
|
13
|
+
TLS: 15165,
|
|
14
|
+
/** WebSocket (unencrypted) */
|
|
15
|
+
WS: 80,
|
|
16
|
+
/** WebSocket Secure (encrypted) */
|
|
17
|
+
WSS: 443,
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/** Transport protocol names */
|
|
21
|
+
export const Protocol = {
|
|
22
|
+
TCP: "tcp",
|
|
23
|
+
TLS: "tls",
|
|
24
|
+
WS: "websocket",
|
|
25
|
+
WSS: "websocketsecure",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type ProtocolType = (typeof Protocol)[keyof typeof Protocol];
|
|
29
|
+
|
|
30
|
+
/** New session status reason codes */
|
|
31
|
+
export const NewSessionStatus = {
|
|
32
|
+
/** Session created successfully */
|
|
33
|
+
SUCCESS: 0,
|
|
34
|
+
/** Server doesn't support the requested protocol version */
|
|
35
|
+
UNSUPPORTED_PROTOCOL_VERSION: 1,
|
|
36
|
+
/** Server is out of resources */
|
|
37
|
+
OUT_OF_RESOURCES: 2,
|
|
38
|
+
/** Bad message received */
|
|
39
|
+
BAD_MESSAGE: 3,
|
|
40
|
+
/** Other unspecified error */
|
|
41
|
+
OTHER_ERROR: 99,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export type NewSessionStatusCode =
|
|
45
|
+
(typeof NewSessionStatus)[keyof typeof NewSessionStatus];
|
|
46
|
+
|
|
47
|
+
/** Resume status reason codes */
|
|
48
|
+
export const ResumeStatus = {
|
|
49
|
+
/** Resume successful */
|
|
50
|
+
SUCCESS: 0,
|
|
51
|
+
/** Session not found (expired or invalid) */
|
|
52
|
+
SESSION_NOT_FOUND: 1,
|
|
53
|
+
/** Resume not allowed in current state */
|
|
54
|
+
NOT_ALLOWED: 2,
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
export type ResumeStatusCode = (typeof ResumeStatus)[keyof typeof ResumeStatus];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame Decoder
|
|
3
|
+
*
|
|
4
|
+
* Handles accumulating partial data from the network and extracting
|
|
5
|
+
* complete framed messages. This is necessary because TCP is a stream
|
|
6
|
+
* protocol and messages may arrive in fragments or multiple messages
|
|
7
|
+
* may arrive in a single chunk.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
HEADER_SIZE,
|
|
12
|
+
decodeHeader,
|
|
13
|
+
type MessageHeader,
|
|
14
|
+
} from "./message-header.ts";
|
|
15
|
+
import type { ReceivedMessage } from "./transport.ts";
|
|
16
|
+
|
|
17
|
+
/** Maximum allowed message size (16 MB) - protection against malformed packets */
|
|
18
|
+
const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Frame decoder for length-prefixed messages
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* ```ts
|
|
25
|
+
* const decoder = new FrameDecoder();
|
|
26
|
+
* decoder.onMessage = (msg) => console.log('Got message:', msg);
|
|
27
|
+
*
|
|
28
|
+
* // Feed data as it arrives from the socket
|
|
29
|
+
* socket.on('data', (chunk) => decoder.push(chunk));
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export class FrameDecoder {
|
|
33
|
+
private buffer: Uint8Array = new Uint8Array(0);
|
|
34
|
+
private pendingHeader: MessageHeader | null = null;
|
|
35
|
+
|
|
36
|
+
/** Callback for complete messages */
|
|
37
|
+
public onMessage: ((message: ReceivedMessage) => void) | null = null;
|
|
38
|
+
|
|
39
|
+
/** Callback for decode errors */
|
|
40
|
+
public onError: ((error: Error) => void) | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Push incoming data into the decoder
|
|
44
|
+
* @param chunk Data received from the network
|
|
45
|
+
*/
|
|
46
|
+
push(chunk: Uint8Array): void {
|
|
47
|
+
// Append chunk to buffer
|
|
48
|
+
this.buffer = this.concat(this.buffer, chunk);
|
|
49
|
+
|
|
50
|
+
// Process as many complete messages as possible
|
|
51
|
+
this.processBuffer();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reset the decoder state (e.g., after a connection reset)
|
|
56
|
+
*/
|
|
57
|
+
reset(): void {
|
|
58
|
+
this.buffer = new Uint8Array(0);
|
|
59
|
+
this.pendingHeader = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the number of bytes currently buffered
|
|
64
|
+
*/
|
|
65
|
+
get bufferedBytes(): number {
|
|
66
|
+
return this.buffer.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private processBuffer(): void {
|
|
70
|
+
while (true) {
|
|
71
|
+
// If we don't have a header yet, try to read one
|
|
72
|
+
if (this.pendingHeader === null) {
|
|
73
|
+
if (this.buffer.length < HEADER_SIZE) {
|
|
74
|
+
// Not enough data for header yet
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
this.pendingHeader = decodeHeader(this.buffer.subarray(0, HEADER_SIZE));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.onError?.(error as Error);
|
|
82
|
+
// Skip one byte and try again (attempt to resync)
|
|
83
|
+
this.buffer = this.buffer.subarray(1);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate packet size
|
|
88
|
+
if (this.pendingHeader.packetSize > MAX_MESSAGE_SIZE) {
|
|
89
|
+
this.onError?.(
|
|
90
|
+
new Error(
|
|
91
|
+
`Message too large: ${this.pendingHeader.packetSize} bytes (max: ${MAX_MESSAGE_SIZE})`
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
// Reset and skip this header
|
|
95
|
+
this.pendingHeader = null;
|
|
96
|
+
this.buffer = this.buffer.subarray(HEADER_SIZE);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// We have a header, check if we have the complete message
|
|
102
|
+
const totalSize = this.pendingHeader.packetSize;
|
|
103
|
+
if (this.buffer.length < totalSize) {
|
|
104
|
+
// Not enough data for complete message yet
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Extract the complete message
|
|
109
|
+
const messageData = this.buffer.subarray(0, totalSize);
|
|
110
|
+
const payload = messageData.subarray(HEADER_SIZE);
|
|
111
|
+
|
|
112
|
+
// Create the received message
|
|
113
|
+
const message: ReceivedMessage = {
|
|
114
|
+
header: this.pendingHeader,
|
|
115
|
+
payload: new Uint8Array(payload), // Copy to avoid issues with buffer reuse
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Remove this message from buffer
|
|
119
|
+
this.buffer = this.buffer.subarray(totalSize);
|
|
120
|
+
this.pendingHeader = null;
|
|
121
|
+
|
|
122
|
+
// Emit the message
|
|
123
|
+
this.onMessage?.(message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
128
|
+
if (a.length === 0) return b;
|
|
129
|
+
if (b.length === 0) return a;
|
|
130
|
+
|
|
131
|
+
const result = new Uint8Array(a.length + b.length);
|
|
132
|
+
result.set(a, 0);
|
|
133
|
+
result.set(b, a.length);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hla4ts/transport - HLA 4 Federate Protocol Transport Layer
|
|
3
|
+
*
|
|
4
|
+
* This package provides transport implementations for the HLA 4 Federate Protocol,
|
|
5
|
+
* including message framing, TLS support, and connection management.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { TlsTransport, MessageType, encodeMessage } from '@hla4ts/transport';
|
|
10
|
+
*
|
|
11
|
+
* // Create a TLS transport
|
|
12
|
+
* const transport = new TlsTransport({
|
|
13
|
+
* host: 'rti.example.com',
|
|
14
|
+
* port: 15165,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // Set up event handlers
|
|
18
|
+
* transport.setEventHandlers({
|
|
19
|
+
* onMessage: (msg) => {
|
|
20
|
+
* console.log('Received:', MessageType[msg.header.messageType]);
|
|
21
|
+
* },
|
|
22
|
+
* onClose: () => console.log('Connection closed'),
|
|
23
|
+
* onError: (err) => console.error('Error:', err),
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // Connect and send a message
|
|
27
|
+
* await transport.connect();
|
|
28
|
+
* const message = encodeMessage(
|
|
29
|
+
* 1, // sequence number
|
|
30
|
+
* 0n, // session ID (0 for new session)
|
|
31
|
+
* 0, // last received seq num
|
|
32
|
+
* MessageType.CTRL_NEW_SESSION, // message type
|
|
33
|
+
* payload // optional payload
|
|
34
|
+
* );
|
|
35
|
+
* await transport.send(message);
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @packageDocumentation
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// Message types
|
|
42
|
+
export { MessageType, isControlMessage, isHlaResponse, messageTypeName } from "./message-type.ts";
|
|
43
|
+
|
|
44
|
+
// Message header encoding/decoding
|
|
45
|
+
export {
|
|
46
|
+
HEADER_SIZE,
|
|
47
|
+
NO_SESSION_ID,
|
|
48
|
+
NO_SEQUENCE_NUMBER,
|
|
49
|
+
encodeHeader,
|
|
50
|
+
decodeHeader,
|
|
51
|
+
encodeMessage,
|
|
52
|
+
getPayloadSize,
|
|
53
|
+
type MessageHeader,
|
|
54
|
+
} from "./message-header.ts";
|
|
55
|
+
|
|
56
|
+
// Constants
|
|
57
|
+
export {
|
|
58
|
+
FEDERATE_PROTOCOL_VERSION,
|
|
59
|
+
Ports,
|
|
60
|
+
Protocol,
|
|
61
|
+
NewSessionStatus,
|
|
62
|
+
ResumeStatus,
|
|
63
|
+
type ProtocolType,
|
|
64
|
+
type NewSessionStatusCode,
|
|
65
|
+
type ResumeStatusCode,
|
|
66
|
+
} from "./constants.ts";
|
|
67
|
+
|
|
68
|
+
// Transport interface
|
|
69
|
+
export type {
|
|
70
|
+
Transport,
|
|
71
|
+
TransportOptions,
|
|
72
|
+
TlsOptions,
|
|
73
|
+
TransportEvents,
|
|
74
|
+
ReceivedMessage,
|
|
75
|
+
} from "./transport.ts";
|
|
76
|
+
|
|
77
|
+
// Frame decoder
|
|
78
|
+
export { FrameDecoder } from "./frame-decoder.ts";
|
|
79
|
+
|
|
80
|
+
// Transport implementations
|
|
81
|
+
export { TlsTransport } from "./tls-transport.ts";
|
|
82
|
+
export { TcpTransport } from "./tcp-transport.ts";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federate Protocol Message Header
|
|
3
|
+
*
|
|
4
|
+
* The message header is a 24-byte structure that precedes every message
|
|
5
|
+
* in the Federate Protocol. It contains:
|
|
6
|
+
*
|
|
7
|
+
* - packetSize (4 bytes): Total size of the packet including header
|
|
8
|
+
* - sequenceNumber (4 bytes): Monotonically increasing sequence number
|
|
9
|
+
* - sessionId (8 bytes): Unique session identifier
|
|
10
|
+
* - lastReceivedSequenceNumber (4 bytes): ACK for flow control
|
|
11
|
+
* - messageType (4 bytes): Type of message (see MessageType enum)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { MessageType } from "./message-type.ts";
|
|
15
|
+
|
|
16
|
+
/** Size of the message header in bytes */
|
|
17
|
+
export const HEADER_SIZE = 24;
|
|
18
|
+
|
|
19
|
+
/** Special value indicating no session ID */
|
|
20
|
+
export const NO_SESSION_ID = 0n;
|
|
21
|
+
|
|
22
|
+
/** Special value indicating no sequence number */
|
|
23
|
+
export const NO_SEQUENCE_NUMBER = 0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decoded message header
|
|
27
|
+
*/
|
|
28
|
+
export interface MessageHeader {
|
|
29
|
+
/** Total packet size including header */
|
|
30
|
+
packetSize: number;
|
|
31
|
+
/** Message sequence number */
|
|
32
|
+
sequenceNumber: number;
|
|
33
|
+
/** Session identifier */
|
|
34
|
+
sessionId: bigint;
|
|
35
|
+
/** Last received sequence number (for ACK) */
|
|
36
|
+
lastReceivedSequenceNumber: number;
|
|
37
|
+
/** Type of message */
|
|
38
|
+
messageType: MessageType;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Encode a message header into a 24-byte buffer
|
|
43
|
+
*/
|
|
44
|
+
export function encodeHeader(
|
|
45
|
+
payloadSize: number,
|
|
46
|
+
sequenceNumber: number,
|
|
47
|
+
sessionId: bigint,
|
|
48
|
+
lastReceivedSequenceNumber: number,
|
|
49
|
+
messageType: MessageType
|
|
50
|
+
): Uint8Array {
|
|
51
|
+
const packetSize = HEADER_SIZE + payloadSize;
|
|
52
|
+
const buffer = new ArrayBuffer(HEADER_SIZE);
|
|
53
|
+
const view = new DataView(buffer);
|
|
54
|
+
|
|
55
|
+
// All values are big-endian (network byte order)
|
|
56
|
+
view.setUint32(0, packetSize, false);
|
|
57
|
+
view.setUint32(4, sequenceNumber, false);
|
|
58
|
+
view.setBigUint64(8, sessionId, false);
|
|
59
|
+
view.setUint32(16, lastReceivedSequenceNumber, false);
|
|
60
|
+
view.setUint32(20, messageType, false);
|
|
61
|
+
|
|
62
|
+
return new Uint8Array(buffer);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decode a message header from a 24-byte buffer
|
|
67
|
+
* @throws Error if buffer is too small or contains invalid data
|
|
68
|
+
*/
|
|
69
|
+
export function decodeHeader(buffer: Uint8Array): MessageHeader {
|
|
70
|
+
if (buffer.length < HEADER_SIZE) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Buffer too small for header: expected ${HEADER_SIZE} bytes, got ${buffer.length}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const view = new DataView(
|
|
77
|
+
buffer.buffer,
|
|
78
|
+
buffer.byteOffset,
|
|
79
|
+
buffer.byteLength
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const packetSize = view.getUint32(0, false);
|
|
83
|
+
if (packetSize < HEADER_SIZE) {
|
|
84
|
+
throw new Error(`Invalid packet size: ${packetSize} (minimum is ${HEADER_SIZE})`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sequenceNumber = view.getUint32(4, false);
|
|
88
|
+
const sessionId = view.getBigUint64(8, false);
|
|
89
|
+
const lastReceivedSequenceNumber = view.getUint32(16, false);
|
|
90
|
+
const messageTypeValue = view.getUint32(20, false);
|
|
91
|
+
|
|
92
|
+
// Validate message type
|
|
93
|
+
if (!Object.values(MessageType).includes(messageTypeValue)) {
|
|
94
|
+
throw new Error(`Unknown message type: ${messageTypeValue}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
packetSize,
|
|
99
|
+
sequenceNumber,
|
|
100
|
+
sessionId,
|
|
101
|
+
lastReceivedSequenceNumber,
|
|
102
|
+
messageType: messageTypeValue as MessageType,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get the payload size from a header
|
|
108
|
+
*/
|
|
109
|
+
export function getPayloadSize(header: MessageHeader): number {
|
|
110
|
+
return header.packetSize - HEADER_SIZE;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Encode a complete message (header + payload)
|
|
115
|
+
*/
|
|
116
|
+
export function encodeMessage(
|
|
117
|
+
sequenceNumber: number,
|
|
118
|
+
sessionId: bigint,
|
|
119
|
+
lastReceivedSequenceNumber: number,
|
|
120
|
+
messageType: MessageType,
|
|
121
|
+
payload?: Uint8Array
|
|
122
|
+
): Uint8Array {
|
|
123
|
+
const payloadSize = payload?.length ?? 0;
|
|
124
|
+
const header = encodeHeader(
|
|
125
|
+
payloadSize,
|
|
126
|
+
sequenceNumber,
|
|
127
|
+
sessionId,
|
|
128
|
+
lastReceivedSequenceNumber,
|
|
129
|
+
messageType
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (payloadSize === 0) {
|
|
133
|
+
return header;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Combine header and payload
|
|
137
|
+
const message = new Uint8Array(HEADER_SIZE + payloadSize);
|
|
138
|
+
message.set(header, 0);
|
|
139
|
+
message.set(payload!, HEADER_SIZE);
|
|
140
|
+
return message;
|
|
141
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federate Protocol Message Types
|
|
3
|
+
*
|
|
4
|
+
* These message types are defined in the IEEE 1516-2025 Federate Protocol specification.
|
|
5
|
+
* They are used in the message header to indicate the type of message being sent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export enum MessageType {
|
|
9
|
+
// Session Management (Control Messages)
|
|
10
|
+
/** Client → Server: Request to create a new session */
|
|
11
|
+
CTRL_NEW_SESSION = 1,
|
|
12
|
+
/** Server → Client: Response with session creation status */
|
|
13
|
+
CTRL_NEW_SESSION_STATUS = 2,
|
|
14
|
+
/** Bidirectional: Keep-alive ping */
|
|
15
|
+
CTRL_HEARTBEAT = 3,
|
|
16
|
+
/** Bidirectional: Keep-alive pong */
|
|
17
|
+
CTRL_HEARTBEAT_RESPONSE = 4,
|
|
18
|
+
/** Client → Server: Request to terminate session */
|
|
19
|
+
CTRL_TERMINATE_SESSION = 5,
|
|
20
|
+
/** Server → Client: Confirmation of session termination */
|
|
21
|
+
CTRL_SESSION_TERMINATED = 6,
|
|
22
|
+
|
|
23
|
+
// Reconnection
|
|
24
|
+
/** Client → Server: Request to resume a dropped session */
|
|
25
|
+
CTRL_RESUME_REQUEST = 10,
|
|
26
|
+
/** Server → Client: Response with resume status */
|
|
27
|
+
CTRL_RESUME_STATUS = 11,
|
|
28
|
+
|
|
29
|
+
// HLA Calls and Callbacks
|
|
30
|
+
/** Client → Server: HLA RTI service call (protobuf CallRequest) */
|
|
31
|
+
HLA_CALL_REQUEST = 20,
|
|
32
|
+
/** Server → Client: HLA RTI service response (protobuf CallResponse) */
|
|
33
|
+
HLA_CALL_RESPONSE = 21,
|
|
34
|
+
/** Server → Client: HLA callback to federate (protobuf CallbackRequest) */
|
|
35
|
+
HLA_CALLBACK_REQUEST = 22,
|
|
36
|
+
/** Client → Server: HLA callback acknowledgment (protobuf CallbackResponse) */
|
|
37
|
+
HLA_CALLBACK_RESPONSE = 23,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if a message type is a control message (session management)
|
|
42
|
+
*/
|
|
43
|
+
export function isControlMessage(type: MessageType): boolean {
|
|
44
|
+
return type < MessageType.HLA_CALL_REQUEST;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a message type is an HLA response
|
|
49
|
+
*/
|
|
50
|
+
export function isHlaResponse(type: MessageType): boolean {
|
|
51
|
+
return (
|
|
52
|
+
type === MessageType.HLA_CALL_RESPONSE ||
|
|
53
|
+
type === MessageType.HLA_CALLBACK_RESPONSE
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get a human-readable name for a message type
|
|
59
|
+
*/
|
|
60
|
+
export function messageTypeName(type: MessageType): string {
|
|
61
|
+
return MessageType[type] ?? `UNKNOWN(${type})`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TCP Transport Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides a plain TCP connection for the Federate Protocol.
|
|
5
|
+
* Note: This is unencrypted and should only be used for local development/testing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { connect, type Socket, type SocketHandler } from "bun";
|
|
9
|
+
import { FrameDecoder } from "./frame-decoder.ts";
|
|
10
|
+
import { Ports, Protocol } from "./constants.ts";
|
|
11
|
+
import type {
|
|
12
|
+
Transport,
|
|
13
|
+
TransportEvents,
|
|
14
|
+
TransportOptions,
|
|
15
|
+
ReceivedMessage,
|
|
16
|
+
} from "./transport.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Plain TCP Transport for Federate Protocol
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ WARNING: This transport is unencrypted. Use TlsTransport for production.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* const transport = new TcpTransport({
|
|
26
|
+
* host: 'localhost',
|
|
27
|
+
* port: 15164,
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* await transport.connect();
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class TcpTransport implements Transport {
|
|
34
|
+
private readonly options: Required<TransportOptions>;
|
|
35
|
+
private socket: Socket<{ decoder: FrameDecoder }> | null = null;
|
|
36
|
+
private events: TransportEvents = {};
|
|
37
|
+
private decoder: FrameDecoder;
|
|
38
|
+
private connected = false;
|
|
39
|
+
|
|
40
|
+
constructor(options: TransportOptions) {
|
|
41
|
+
this.options = {
|
|
42
|
+
host: options.host,
|
|
43
|
+
port: options.port ?? Ports.TCP,
|
|
44
|
+
connectionTimeout: options.connectionTimeout ?? 10000,
|
|
45
|
+
noDelay: options.noDelay ?? true,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
this.decoder = new FrameDecoder();
|
|
49
|
+
this.decoder.onMessage = (msg) => this.handleMessage(msg);
|
|
50
|
+
this.decoder.onError = (err) => this.handleError(err);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async connect(): Promise<void> {
|
|
54
|
+
if (this.socket) {
|
|
55
|
+
throw new Error("Already connected");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const decoder = this.decoder;
|
|
59
|
+
const transport = this;
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timeoutId = setTimeout(() => {
|
|
63
|
+
reject(new Error(`Connection timeout after ${this.options.connectionTimeout}ms`));
|
|
64
|
+
}, this.options.connectionTimeout);
|
|
65
|
+
|
|
66
|
+
const socketHandler: SocketHandler<{ decoder: FrameDecoder }> = {
|
|
67
|
+
data(_socket, data) {
|
|
68
|
+
decoder.push(new Uint8Array(data));
|
|
69
|
+
},
|
|
70
|
+
open(_socket) {
|
|
71
|
+
clearTimeout(timeoutId);
|
|
72
|
+
transport.connected = true;
|
|
73
|
+
resolve();
|
|
74
|
+
},
|
|
75
|
+
close(_socket) {
|
|
76
|
+
transport.connected = false;
|
|
77
|
+
transport.socket = null;
|
|
78
|
+
transport.events.onClose?.(false);
|
|
79
|
+
},
|
|
80
|
+
error(_socket, error) {
|
|
81
|
+
clearTimeout(timeoutId);
|
|
82
|
+
transport.connected = false;
|
|
83
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
84
|
+
transport.events.onError?.(err);
|
|
85
|
+
reject(err);
|
|
86
|
+
},
|
|
87
|
+
connectError(_socket, error) {
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
90
|
+
reject(err);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
connect({
|
|
95
|
+
hostname: this.options.host,
|
|
96
|
+
port: this.options.port,
|
|
97
|
+
socket: socketHandler,
|
|
98
|
+
data: { decoder },
|
|
99
|
+
})
|
|
100
|
+
.then((socket) => {
|
|
101
|
+
this.socket = socket;
|
|
102
|
+
})
|
|
103
|
+
.catch(reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async disconnect(): Promise<void> {
|
|
108
|
+
if (this.socket) {
|
|
109
|
+
this.socket.end();
|
|
110
|
+
this.socket = null;
|
|
111
|
+
this.connected = false;
|
|
112
|
+
this.decoder.reset();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
isConnected(): boolean {
|
|
117
|
+
return this.connected && this.socket !== null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async send(data: Uint8Array): Promise<void> {
|
|
121
|
+
if (!this.socket || !this.connected) {
|
|
122
|
+
throw new Error("Not connected");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const written = this.socket.write(data);
|
|
126
|
+
if (written < data.length) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Failed to write all data: wrote ${written} of ${data.length} bytes`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setEventHandlers(handlers: TransportEvents): void {
|
|
134
|
+
this.events = handlers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getProtocolName(): string {
|
|
138
|
+
return Protocol.TCP;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getRemoteAddress(): string | null {
|
|
142
|
+
return this.socket
|
|
143
|
+
? `${this.options.host}:${this.options.port}`
|
|
144
|
+
: null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private handleMessage(message: ReceivedMessage): void {
|
|
148
|
+
this.events.onMessage?.(message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private handleError(error: Error): void {
|
|
152
|
+
this.events.onError?.(error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS Transport Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides a TLS-encrypted TCP connection for the Federate Protocol.
|
|
5
|
+
* Uses Bun's built-in TLS support.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { connect, type Socket, type SocketHandler, type TLSOptions } from "bun";
|
|
9
|
+
import { FrameDecoder } from "./frame-decoder.ts";
|
|
10
|
+
import { Ports, Protocol } from "./constants.ts";
|
|
11
|
+
import type {
|
|
12
|
+
Transport,
|
|
13
|
+
TransportEvents,
|
|
14
|
+
TlsOptions,
|
|
15
|
+
ReceivedMessage,
|
|
16
|
+
} from "./transport.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* TLS Transport for Federate Protocol
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const transport = new TlsTransport({
|
|
24
|
+
* host: 'localhost',
|
|
25
|
+
* port: 15165,
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* transport.setEventHandlers({
|
|
29
|
+
* onMessage: (msg) => console.log('Received:', msg.header.messageType),
|
|
30
|
+
* onClose: () => console.log('Disconnected'),
|
|
31
|
+
* onError: (err) => console.error('Error:', err),
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* await transport.connect();
|
|
35
|
+
* await transport.send(someData);
|
|
36
|
+
* await transport.disconnect();
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export class TlsTransport implements Transport {
|
|
40
|
+
private readonly options: Required<
|
|
41
|
+
Pick<TlsOptions, "host" | "port" | "connectionTimeout" | "noDelay">
|
|
42
|
+
> &
|
|
43
|
+
Omit<TlsOptions, "host" | "port" | "connectionTimeout" | "noDelay">;
|
|
44
|
+
|
|
45
|
+
private socket: Socket<{ decoder: FrameDecoder }> | null = null;
|
|
46
|
+
private events: TransportEvents = {};
|
|
47
|
+
private decoder: FrameDecoder;
|
|
48
|
+
private connected = false;
|
|
49
|
+
|
|
50
|
+
constructor(options: TlsOptions) {
|
|
51
|
+
this.options = {
|
|
52
|
+
host: options.host,
|
|
53
|
+
port: options.port ?? Ports.TLS,
|
|
54
|
+
connectionTimeout: options.connectionTimeout ?? 10000,
|
|
55
|
+
noDelay: options.noDelay ?? true,
|
|
56
|
+
ca: options.ca,
|
|
57
|
+
cert: options.cert,
|
|
58
|
+
key: options.key,
|
|
59
|
+
rejectUnauthorized: options.rejectUnauthorized ?? true,
|
|
60
|
+
serverName: options.serverName,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
this.decoder = new FrameDecoder();
|
|
64
|
+
this.decoder.onMessage = (msg) => this.handleMessage(msg);
|
|
65
|
+
this.decoder.onError = (err) => this.handleError(err);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async connect(): Promise<void> {
|
|
69
|
+
if (this.socket) {
|
|
70
|
+
throw new Error("Already connected");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const decoder = this.decoder;
|
|
74
|
+
const transport = this;
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const timeoutId = setTimeout(() => {
|
|
78
|
+
reject(new Error(`Connection timeout after ${this.options.connectionTimeout}ms`));
|
|
79
|
+
}, this.options.connectionTimeout);
|
|
80
|
+
|
|
81
|
+
const socketHandler: SocketHandler<{ decoder: FrameDecoder }> = {
|
|
82
|
+
data(_socket, data) {
|
|
83
|
+
decoder.push(new Uint8Array(data));
|
|
84
|
+
},
|
|
85
|
+
open(_socket) {
|
|
86
|
+
clearTimeout(timeoutId);
|
|
87
|
+
transport.connected = true;
|
|
88
|
+
resolve();
|
|
89
|
+
},
|
|
90
|
+
close(_socket) {
|
|
91
|
+
transport.connected = false;
|
|
92
|
+
transport.socket = null;
|
|
93
|
+
transport.events.onClose?.(false);
|
|
94
|
+
},
|
|
95
|
+
error(_socket, error) {
|
|
96
|
+
clearTimeout(timeoutId);
|
|
97
|
+
transport.connected = false;
|
|
98
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
99
|
+
transport.events.onError?.(err);
|
|
100
|
+
reject(err);
|
|
101
|
+
},
|
|
102
|
+
connectError(_socket, error) {
|
|
103
|
+
clearTimeout(timeoutId);
|
|
104
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
105
|
+
reject(err);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Build TLS options
|
|
110
|
+
const tlsConfig: TLSOptions = {
|
|
111
|
+
rejectUnauthorized: this.options.rejectUnauthorized,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Add optional TLS settings
|
|
115
|
+
if (this.options.ca) {
|
|
116
|
+
tlsConfig.ca = Bun.file(this.options.ca);
|
|
117
|
+
}
|
|
118
|
+
if (this.options.cert) {
|
|
119
|
+
tlsConfig.cert = Bun.file(this.options.cert);
|
|
120
|
+
}
|
|
121
|
+
if (this.options.key) {
|
|
122
|
+
tlsConfig.key = Bun.file(this.options.key);
|
|
123
|
+
}
|
|
124
|
+
if (this.options.serverName) {
|
|
125
|
+
tlsConfig.serverName = this.options.serverName;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
connect({
|
|
129
|
+
hostname: this.options.host,
|
|
130
|
+
port: this.options.port,
|
|
131
|
+
socket: socketHandler,
|
|
132
|
+
data: { decoder },
|
|
133
|
+
tls: tlsConfig,
|
|
134
|
+
})
|
|
135
|
+
.then((socket) => {
|
|
136
|
+
this.socket = socket;
|
|
137
|
+
})
|
|
138
|
+
.catch(reject);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async disconnect(): Promise<void> {
|
|
143
|
+
if (this.socket) {
|
|
144
|
+
this.socket.end();
|
|
145
|
+
this.socket = null;
|
|
146
|
+
this.connected = false;
|
|
147
|
+
this.decoder.reset();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
isConnected(): boolean {
|
|
152
|
+
return this.connected && this.socket !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async send(data: Uint8Array): Promise<void> {
|
|
156
|
+
if (!this.socket || !this.connected) {
|
|
157
|
+
throw new Error("Not connected");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const written = this.socket.write(data);
|
|
161
|
+
if (written < data.length) {
|
|
162
|
+
// Bun's socket.write returns the number of bytes written
|
|
163
|
+
// If not all bytes were written, we need to wait for drain
|
|
164
|
+
// For simplicity, we'll throw an error here
|
|
165
|
+
// In production, you'd want to buffer and retry
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Failed to write all data: wrote ${written} of ${data.length} bytes`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setEventHandlers(handlers: TransportEvents): void {
|
|
173
|
+
this.events = handlers;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getProtocolName(): string {
|
|
177
|
+
return Protocol.TLS;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getRemoteAddress(): string | null {
|
|
181
|
+
return this.socket
|
|
182
|
+
? `${this.options.host}:${this.options.port}`
|
|
183
|
+
: null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private handleMessage(message: ReceivedMessage): void {
|
|
187
|
+
this.events.onMessage?.(message);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private handleError(error: Error): void {
|
|
191
|
+
this.events.onError?.(error);
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport Interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for Federate Protocol transports (TCP, TLS, WebSocket).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MessageHeader } from "./message-header.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Transport connection options
|
|
11
|
+
*/
|
|
12
|
+
export interface TransportOptions {
|
|
13
|
+
/** Server hostname or IP address */
|
|
14
|
+
host: string;
|
|
15
|
+
/** Server port (defaults to Ports.TCP for TCP, Ports.TLS for TLS) */
|
|
16
|
+
port?: number;
|
|
17
|
+
/** Connection timeout in milliseconds */
|
|
18
|
+
connectionTimeout?: number;
|
|
19
|
+
/** Enable TCP_NODELAY (disable Nagle's algorithm) */
|
|
20
|
+
noDelay?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* TLS-specific options
|
|
25
|
+
*/
|
|
26
|
+
export interface TlsOptions extends TransportOptions {
|
|
27
|
+
/** Path to CA certificate file (for server verification) */
|
|
28
|
+
ca?: string;
|
|
29
|
+
/** Path to client certificate file (for mutual TLS) */
|
|
30
|
+
cert?: string;
|
|
31
|
+
/** Path to client private key file (for mutual TLS) */
|
|
32
|
+
key?: string;
|
|
33
|
+
/** Skip server certificate verification (INSECURE - for testing only) */
|
|
34
|
+
rejectUnauthorized?: boolean;
|
|
35
|
+
/** Server name for SNI */
|
|
36
|
+
serverName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Received message with header and payload
|
|
41
|
+
*/
|
|
42
|
+
export interface ReceivedMessage {
|
|
43
|
+
header: MessageHeader;
|
|
44
|
+
payload: Uint8Array;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Transport event handlers
|
|
49
|
+
*/
|
|
50
|
+
export interface TransportEvents {
|
|
51
|
+
/** Called when a complete message is received */
|
|
52
|
+
onMessage?: (message: ReceivedMessage) => void;
|
|
53
|
+
/** Called when the connection is closed */
|
|
54
|
+
onClose?: (hadError: boolean) => void;
|
|
55
|
+
/** Called when an error occurs */
|
|
56
|
+
onError?: (error: Error) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Transport interface for Federate Protocol communication
|
|
61
|
+
*/
|
|
62
|
+
export interface Transport {
|
|
63
|
+
/**
|
|
64
|
+
* Connect to the server
|
|
65
|
+
* @throws Error if connection fails
|
|
66
|
+
*/
|
|
67
|
+
connect(): Promise<void>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Disconnect from the server
|
|
71
|
+
*/
|
|
72
|
+
disconnect(): Promise<void>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if the transport is connected
|
|
76
|
+
*/
|
|
77
|
+
isConnected(): boolean;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Send raw bytes to the server
|
|
81
|
+
* @param data The data to send
|
|
82
|
+
*/
|
|
83
|
+
send(data: Uint8Array): Promise<void>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set event handlers
|
|
87
|
+
*/
|
|
88
|
+
setEventHandlers(handlers: TransportEvents): void;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the protocol name (tcp, tls, websocket, websocketsecure)
|
|
92
|
+
*/
|
|
93
|
+
getProtocolName(): string;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the remote address
|
|
97
|
+
*/
|
|
98
|
+
getRemoteAddress(): string | null;
|
|
99
|
+
}
|