socketduct 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,558 @@
1
+ # Socketduct wire protocol
2
+
3
+ Socketduct has two separately selected wire protocols. Protocol v3 is the
4
+ multiplexed transport. Protocol v2 remains available unchanged for migration
5
+ and rollback. A listener is configured for one version; there is no ambiguous
6
+ fallback after bytes have been sent.
7
+
8
+ ## Multiplexed protocol version 3
9
+
10
+ V3 carries many independent virtual byte streams on one authenticated TCP or
11
+ TLS connection. The physical connection performs TLS and bearer authentication
12
+ once. Stream ID `0` is reserved for connection control; client-created stream
13
+ IDs are monotonically allocated odd uint32 values and are never reused during a
14
+ connection identity's lifetime.
15
+
16
+ ### Binary frame
17
+
18
+ Every frame has a 24-byte big-endian header followed by its payload:
19
+
20
+ ```text
21
+ offset size field
22
+ 0 4 ASCII magic "SDV3"
23
+ 4 1 version (3)
24
+ 5 1 frame type
25
+ 6 2 flags
26
+ 8 4 stream ID
27
+ 12 8 sequence (restricted to JavaScript safe integers)
28
+ 20 4 payload length
29
+ 24 N payload
30
+ ```
31
+
32
+ Unknown types, bad magic/version, unsafe sequences, stream/control ID
33
+ confusion, oversized payloads, non-object control JSON, gaps, overlaps and
34
+ invalid acknowledgements fail loudly. Control JSON and DATA frames are bounded
35
+ independently. Ordinary v3 relay connections retain raw DATA. Native reverse
36
+ connections negotiate zstd and reserve DATA flag `0x0001`; no ordinary-v3 peer
37
+ emits it and ordinary handlers reject it as unnegotiated.
38
+
39
+ ### Connection control (stream 0)
40
+
41
+ - `AUTH` / `AUTH_OK` / `AUTH_ERROR`: negotiate exactly version 3 and
42
+ authenticate a stable connection identity. No stream operation is accepted
43
+ before `AUTH_OK`.
44
+ - `PING` / `PONG`: connection heartbeat and readiness.
45
+ - `RESUME` / `RESUMED`: bounded pages of stream manifests. Each entry contains
46
+ stream ID, stable identifier and both directional acknowledgement positions.
47
+ - `GOAWAY`: terminal physical-connection shutdown. Authentication,
48
+ authorization and TLS failures are terminal and are not retried forever.
49
+ - `GATEWAY_REGISTER` / `GATEWAY_REGISTERED`: reverse-only authenticated stable
50
+ gateway registration and generation fencing.
51
+ - `GATEWAY_SELECT` / `GATEWAY_SELECTED`: reverse-only binding of a client
52
+ physical member to one currently registered gateway before readiness.
53
+
54
+ ### Stream control (nonzero stream ID)
55
+
56
+ - `OPEN` / `OPENED`: stable opaque identifier, human-readable name, exact
57
+ target and initial receive window. The relay authorizes the target for every
58
+ OPEN before creating its one local target socket.
59
+ - `DATA`: raw bytes with a cumulative end sequence.
60
+ - `ACK`: cumulative committed/delivered sequence; acknowledged replay bytes are
61
+ removed from the spool and never replayed.
62
+ - `WINDOW`: explicit additional receive credit when needed.
63
+ - `END`: directional half-close.
64
+ - `CLOSE`: clean terminal close. The receiver echoes `CLOSE` after delivery;
65
+ the sender retains the stream until that terminal acknowledgement arrives,
66
+ so cleanup cannot race the last bytes or leak relay stream capacity.
67
+ - `RESET`: stream-local error/cancellation. Resetting one stream never closes a
68
+ sibling or the shared physical transport.
69
+
70
+ Each direction has an independent per-stream window. Send/replay bytes are
71
+ bounded per stream and in aggregate per connection. Control frames have
72
+ priority; DATA frames use round-robin scheduling with bounded frame sizes, so a
73
+ large upload cannot monopolize the physical connection.
74
+
75
+ Flow-control credit and scheduler position belong to one physical generation.
76
+ After both peers reconcile cumulative durable acknowledgements in RESUME, each
77
+ surviving stream begins the replacement generation with a fresh negotiated
78
+ window and replay is rebuilt strictly above the reconciled ACK. Credit consumed
79
+ by a failed transport is never carried forward.
80
+
81
+ Relay retention is bounded independently per stream, per connection identity,
82
+ and process-wide. The process-wide reservation is owned by the relay runtime
83
+ and is released idempotently on acknowledgement, reset, close, error or expiry.
84
+
85
+ ### Physical reconnect and resume
86
+
87
+ One `MultiplexedSocketductConnection` owns one connect/auth promise and one
88
+ reconnect timer. Concurrent first opens share that promise. After a transport
89
+ drop, active streams remain represented by their stable identifiers and spool
90
+ state while exactly one new physical generation authenticates. The client sends
91
+ the paginated resume manifest; the relay answers per stream and replays only
92
+ bytes above the peer's acknowledgement. A missing target or invalid resume
93
+ resets only that stream with a typed session-loss error. Streams locally closed
94
+ during the outage are omitted and the relay closes their retained targets.
95
+ The connect/authentication deadline remains active until `AUTH_OK`; a peer that
96
+ accepts TCP/TLS and stays silent cannot retain the shared open promise.
97
+ Replacement objects advertise only the durable receive acknowledgement because
98
+ they do not own unread bytes buffered by the previous `VirtualSocket`; a
99
+ physical reconnect on the same object may acknowledge its still-owned readable
100
+ buffer. A durable `OPEN` without `OPENED` is resumed and reconciled by the
101
+ relay's per-stream result rather than being sent again.
102
+
103
+ ### Native reverse-gateway extension
104
+
105
+ Reverse mode reuses the v3 physical transport and stream frames directly. A
106
+ home gateway registers outbound; a Server3 client selects it. Client streams
107
+ are odd and the hub maps them to even gateway-side IDs. The hub authorizes the
108
+ principal/gateway/target tuple and the gateway separately authorizes its local
109
+ target. Registration reconnect uses a higher generation, but any established
110
+ stream is reset on gateway loss and never replayed.
111
+
112
+ Stream IDs must increase monotonically on each physical connection. END and
113
+ CLOSE positions must match the sender's accepted raw-byte sequence; a
114
+ gateway-originated CLOSE is retained until the client acknowledges it with
115
+ CLOSE. RESET is immediately terminal. Repeats, data after END, and stale ACKs
116
+ are protocol violations.
117
+
118
+ Reverse peers must negotiate `zstd` before selection/registration completes.
119
+ A compressed DATA payload is `uint32_be(uncompressedLength)` followed by one
120
+ independent zstd frame and carries flag `0x0001`. Raw DATA carries flag zero.
121
+ Sequences, acknowledgements, windows, and all buffer limits count uncompressed
122
+ bytes. Invalid metadata, unavailable codecs, decompression bombs, or corrupt
123
+ payloads terminate the physical connection. TLS certificate verification is
124
+ mandatory for every reverse client and gateway transport. The complete role
125
+ contract is in [Native reverse gateway](reverse-gateway.md).
126
+
127
+ ## Legacy protocol version 2
128
+
129
+ V2 starts every connection with the length-prefixed JSON envelope
130
+ described below. That envelope remains the bootstrap format, but authenticated
131
+ sessions require compact framing version 2 for all stream records.
132
+
133
+ ## Compact framing version 2
134
+
135
+ The client must add `"framing":[2]` to the length-prefixed `session.create` or
136
+ `session.resume` header. The relay must reject an absent offer or `"framing":[1]`
137
+ with `PROTOCOL_VIOLATION` before authentication and before creating or resuming
138
+ a session. Every successful `session.created` and `session.resumed` response
139
+ contains `"framing":2`; the client treats any response without that exact value
140
+ as a failed handshake. The offer and selection are connection-local and are
141
+ repeated on resume. There is no older framing fallback.
142
+
143
+ After selection, every `stream.data` and `stream.ack` uses a compact record.
144
+ Bootstrap, error, lifecycle (including `session.close`), and other control
145
+ messages retain the length-prefixed JSON envelope because compact v2 has no
146
+ equivalent record for them. A compact record starts with a byte whose high bit
147
+ is set, while a valid JSON-envelope header length
148
+ starts with a zero byte under the 64 KiB header limit, so controls and compact
149
+ records can be mixed in one TCP byte stream.
150
+
151
+ After a successful V2 handshake, both peers reject any length-prefixed
152
+ `stream.data` or `stream.ack` input as a protocol violation. Parsers use the
153
+ decoded `header.compact` marker to enforce this at the authenticated session
154
+ boundary while continuing to accept length-prefixed control, lifecycle, and
155
+ error messages.
156
+
157
+ ```text
158
+ 0x80 varint(endSequence) varint(payloadLength) rawPayload
159
+ 0x81 varint(endSequence) varint(uncompressedLength) varint(payloadLength) zstdPayload
160
+ 0x82 varint(ackSequence)
161
+ ```
162
+
163
+ Integers are canonical unsigned LEB128 and must be JavaScript safe integers.
164
+ Overlong encodings, overflow, truncated records, and payload lengths above the
165
+ configured frame limit are protocol violations. Direction and session are
166
+ implicit in the authenticated connection: data travels toward the receiving
167
+ endpoint and an ACK acknowledges the opposite data direction. Decoders
168
+ normalize these records into the same internal headers used by the envelope decoder, so zstd
169
+ negotiation and expansion checks, cumulative uncompressed byte sequences,
170
+ ACKs, replay retention, and raw spool storage have identical semantics.
171
+
172
+ ## No-delay coalescing
173
+
174
+ A sole application write, or a sole chunk read from the relay's target socket,
175
+ begins spool/compression/send work immediately.
176
+ Socketduct does not use a timer, debounce, immediate, or microtask to wait for
177
+ more data. If Node has already queued several application writes behind
178
+ pre-existing async spool, compression, send, or drain work, the edge may merge
179
+ those queued original bytes in order. Likewise, the relay may merge adjacent
180
+ raw target chunks only when they are already waiting behind its per-session
181
+ compression/send/drain queue. The first target chunk entering an idle queue is
182
+ not held. Each merged record is bounded by the configured maximum decompressed
183
+ size. Close, error, replay, ACK retention, and spool ordering use the same
184
+ serialized pipelines as unmerged writes; cumulative sequences always count
185
+ the exact uncompressed bytes.
186
+
187
+ ## Requirements
188
+
189
+ The protocol must support:
190
+
191
+ - authentication before session operations
192
+ - session creation with `name`, `identifier`, and authorized `target`
193
+ - session resume by `identifier`
194
+ - ordered byte-stream transfer
195
+ - acknowledgements
196
+ - reconnection after short interruptions
197
+ - replay from temporary spool files
198
+ - clear failures when resume is impossible
199
+
200
+ ## Framing
201
+
202
+ Use a length-prefixed frame format rather than raw JSON lines.
203
+
204
+ A practical MVP can use:
205
+
206
+ ```text
207
+ uint32_be headerLength
208
+ json header
209
+ bytes payload
210
+ ```
211
+
212
+ Header fields:
213
+
214
+ ```json
215
+ {
216
+ "version": 1,
217
+ "type": "stream.data",
218
+ "session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
219
+ "sequence": 12345,
220
+ "payloadLength": 4096
221
+ }
222
+ ```
223
+
224
+ Later, this can move to a binary header if profiling shows JSON headers matter.
225
+
226
+ ## Authentication flow
227
+
228
+ ```text
229
+ client -> server: auth.login
230
+ server -> client: auth.ok | auth.error
231
+ ```
232
+
233
+ Request:
234
+
235
+ ```json
236
+ {
237
+ "type": "auth.login",
238
+ "username": "kasper",
239
+ "password": "<redacted>",
240
+ "clientName": "office-edge"
241
+ }
242
+ ```
243
+
244
+ Response:
245
+
246
+ ```json
247
+ {
248
+ "type": "auth.ok",
249
+ "token": "<opaque-session-token>",
250
+ "expiresAt": "2026-07-07T12:00:00.000Z"
251
+ }
252
+ ```
253
+
254
+ After login, reconnects should prefer token auth:
255
+
256
+ ```json
257
+ {
258
+ "type": "auth.resume",
259
+ "token": "<opaque-session-token>"
260
+ }
261
+ ```
262
+
263
+ ## Session create
264
+
265
+ ```text
266
+ client -> server: session.create
267
+ server -> client: session.created | error
268
+ ```
269
+
270
+ Request:
271
+
272
+ ```json
273
+ {
274
+ "type": "session.create",
275
+ "name": "work-ssh",
276
+ "identifier": null,
277
+ "target": {
278
+ "host": "10.0.0.15",
279
+ "port": 22
280
+ },
281
+ "framing": [2]
282
+ }
283
+ ```
284
+
285
+ Response:
286
+
287
+ ```json
288
+ {
289
+ "type": "session.created",
290
+ "identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
291
+ "name": "work-ssh",
292
+ "state": "active",
293
+ "target": {
294
+ "host": "10.0.0.15",
295
+ "port": 22
296
+ },
297
+ "framing": 2
298
+ }
299
+ ```
300
+
301
+ The client may propose an identifier, but the server must verify that it is valid, owned by the user, and not already active for another incompatible session. The server must also validate `target` before creating the session. The relay is not configured with one static upstream target; it applies an explicit allowlist policy such as `authorizeTarget({principal, target})` for each create request.
302
+
303
+ After authentication and authorization, the server stores the session in `creating` but withholds `session.created` until target TCP connect succeeds. Initial connection refusal is not a wire error: the relay retries at a bounded exponential rate while the authenticated tunnel remains open. Compact stream records received before readiness are a terminal `PROTOCOL_VIOLATION`. If the tunnel closes before the response is announced, the relay deletes the creating session immediately, so it cannot be resumed.
304
+
305
+ `session.created` is the safety boundary. Before it, the relay has accepted no application bytes and may retry target acquisition. After it, target failure or write uncertainty is terminal and Socketduct does not reconnect the target or replay the application operation.
306
+
307
+ ## Session resume
308
+
309
+ ```text
310
+ client -> server: session.resume
311
+ server -> client: session.resumed | error
312
+ client -> server: spool.manifest
313
+ client -> server: stream.data frames for unacknowledged chunks
314
+ ```
315
+
316
+ Request:
317
+
318
+ ```json
319
+ {
320
+ "type": "session.resume",
321
+ "identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
322
+ "name": "work-ssh",
323
+ "clientLastSequence": 180224,
324
+ "clientLastAck": 171520,
325
+ "framing": [2]
326
+ }
327
+ ```
328
+
329
+ Response:
330
+
331
+ ```json
332
+ {
333
+ "type": "session.resumed",
334
+ "identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
335
+ "serverLastSequence": 99120,
336
+ "serverLastAck": 171520,
337
+ "replayFromSequence": 171521,
338
+ "target": {
339
+ "host": "10.0.0.15",
340
+ "port": 22
341
+ },
342
+ "framing": 2
343
+ }
344
+ ```
345
+
346
+ The server tells the client which sequence to replay from. The edge then sends the relevant spooled data.
347
+
348
+ ## Stream data
349
+
350
+ ```json
351
+ {
352
+ "type": "stream.data",
353
+ "session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
354
+ "direction": "client-to-target",
355
+ "sequence": 171521,
356
+ "payloadLength": 4096
357
+ }
358
+ ```
359
+
360
+ The payload bytes follow the header.
361
+
362
+ ## Stream data compression
363
+
364
+ `stream.data` payloads may be compressed. Nothing else is: authentication,
365
+ session and other control frames are always sent raw.
366
+
367
+ ### Negotiation
368
+
369
+ Compression is opt-in per connection and rides on the existing handshake frames,
370
+ because unknown header fields are ignored by both peers while unknown frame
371
+ types are a protocol violation.
372
+
373
+ The client advertises the codecs it can use on `session.create` and
374
+ `session.resume`:
375
+
376
+ ```json
377
+ {
378
+ "type": "session.create",
379
+ "token": "<redacted>",
380
+ "name": "office-edge",
381
+ "target": {"host": "10.0.0.15", "port": 22},
382
+ "compression": ["zstd"]
383
+ }
384
+ ```
385
+
386
+ The relay answers on `session.created` / `session.resumed` with the single codec
387
+ it selected, or omits the field entirely:
388
+
389
+ ```json
390
+ {
391
+ "type": "session.created",
392
+ "identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
393
+ "compression": "zstd"
394
+ }
395
+ ```
396
+
397
+ If compression is omitted, stream payloads are uncompressed but still use
398
+ compact framing v2. Compression negotiation is redone on every reconnect, so a
399
+ resumed session may land on a different codec (or none) than it started with.
400
+
401
+ ### Frame metadata
402
+
403
+ A compressed compact `stream.data` record carries two extra normalized header
404
+ fields. Both are absent on an uncompressed compact record.
405
+
406
+ ```json
407
+ {
408
+ "type": "stream.data",
409
+ "direction": "target-to-client",
410
+ "sequence": 171521,
411
+ "encoding": "zstd",
412
+ "dataLength": 4096
413
+ }
414
+ ```
415
+
416
+ - `encoding` — the negotiated codec. Absent means the payload is uncompressed.
417
+ - `dataLength` — the **uncompressed** byte length. Required whenever `encoding`
418
+ is present.
419
+
420
+ Each frame is an independent zstd frame with no shared dictionary or window, so
421
+ any frame can be decoded on its own. That is what keeps reconnect, resume and
422
+ replay correct: spools and relay replay buffers store **raw** chunks, and a
423
+ replayed chunk is re-encoded under whatever codec the current connection
424
+ negotiated.
425
+
426
+ `sequence`, `ackSequence`, spool offsets, byte limits and backpressure are all
427
+ counted in **uncompressed** bytes. `dataLength`, not the wire payload length, is
428
+ what the relay's contiguity check (`sequence == previous + length`) uses.
429
+
430
+ ### Validation
431
+
432
+ A receiver fails closed. Any of the following is a `PROTOCOL_VIOLATION` that
433
+ ends the connection; a frame that cannot be decoded is never written to the
434
+ target, and there is no fallback to treating it as raw:
435
+
436
+ - `encoding` present but not the codec negotiated for this connection.
437
+ - `dataLength` missing, not a positive safe integer, or above the configured
438
+ maximum.
439
+ - `dataLength` present without an `encoding`.
440
+ - An uncompressed payload above the configured maximum `stream.data` size.
441
+ - An expansion ratio (`dataLength` / compressed size) above the configured
442
+ maximum.
443
+ - A payload that fails to decompress, or whose decompressed size does not
444
+ exactly equal the advertised `dataLength`.
445
+
446
+ Decompression is bounded by the advertised `dataLength`, so a compression bomb
447
+ can never produce more bytes than the sender promised.
448
+
449
+ ### Send policy
450
+
451
+ Compression is adaptive and entirely send-side; a receiver needs no knowledge of
452
+ it, because every frame declares its own encoding.
453
+
454
+ - Payloads below `SOCKETDUCT_MIN_COMPRESS_BYTES` (default 256) are sent raw.
455
+ - The compressed form is kept only if it actually saves bytes, so a frame never
456
+ grows.
457
+ - Streams that do not compress (TLS, media, already-compressed data) stop being
458
+ compressed after a few probes and are re-probed periodically.
459
+
460
+ ### Runtime support
461
+
462
+ zstd is provided by `node:zlib`, which only exposes it from Node 22.15 onward,
463
+ while `engines` accepts Node >=22.0. Support is therefore feature-detected at
464
+ runtime: a peer whose Node lacks zstd advertises no codec and the tunnel runs
465
+ raw. No runtime dependency is added.
466
+
467
+ ## Acknowledgements
468
+
469
+ ```json
470
+ {
471
+ "type": "stream.ack",
472
+ "session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
473
+ "direction": "client-to-target",
474
+ "ackSequence": 175616
475
+ }
476
+ ```
477
+
478
+ Acked chunks can be truncated from the spool file.
479
+
480
+ ## Session close
481
+
482
+ `session.close` remains an envelope control frame. A relay response confirming
483
+ that it processed a client-requested logical close includes
484
+ `"acknowledged": true`; a target-originated terminal close omits that field.
485
+ Clients must not treat an unacknowledged terminal notification already in
486
+ flight as confirmation of a later close request. A transport EOF is not a
487
+ logical close and retains the announced session for normal resume.
488
+ If the application requests a logical close while the relay transport is
489
+ interrupted, the client first reconnects, authenticates, and receives
490
+ `session.resumed`; only then does it send `session.close`. No application data
491
+ is replayed by this close-only reconnect.
492
+
493
+ ## Backpressure
494
+
495
+ The protocol should allow explicit pause/resume:
496
+
497
+ ```json
498
+ {
499
+ "type": "stream.pause",
500
+ "session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
501
+ "direction": "client-to-target",
502
+ "reason": "remote-buffer-full"
503
+ }
504
+ ```
505
+
506
+ ```json
507
+ {
508
+ "type": "stream.resume",
509
+ "session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
510
+ "direction": "client-to-target"
511
+ }
512
+ ```
513
+
514
+ ## Error handling
515
+
516
+ Errors should be explicit and machine-readable:
517
+
518
+ ```json
519
+ {
520
+ "type": "error",
521
+ "code": "SESSION_NOT_FOUND",
522
+ "message": "Session does not exist or is not owned by this user.",
523
+ "retryable": false
524
+ }
525
+ ```
526
+
527
+ Useful error codes:
528
+
529
+ - `AUTH_REQUIRED`
530
+ - `AUTH_FAILED`
531
+ - `SESSION_NOT_FOUND`
532
+ - `SESSION_ALREADY_ACTIVE`
533
+ - `SESSION_EXPIRED`
534
+ - `REPLAY_TOO_OLD`
535
+ - `SPOOL_TOO_LARGE`
536
+ - `TARGET_CLOSED`
537
+ - `TARGET_BUFFER_OVERFLOW`
538
+ - `TARGET_UNAUTHORIZED`
539
+ - `PROTOCOL_VIOLATION`
540
+ - `HANDSHAKE_TIMEOUT`
541
+
542
+ `TARGET_CLOSED`, `TARGET_BUFFER_OVERFLOW`, spool overflow, authorization errors, and protocol errors are terminal (`retryable:false`). Initial target refusal does not emit `TARGET_UNAVAILABLE` or any other error frame.
543
+
544
+ ## Open protocol decisions
545
+
546
+ - Use TLS-over-TCP, WebSocket, HTTP/2, or QUIC as the first edge-relay transport?
547
+ - Should session identifiers be client-generated UUIDv7 or server-generated random IDs?
548
+ - Should password auth be part of the tunnel protocol, or should the first version use pre-created bearer tokens?
549
+ - How much target-side state should the relay keep after an edge disconnect?
550
+
551
+ ## Transport security
552
+
553
+ The relay supports TLS via Node.js built-in `node:tls`. When configured with `SOCKETDUCT_TLS_CERT_FILE` and `SOCKETDUCT_TLS_KEY_FILE`, the protocol runs over a TLS socket. The mixed wire format (length-prefixed control envelopes and compact V2 stream records) is unchanged regardless of whether the transport is plain TCP or TLS.
554
+
555
+ - Server certificate verification is enabled by default on the client (`rejectUnauthorized: true`).
556
+ - Optional mutual TLS: when `SOCKETDUCT_TLS_CLIENT_CA_FILE` is set, the relay requests and verifies client certificates.
557
+ - The relay fails fast at startup if certificate files are missing or unreadable.
558
+ - Binding to a non-loopback address without TLS is refused unless `SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1` or `true`.
@@ -0,0 +1,18 @@
1
+ # Releasing
2
+
3
+ Socketduct publishes patch releases from a clean, current `master` checkout:
4
+
5
+ ```sh
6
+ npm run release:patch
7
+ ```
8
+
9
+ `release-patch` owns the version bump, lockfile update, annotated tag, atomic
10
+ Git push, npm publication, and registry verification. Do not run `npm version`
11
+ or create release tags manually for ordinary patch releases.
12
+
13
+ Confirm CI is green and `npm run check` passes before releasing. If publication
14
+ fails after the commit and tag are pushed, resume the exact release with:
15
+
16
+ ```sh
17
+ npx release-patch --resume
18
+ ```