socketduct 0.1.20 → 0.1.22
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/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
- package/changelog.d/20260928120416-reverse-gateway-spool-directory.md +3 -0
- package/changelog.d/20260928124500-critical-reserve-bound-test.md +3 -0
- package/docs/logging.md +5 -1
- package/docs/protocol.md +21 -3
- package/docs/reverse-gateway.md +57 -8
- package/docs/running.md +3 -2
- package/docs/session-lifecycle.md +22 -0
- package/docs/socket-api.md +22 -17
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +42 -1
- package/src/client/multiplexed-connection.js +17 -0
- package/src/client/multiplexed-transport-set.js +43 -8
- package/src/client/multiplexed.js +1 -1
- package/src/client/virtual-socket.js +109 -4
- package/src/config/environment.js +5 -1
- package/src/index.js +1 -1
- package/src/reverse/client.js +174 -23
- package/src/reverse/gateway.js +10 -1
- package/src/reverse/runtime.js +1 -0
- package/src/spool/spool-file.js +92 -6
- package/tensorbuzz.yml +1 -1
- package/test/e2e/reverse-client-recovery-process.test.js +3 -0
- package/test/e2e/reverse-gateway-process.test.js +120 -1
- package/test/fixtures/reverse-client-recovery.mjs +14 -2
- package/test/integration/multiplexed-transport-set.test.js +1 -5
- package/test/integration/reverse-gateway.test.js +158 -1
- package/test/unit/client/multiplexed-transport-set.test.js +50 -3
- package/test/unit/client/virtual-socket.test.js +123 -1
- package/test/unit/config/environment.test.js +25 -0
- package/test/unit/index.test.js +2 -0
- package/test/unit/logging/compressed-log-writer.test.js +3 -1
- package/test/unit/spool/spool-file.test.js +58 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Reverse clients can now durably admit complete writes, manually acknowledge application-committed receive bytes, and recover transport-set streams by stable identifier alone.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Outbound reverse gateway spool directory environment variable
|
|
2
|
+
|
|
3
|
+
The standalone outbound reverse gateway runtime now accepts `SOCKETDUCT_SPOOL_DIRECTORY`, which points the gateway's durable target-output spool at an operator-chosen persistent path. The default remains the process temporary directory, so containerized gateways can move the bounded spool behind exactly-once resume replay onto a host volume instead of the container's ephemeral, size-bounded `/tmp`. The variable is accepted only in `reverse-gateway` mode and is rejected in every other mode.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Bound the compressed-log critical reserve test to a coverable charge
|
|
2
|
+
|
|
3
|
+
The compressed-log queue-pressure test asserted that a critical disconnect record is accepted while a 4096-byte queue holds a 1024-byte critical reserve, but the record's conservative charge (line bytes × 3 + 128) exceeds the reserve, so the writer correctly dropped it and the test failed on every run. Accepted log sequences are append-only and cannot be evicted without tearing the durable journal, so the test now reserves 2048 bytes — covering the critical record's charge — and the logging docs state the reserve sizing bound explicitly.
|
package/docs/logging.md
CHANGED
|
@@ -28,7 +28,11 @@ SOCKETDUCT_LOG_COMPRESSION_LEVEL=19
|
|
|
28
28
|
Those values are the defaults. Reaching the uncompressed frame limit starts a
|
|
29
29
|
flush immediately. Flush deadlines are measured from the oldest queued work and
|
|
30
30
|
are never extended by later records; critical lifecycle, security, and resume
|
|
31
|
-
events use the shorter deadline and may use the reserved queue capacity.
|
|
31
|
+
events use the shorter deadline and may use the reserved queue capacity. A
|
|
32
|
+
critical record is guaranteed acceptance only while its conservative charge
|
|
33
|
+
(line bytes × 3 + 128) fits the reserve, because accepted sequences are
|
|
34
|
+
append-only and can never be evicted from the durable journal; size the
|
|
35
|
+
reserve for the largest critical record you must retain under queue pressure.
|
|
32
36
|
Segment size, age, and directory quota are bounded independently. Invalid or mutually
|
|
33
37
|
inconsistent values stop startup.
|
|
34
38
|
The former `SOCKETDUCT_LOG_MAX_FILE_BYTES` and `SOCKETDUCT_LOG_MAX_FILES`
|
package/docs/protocol.md
CHANGED
|
@@ -133,9 +133,9 @@ each per-stream `RESUMED` result, and only then admits replay. Senders replay
|
|
|
133
133
|
durable raw chunks strictly above the high-water sequence while retaining
|
|
134
134
|
unacknowledged chunks and charging their outstanding bytes to the new window.
|
|
135
135
|
A DATA frame that entered a failed physical socket but did not reach the
|
|
136
|
-
logical receiver is therefore replayed; a byte
|
|
137
|
-
is not delivered twice. The target socket is never reopened and the
|
|
138
|
-
request is never recreated as a new stream.
|
|
136
|
+
logical receiver is therefore replayed; a byte durably acknowledged by the
|
|
137
|
+
receiver is not delivered twice. The target socket is never reopened and the
|
|
138
|
+
application request is never recreated as a new stream.
|
|
139
139
|
|
|
140
140
|
Reverse send replay is persisted before transmission on both the Server3 client
|
|
141
141
|
and outbound gateway. ACKs truncate those private temporary spools. Missing
|
|
@@ -151,6 +151,24 @@ returned to the application; merely buffering them below the readable
|
|
|
151
151
|
high-water mark is process-local and does not make them safe across replacement.
|
|
152
152
|
It never emits `OPEN`.
|
|
153
153
|
|
|
154
|
+
Reverse sockets default to this automatic application-consumption ACK. With the
|
|
155
|
+
persisted `manualReceiveAck` stream flag, consumption only advances an
|
|
156
|
+
application-delivered counter. An explicit byte count is valid only through
|
|
157
|
+
that counter. Socketduct atomically persists the resulting cumulative
|
|
158
|
+
`receiveAck` before emitting the wire `ACK`; persistence failure emits no ACK
|
|
159
|
+
and terminates the stream. ACK positions are application-byte positions and may
|
|
160
|
+
split a stored DATA chunk, in which case only its suffix remains replayable.
|
|
161
|
+
|
|
162
|
+
The reverse client also supports an all-or-nothing durable application
|
|
163
|
+
admission. It preflights the complete operation against current stream,
|
|
164
|
+
connection, and aggregate budgets, splits it into ordinary bounded DATA chunks,
|
|
165
|
+
writes the complete raw batch, and writes recovery metadata containing the new
|
|
166
|
+
`sendSequence` last. No DATA becomes send-eligible until that commit succeeds.
|
|
167
|
+
An interrupted pre-commit batch is an uncommitted tail: live retry replaces it,
|
|
168
|
+
and replacement recovery discards it using the older committed sequence. After
|
|
169
|
+
commit, ordinary flow-control, compression, transmission, ACK, replay, and END
|
|
170
|
+
ordering are unchanged.
|
|
171
|
+
|
|
154
172
|
Only the reverse client is replaceable in this protocol boundary. The same hub
|
|
155
173
|
process and retained bridge, outbound gateway process, and original target TCP
|
|
156
174
|
socket must survive. Hub restart reconstruction, gateway process/socket-keeper
|
package/docs/reverse-gateway.md
CHANGED
|
@@ -116,6 +116,22 @@ per-stream and per-connection ceilings, and released connection capacity rotates
|
|
|
116
116
|
among streams with pending writes. The ceilings limit retained flow-control state,
|
|
117
117
|
not the size of one application write.
|
|
118
118
|
|
|
119
|
+
Reverse `VirtualSocket` instances also expose `writeDurable(bytes)` for one
|
|
120
|
+
complete serialized application operation such as an HTTP request. Unlike an
|
|
121
|
+
ordinary streaming `write()`, it requires the whole operation to fit the
|
|
122
|
+
current per-stream, per-connection, and transport-set retained/spool budgets.
|
|
123
|
+
Socketduct reserves the complete byte count, replaces any uncommitted spool
|
|
124
|
+
tail, writes every frame-sized raw chunk, and commits the new send sequence
|
|
125
|
+
before resolving. Only then can the first byte be transmitted. Limit rejection
|
|
126
|
+
uses `SOCKETDUCT_DURABLE_WRITE_LIMIT` (or an applicable `SOCKETDUCT_SET_*`
|
|
127
|
+
limit), and persistence failure uses
|
|
128
|
+
`SOCKETDUCT_DURABLE_WRITE_PERSISTENCE`; both admit zero bytes and leave the
|
|
129
|
+
socket available for retry. Each successful repeated call is a distinct
|
|
130
|
+
operation. A physical write failure after admission uses normal durable replay.
|
|
131
|
+
Destroy/close rejects with `ERR_STREAM_DESTROYED`, while detach finishes an
|
|
132
|
+
admission already committing, rejects queued admissions with
|
|
133
|
+
`SOCKETDUCT_CONNECTION_DETACHED`, and preserves committed spool state.
|
|
134
|
+
|
|
119
135
|
Compression and decompression use Node's asynchronous native zstd APIs, whose
|
|
120
136
|
work runs in the libuv worker pool across CPU cores. The hub keeps registration
|
|
121
137
|
and stream ownership on one deterministic event loop instead of splitting its
|
|
@@ -150,10 +166,17 @@ Reverse replay state is bounded per stream and connection and stored in private
|
|
|
150
166
|
spool directories. ACKed chunks are truncated, and terminal close/reset removes
|
|
151
167
|
the spool. `status().spoolBytes` reports retained durable payload. Reverse-client
|
|
152
168
|
replacement requires the same explicit `connectionId`, an explicit persistent
|
|
153
|
-
`spoolDirectory`, and the same stream identifier
|
|
154
|
-
|
|
155
|
-
`registerRecoveredConnection({...options, recover: true})` before
|
|
156
|
-
|
|
169
|
+
`spoolDirectory`, and the same stream identifier. A direct
|
|
170
|
+
`ReverseGatewayConnection` may still register the stored name, target, and
|
|
171
|
+
selector with `registerRecoveredConnection({...options, recover: true})` before
|
|
172
|
+
`connect()`. A transport set can instead call
|
|
173
|
+
`recoverConnection({identifier})` before `start()`; it validates every
|
|
174
|
+
configured member namespace and restores the stored target, name, receive-ACK
|
|
175
|
+
mode, and original selector without selecting a live path. No match fails with
|
|
176
|
+
`SOCKETDUCT_RECOVERY_NOT_FOUND`; more than one match fails with
|
|
177
|
+
`SOCKETDUCT_RECOVERY_AMBIGUOUS`. Corrupt, expired, or wrong-owner state fails
|
|
178
|
+
rather than being treated as absent. Legacy selector-based recovery remains
|
|
179
|
+
supported. Registration synchronously rejects missing, expired, corrupt, wrong-owner,
|
|
157
180
|
mismatched, noncontiguous, or oversized state before any network attempt. The
|
|
158
181
|
stored generation is advanced and persisted before AUTH, recovered outbound
|
|
159
182
|
replay starts above the gateway high-water position, and inbound delivery starts
|
|
@@ -165,6 +188,23 @@ repair derivable usage accounting after an abrupt process loss; structural,
|
|
|
165
188
|
identity, sequence, quota, and non-tail corruption still fail closed. Recovery
|
|
166
189
|
never sends `OPEN`.
|
|
167
190
|
|
|
191
|
+
The default remains automatic receive ACK. A reverse stream opts into durable
|
|
192
|
+
application ownership with `manualReceiveAck: true`. Socketduct then records
|
|
193
|
+
bytes delivered to the application but does not advance the durable ACK until
|
|
194
|
+
`await socket.acknowledgeReceive(bytes)` succeeds after the parser or journal
|
|
195
|
+
commit. Counts are application bytes and may end inside a DATA frame; sender
|
|
196
|
+
memory and spool state retain only the unacknowledged suffix. Calls serialize
|
|
197
|
+
and return the cumulative receive sequence. Non-positive counts or counts above
|
|
198
|
+
delivered, not-yet-acknowledged bytes fail with
|
|
199
|
+
`SOCKETDUCT_INVALID_RECEIVE_ACK`; a repeated successful call acknowledges new
|
|
200
|
+
bytes rather than duplicating the previous call. Calling without opting in
|
|
201
|
+
fails with `SOCKETDUCT_MANUAL_ACK_DISABLED`. Persistence failure rejects the
|
|
202
|
+
call and terminates the stream before a wire ACK advances. Calls after socket
|
|
203
|
+
close reject with `ERR_STREAM_DESTROYED`, and calls after detach reject with
|
|
204
|
+
`SOCKETDUCT_CONNECTION_DETACHED`; an ACK already admitted is persisted before
|
|
205
|
+
detach completes. Recovery preserves manual mode so a replacement cannot
|
|
206
|
+
silently fall back to automatic ACK.
|
|
207
|
+
|
|
168
208
|
The default reverse spool is under `/tmp`, and the sample reverse-gateway
|
|
169
209
|
Compose mounts `/tmp` as tmpfs. Those defaults do not survive a container or
|
|
170
210
|
host replacement. A caller claiming process/container recovery must supply a
|
|
@@ -258,9 +298,17 @@ SOCKETDUCT_REVERSE_TOKEN='<secret>' \
|
|
|
258
298
|
SOCKETDUCT_REVERSE_HUB_CA_FILE=/certs/hub-ca.crt \
|
|
259
299
|
SOCKETDUCT_REVERSE_HUB_SERVERNAME=server3.example.net \
|
|
260
300
|
SOCKETDUCT_TARGET_ALLOWLIST=docker-a:2375 \
|
|
301
|
+
SOCKETDUCT_SPOOL_DIRECTORY=/var/lib/socketduct/spool \
|
|
261
302
|
node src/index.js
|
|
262
303
|
```
|
|
263
304
|
|
|
305
|
+
`SOCKETDUCT_SPOOL_DIRECTORY` is reverse-gateway-only and points the gateway's
|
|
306
|
+
durable target-output spool — the bounded store behind exactly-once resume
|
|
307
|
+
replay — at an operator-chosen path. Without it the spool lives under the
|
|
308
|
+
process temporary directory, which is ephemeral and size-bounded inside a
|
|
309
|
+
container; set it to a persistent host mount so spool capacity and retention
|
|
310
|
+
do not depend on container lifetime. The runtime rejects it in every other mode.
|
|
311
|
+
|
|
264
312
|
The namespace/default-route configuration remains outside Socketduct. Each
|
|
265
313
|
gateway registration represents one exact target server and WAN class;
|
|
266
314
|
Socketduct does not aggregate away either identity. Socketduct needs no inbound
|
|
@@ -299,10 +347,11 @@ Callers cannot override either selection per open. Reverse streams remain
|
|
|
299
347
|
sticky to their selected gateway/target but resume transparently after a
|
|
300
348
|
transient physical transport replacement.
|
|
301
349
|
|
|
302
|
-
For process replacement, call `recoverConnection()` before
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
350
|
+
For process replacement, call `recoverConnection({identifier})` before
|
|
351
|
+
`start()`. Recovery locates exactly one configured durable member from the
|
|
352
|
+
stable stream identifier, derives its original identity and spool namespace,
|
|
353
|
+
and never falls back to or probes a sibling. The older explicit `gatewayId` or
|
|
354
|
+
`routeClass` form remains available when the caller already stores it.
|
|
306
355
|
|
|
307
356
|
The socket is returned immediately and waits inside Socketduct until the set
|
|
308
357
|
reaches minimum readiness. New sockets rotate equally across ready members and
|
package/docs/running.md
CHANGED
|
@@ -213,6 +213,7 @@ inspect its ordered aggregate causes or privacy-safe per-member status codes.
|
|
|
213
213
|
No extra physical member is created. Configure the set-wide buffer/spool/queue
|
|
214
214
|
limits for the whole process rather than multiplying a desired budget by
|
|
215
215
|
gateway count.
|
|
216
|
-
For process replacement, call `recoverConnection()`
|
|
217
|
-
|
|
216
|
+
For process replacement, call `recoverConnection({identifier})` for each durable
|
|
217
|
+
stream before `start()`; Socketduct restores its original configured member from
|
|
218
|
+
private spool metadata. See the sequence in
|
|
218
219
|
[Node-compatible socket API design](socket-api.md#multiplexedsocketducttransportset-multi-gateway-v3-api).
|
|
@@ -37,6 +37,22 @@ socket. That ACK covers bytes returned to the application, not bytes merely
|
|
|
37
37
|
buffered in the dead process. An orderly `detach()` drains already-queued frame
|
|
38
38
|
handling before its final durable snapshot and handoff.
|
|
39
39
|
|
|
40
|
+
Applications that need parser/journal durability opt a reverse stream into
|
|
41
|
+
manual receive ACK. Bytes may be read and parsed without changing the durable
|
|
42
|
+
ACK; after the application state commit, an explicit application-byte count is
|
|
43
|
+
persisted and then sent cumulatively. Invalid, duplicate-overrun, post-close,
|
|
44
|
+
and post-detach calls fail without advancing it. A persistence failure is
|
|
45
|
+
terminal because Socketduct cannot prove that application and transport state
|
|
46
|
+
agree.
|
|
47
|
+
|
|
48
|
+
A complete serialized request can use durable admission instead of ordinary
|
|
49
|
+
streaming write. The whole request must fit current configured retained/spool
|
|
50
|
+
limits. Its chunks and final send sequence commit before any chunk is eligible
|
|
51
|
+
for transmission. Limit or persistence failure admits zero bytes, so the caller
|
|
52
|
+
may retry the same operation; success admits it once, and a repeated successful
|
|
53
|
+
call is a new operation. Detach waits for a committing admission and preserves
|
|
54
|
+
it, but rejects queued admissions that have not started.
|
|
55
|
+
|
|
40
56
|
Hub process reconstruction, gateway process or file-descriptor transfer, and
|
|
41
57
|
target reopen/retry are unsupported. A replacement gateway has no live target
|
|
42
58
|
sockets and therefore completes retained-state registration with an empty
|
|
@@ -50,6 +66,12 @@ resume reset and a typed session-loss error, while retained siblings continue.
|
|
|
50
66
|
Only bytes above the peer's received high-water sequence are replayed; spool
|
|
51
67
|
cleanup remains governed by its acknowledgement.
|
|
52
68
|
|
|
69
|
+
Replacement transport sets look up a durable stream by its stable identifier
|
|
70
|
+
across configured private spool namespaces before starting. Exactly one owner is
|
|
71
|
+
required. Missing and ambiguous identities fail closed; target, name, original
|
|
72
|
+
selector, and manual-ACK mode come from validated durable metadata rather than
|
|
73
|
+
ephemeral caller state.
|
|
74
|
+
|
|
53
75
|
## States
|
|
54
76
|
|
|
55
77
|
```text
|
package/docs/socket-api.md
CHANGED
|
@@ -218,36 +218,33 @@ socket with ordinary `tls.connect` target options such as `servername`, `ca`,
|
|
|
218
218
|
the callback, which runs exactly once only after `secureConnect` or failure.
|
|
219
219
|
Both methods count only completed member assignments and preserve stream
|
|
220
220
|
stickiness. Every returned raw or TLS socket exposes enumerable `gatewayId` and
|
|
221
|
-
`routeClass` allocation metadata. Only the configured identity is set.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
selector to recover the stream on its original member without running selection
|
|
225
|
-
again.
|
|
221
|
+
`routeClass` allocation metadata. Only the configured identity is set. The
|
|
222
|
+
metadata remains useful for diagnostics, but replacement recovery no longer
|
|
223
|
+
requires the caller to persist it.
|
|
226
224
|
|
|
227
225
|
Process-replacement recovery is deliberately separate from ordinary selection.
|
|
228
226
|
First detach the old set when an orderly handoff is possible. Construct the
|
|
229
227
|
replacement with the same base `connectionId`, spool directory, and configured
|
|
230
|
-
member endpoints. Then register each durable stream
|
|
231
|
-
|
|
228
|
+
member endpoints. Then register each durable stream by stable identifier before
|
|
229
|
+
calling `start()`:
|
|
232
230
|
|
|
233
231
|
```js
|
|
234
232
|
await set.detach()
|
|
235
233
|
|
|
236
234
|
const replacement = new MultiplexedSocketductTransportSet(sameSetOptions)
|
|
237
235
|
const recovered = replacement.recoverConnection({
|
|
238
|
-
|
|
239
|
-
host: "api.internal",
|
|
240
|
-
port: 443,
|
|
241
|
-
identifier: "build_stream_01",
|
|
242
|
-
sessionName: "build"
|
|
236
|
+
identifier: "build_stream_01"
|
|
243
237
|
})
|
|
244
238
|
await replacement.start()
|
|
245
239
|
```
|
|
246
240
|
|
|
247
|
-
`recoverConnection()` implies durable recovery
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
241
|
+
`recoverConnection()` implies durable recovery. Identifier-only lookup restores
|
|
242
|
+
the target, name, receive-ACK mode, and original member from private spool
|
|
243
|
+
metadata. It fails with `SOCKETDUCT_RECOVERY_NOT_FOUND` when no configured
|
|
244
|
+
namespace owns the identifier and `SOCKETDUCT_RECOVERY_AMBIGUOUS` when more than
|
|
245
|
+
one does. It also rejects corrupt state, an explicit `recover` option, or
|
|
246
|
+
registration after `start()` begins. The legacy exact-selector form remains
|
|
247
|
+
valid. Recovery never migrates or replays the stream on a sibling.
|
|
251
248
|
Ordinary `createConnection()` and
|
|
252
249
|
`createTlsConnection()` reject `recover: true`, `gatewayId`, and `routeClass`,
|
|
253
250
|
so new opens cannot force a path. `detach()` coalesces with concurrent shutdown calls,
|
|
@@ -269,6 +266,14 @@ buffer is discarded, but shared spool capacity remains reserved until the
|
|
|
269
266
|
corresponding durable truncate or remove succeeds. Failed cleanup keeps that
|
|
270
267
|
capacity occupied and fails the affected member visibly.
|
|
271
268
|
|
|
269
|
+
For reverse members, `manualReceiveAck: true` changes inbound ownership from the
|
|
270
|
+
default automatic ACK to explicit `await socket.acknowledgeReceive(bytes)` after
|
|
271
|
+
the caller's parser or journal commit. `writeDurable(bytes)` atomically admits a
|
|
272
|
+
complete serialized operation and resolves only after its full raw payload and
|
|
273
|
+
send sequence are committed. Ordinary `write()` and default receive behavior
|
|
274
|
+
are unchanged. See [Native reverse gateway](reverse-gateway.md#failure-and-replay-boundary)
|
|
275
|
+
for limits, retry, detach, duplicate-call, and error semantics.
|
|
276
|
+
|
|
272
277
|
`status()` returns only safe aggregate counts/bytes, `pendingOpens`, its maximum,
|
|
273
278
|
and an ordered member list:
|
|
274
279
|
gateway ID or route class, lifecycle state, assignments, physical connects/reconnects, virtual
|
|
@@ -320,7 +325,7 @@ socket.on("data", (chunk) => process.stdout.write(chunk))
|
|
|
320
325
|
`SocketductSocket` should extend `Duplex` and expose the common socket fields/methods that libraries usually probe:
|
|
321
326
|
|
|
322
327
|
- events: `connect`, `ready`, `data`, `end`, `close`, `error`, `timeout`, `drain`
|
|
323
|
-
- methods: `connect`, `write`, `end`, `destroy`, `pause`, `resume`, `setTimeout`, `setNoDelay`, `setKeepAlive`, `address`, `ref`, `unref`
|
|
328
|
+
- methods: `connect`, `write`, `writeDurable`, `acknowledgeReceive`, `end`, `destroy`, `pause`, `resume`, `setTimeout`, `setNoDelay`, `setKeepAlive`, `address`, `ref`, `unref`
|
|
324
329
|
- properties: `connecting`, `destroyed`, `pending`, `remoteAddress`, `remotePort`, `localAddress`, `localPort`, `bytesRead`, `bytesWritten`
|
|
325
330
|
|
|
326
331
|
The constructor should configure the socket without opening the relay connection. The public `.connect()` method starts relay connection/session create or resume and returns `this`, matching Node's chaining style. `connectSocketduct(options)` is the convenience wrapper that constructs `new SocketductSocket(options)`, schedules `.connect()`, and returns the socket immediately.
|
package/package.json
CHANGED
|
@@ -47,6 +47,12 @@ class DeferredSocketOwner {
|
|
|
47
47
|
/** @type {boolean} */
|
|
48
48
|
#cancelled = false
|
|
49
49
|
|
|
50
|
+
/** @type {number} */
|
|
51
|
+
#deliveredBytes = 0
|
|
52
|
+
|
|
53
|
+
/** @type {number} */
|
|
54
|
+
#acknowledgedBytes = 0
|
|
55
|
+
|
|
50
56
|
/** @param {DeferredVirtualSocketOptions} options - Set-owned deferred socket policy. */
|
|
51
57
|
constructor(options) {
|
|
52
58
|
this.#budget = options.budget
|
|
@@ -149,6 +155,19 @@ class DeferredSocketOwner {
|
|
|
149
155
|
this.#inner.write(data, callback)
|
|
150
156
|
}
|
|
151
157
|
|
|
158
|
+
/**
|
|
159
|
+
* @param {number} _streamId - Facade stream ID.
|
|
160
|
+
* @param {Buffer} data - Complete durable operation.
|
|
161
|
+
* @param {(error?: Error | null) => void} callback - Completion.
|
|
162
|
+
*/
|
|
163
|
+
_writeDurableStream(_streamId, data, callback) {
|
|
164
|
+
if (this.#inner === undefined) {
|
|
165
|
+
callback(new SocketductStreamError("Durable write requires an assigned Socketduct member", "SOCKETDUCT_DURABLE_WRITE_UNAVAILABLE"))
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
this.#inner.writeDurable(data).then(() => callback(), callback)
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
/**
|
|
153
172
|
* @param {number} _streamId - Facade stream ID.
|
|
154
173
|
* @param {(error?: Error | null) => void} callback - Completion.
|
|
@@ -191,6 +210,28 @@ class DeferredSocketOwner {
|
|
|
191
210
|
if (this.#inner !== undefined) this.#inner.resume()
|
|
192
211
|
}
|
|
193
212
|
|
|
213
|
+
/**
|
|
214
|
+
* @param {number} _streamId - Facade stream ID.
|
|
215
|
+
* @param {number} bytes - Bytes exposed by the facade.
|
|
216
|
+
*/
|
|
217
|
+
_deliverReadable(_streamId, bytes) {
|
|
218
|
+
this.#deliveredBytes += bytes
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {number} _streamId - Facade stream ID.
|
|
223
|
+
* @param {number} bytes - Newly durable application bytes.
|
|
224
|
+
*/
|
|
225
|
+
_acknowledgeReadable(_streamId, bytes) {
|
|
226
|
+
if (this.#inner === undefined) return Promise.reject(new SocketductStreamError("Manual receive acknowledgement requires an assigned Socketduct member", "SOCKETDUCT_MANUAL_ACK_UNAVAILABLE"))
|
|
227
|
+
if (this.#acknowledgedBytes + bytes > this.#deliveredBytes) return Promise.reject(new SocketductStreamError("Receive acknowledgement exceeds bytes delivered to the application", "SOCKETDUCT_INVALID_RECEIVE_ACK"))
|
|
228
|
+
this.#acknowledgedBytes += bytes
|
|
229
|
+
return this.#inner.acknowledgeReceive(bytes).catch((error) => {
|
|
230
|
+
this.#acknowledgedBytes -= bytes
|
|
231
|
+
throw error
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
194
235
|
/** References the assigned member or the whole set while pending. */
|
|
195
236
|
ref() {
|
|
196
237
|
if (this.#inner === undefined) this.#ref()
|
|
@@ -216,7 +257,7 @@ export class DeferredVirtualSocket extends VirtualSocket {
|
|
|
216
257
|
#routeClass
|
|
217
258
|
|
|
218
259
|
/**
|
|
219
|
-
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} socketOptions - Target/socket options.
|
|
260
|
+
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number, manualReceiveAck?: boolean}} socketOptions - Target/socket options.
|
|
220
261
|
* @param {DeferredVirtualSocketOptions} deferredOptions - Set-owned wait policy.
|
|
221
262
|
*/
|
|
222
263
|
constructor(socketOptions, deferredOptions) {
|
|
@@ -51,6 +51,7 @@ const STABLE_IDENTIFIER = /^[A-Za-z0-9_-]{8,128}$/
|
|
|
51
51
|
* @property {string} [sessionName] - Human-readable stream name.
|
|
52
52
|
* @property {string} [identifier] - Stable resumable identifier.
|
|
53
53
|
* @property {boolean} [recover] - Recover this stream from its existing durable v3 spool.
|
|
54
|
+
* @property {boolean} [manualReceiveAck] - Reverse-client-only application-controlled durable receive ACK.
|
|
54
55
|
* @property {boolean} [allowHalfOpen] - Duplex half-close behavior.
|
|
55
56
|
* @property {number} [readableHighWaterMark] - Readable buffer threshold.
|
|
56
57
|
* @property {number} [writableHighWaterMark] - Writable buffer threshold.
|
|
@@ -274,6 +275,21 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
274
275
|
return this.#createConnection(options, callback, false)
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Reads and validates one durable descriptor without allocating a socket.
|
|
280
|
+
* @param {string} identifier - Stable stream identifier.
|
|
281
|
+
* @returns {{identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: false} | undefined} Recovery descriptor.
|
|
282
|
+
*/
|
|
283
|
+
_inspectRecoveredConnection(identifier) {
|
|
284
|
+
if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Virtual stream identifier must be 8-128 URL-safe opaque characters")
|
|
285
|
+
const value = this.#spool.findRecoveryStateSync(identifier, "edge-v3")
|
|
286
|
+
if (value === undefined) return undefined
|
|
287
|
+
const rawTarget = /** @type {{target?: unknown}} */ (value).target
|
|
288
|
+
const target = normalizeTarget(/** @type {VirtualConnectionOptions} */ ({target: rawTarget}))
|
|
289
|
+
const state = validateRecoveryState(value, {connectionId: this.#connectionId, identifier, target})
|
|
290
|
+
return {identifier, target, sessionName: state.name, manualReceiveAck: false}
|
|
291
|
+
}
|
|
292
|
+
|
|
277
293
|
/**
|
|
278
294
|
* @param {VirtualConnectionOptions} options - Target/socket options.
|
|
279
295
|
* @param {((error: Error | null, socket: VirtualSocket) => void) | undefined} callback - Node-style optional callback.
|
|
@@ -282,6 +298,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
282
298
|
*/
|
|
283
299
|
#createConnection(options, callback, connectOnReady) {
|
|
284
300
|
if (this.#closing) throw new Error("Multiplexed Socketduct connection is closed")
|
|
301
|
+
if (options.manualReceiveAck !== undefined) throw new TypeError("manualReceiveAck is supported only by reverse gateway connections")
|
|
285
302
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum virtual stream count exceeded")
|
|
286
303
|
const target = normalizeTarget(options)
|
|
287
304
|
const identifier = options.identifier ?? randomBytes(24).toString("base64url")
|
|
@@ -56,6 +56,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
56
56
|
* @property {() => Promise<void>} connect - Warm the member.
|
|
57
57
|
* @property {(options: Record<string, unknown>, callback?: (error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) => import('./virtual-socket.js').VirtualSocket} createConnection - Open a virtual stream.
|
|
58
58
|
* @property {(options: Record<string, unknown>, callback?: (error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) => import('./virtual-socket.js').VirtualSocket} registerRecoveredConnection - Cold-register durable recovery.
|
|
59
|
+
* @property {(identifier: string) => {identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: boolean} | undefined} [_inspectRecoveredConnection] - Read-only stable-identifier recovery lookup.
|
|
59
60
|
* @property {() => MemberConnectionStatus} status - Privacy-safe status.
|
|
60
61
|
* @property {() => Promise<void>} close - Close the member.
|
|
61
62
|
* @property {() => Promise<void>} detach - Preserve durable streams for process-replacement recovery.
|
|
@@ -112,6 +113,19 @@ export class SocketductTransportSetTerminalError extends AggregateError {
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/** Stable-identifier recovery found no unique durable owner. */
|
|
117
|
+
export class SocketductRecoveryLookupError extends Error {
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} message - Privacy-safe detail.
|
|
120
|
+
* @param {'SOCKETDUCT_RECOVERY_NOT_FOUND' | 'SOCKETDUCT_RECOVERY_AMBIGUOUS'} code - Stable classification.
|
|
121
|
+
*/
|
|
122
|
+
constructor(message, code) {
|
|
123
|
+
super(message)
|
|
124
|
+
this.name = "SocketductRecoveryLookupError"
|
|
125
|
+
this.code = code
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
115
129
|
/** Shared retained-byte reservations across every configured member. */
|
|
116
130
|
class AggregateTransportBudget {
|
|
117
131
|
#memoryBytes = 0
|
|
@@ -343,7 +357,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
343
357
|
/**
|
|
344
358
|
* Returns an app-facing socket while the set owns readiness and assignment.
|
|
345
359
|
* @param {import('./multiplexed-connection.js').VirtualConnectionOptions} options - Validated member open options.
|
|
346
|
-
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} socketOptions - Normal socket metadata.
|
|
360
|
+
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number, manualReceiveAck?: boolean}} socketOptions - Normal socket metadata.
|
|
347
361
|
* @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
|
|
348
362
|
* @returns {GatewayVirtualSocket} Deferred socket.
|
|
349
363
|
*/
|
|
@@ -381,7 +395,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
381
395
|
|
|
382
396
|
/**
|
|
383
397
|
* Creates a socket that asynchronously reports the already-terminal startup failure.
|
|
384
|
-
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} socketOptions - Normal socket metadata.
|
|
398
|
+
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number, manualReceiveAck?: boolean}} socketOptions - Normal socket metadata.
|
|
385
399
|
* @param {Error} error - Terminal startup failure.
|
|
386
400
|
* @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
|
|
387
401
|
* @returns {GatewayVirtualSocket} Failed socket facade.
|
|
@@ -400,7 +414,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
400
414
|
|
|
401
415
|
/**
|
|
402
416
|
* Registers a durable stream on its one original member before any member starts.
|
|
403
|
-
* @param {import('./multiplexed-connection.js').VirtualConnectionOptions & ({gatewayId: string, routeClass?: never} | {routeClass: string, gatewayId?: never})} options -
|
|
417
|
+
* @param {import('./multiplexed-connection.js').VirtualConnectionOptions & ({gatewayId: string, routeClass?: never} | {routeClass: string, gatewayId?: never} | {gatewayId?: never, routeClass?: never})} options - Stable identifier with an optional legacy exact selector.
|
|
404
418
|
* @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
|
|
405
419
|
* @returns {GatewayVirtualSocket} Original member-owned virtual socket with stable gateway metadata.
|
|
406
420
|
*/
|
|
@@ -410,16 +424,36 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
410
424
|
if (typeof options !== "object" || options === null) throw new TypeError("recoverConnection requires exactly one configured gatewayId or routeClass")
|
|
411
425
|
const hasGatewayId = Object.hasOwn(options, "gatewayId")
|
|
412
426
|
const hasRouteClass = Object.hasOwn(options, "routeClass")
|
|
413
|
-
if (hasGatewayId
|
|
427
|
+
if (hasGatewayId && hasRouteClass) throw new TypeError("recoverConnection accepts at most one configured gatewayId or routeClass")
|
|
414
428
|
if (hasGatewayId && (typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId))) throw new TypeError("recoverConnection requires a valid configured gatewayId")
|
|
415
429
|
if (hasRouteClass && (typeof options.routeClass !== "string" || !ROUTE_CLASS.test(options.routeClass))) throw new TypeError("recoverConnection requires a valid configured routeClass")
|
|
416
430
|
if (Object.hasOwn(options, "recover")) throw new TypeError("recoverConnection owns the recover option")
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
431
|
+
if (typeof options.identifier !== "string" || !STABLE_IDENTIFIER.test(options.identifier)) throw new TypeError("recoverConnection requires a stable 8-128 character identifier")
|
|
432
|
+
let member
|
|
433
|
+
/** @type {{identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: boolean} | undefined} */
|
|
434
|
+
let descriptor
|
|
435
|
+
if (!hasGatewayId && !hasRouteClass) {
|
|
436
|
+
const matches = this.#members.flatMap((candidate) => {
|
|
437
|
+
const recovered = candidate.connection._inspectRecoveredConnection?.(/** @type {string} */ (options.identifier))
|
|
438
|
+
return recovered === undefined ? [] : [{member: candidate, descriptor: recovered}]
|
|
439
|
+
})
|
|
440
|
+
if (matches.length === 0) throw new SocketductRecoveryLookupError("No durable Socketduct stream matches the stable identifier", "SOCKETDUCT_RECOVERY_NOT_FOUND")
|
|
441
|
+
if (matches.length > 1) throw new SocketductRecoveryLookupError("Durable Socketduct stream identifier is ambiguous across configured members", "SOCKETDUCT_RECOVERY_AMBIGUOUS")
|
|
442
|
+
member = /** @type {{member: TransportMember, descriptor: NonNullable<typeof descriptor>}} */ (matches[0]).member
|
|
443
|
+
descriptor = /** @type {{member: TransportMember, descriptor: NonNullable<typeof descriptor>}} */ (matches[0]).descriptor
|
|
444
|
+
} else {
|
|
445
|
+
const selector = hasGatewayId ? options.gatewayId : options.routeClass
|
|
446
|
+
member = this.#members.find((candidate) => hasGatewayId ? candidate.gatewayId === selector : candidate.routeClass === selector)
|
|
447
|
+
if (member === undefined) throw new Error(`Unknown ${hasGatewayId ? "gatewayId" : "routeClass"}: ${selector}`)
|
|
448
|
+
}
|
|
420
449
|
const virtualOptions = {...options}
|
|
421
450
|
delete /** @type {{gatewayId?: string}} */ (virtualOptions).gatewayId
|
|
422
451
|
delete /** @type {{routeClass?: string}} */ (virtualOptions).routeClass
|
|
452
|
+
if (descriptor !== undefined) {
|
|
453
|
+
if (virtualOptions.target === undefined && virtualOptions.host === undefined && virtualOptions.port === undefined) virtualOptions.target = descriptor.target
|
|
454
|
+
if (virtualOptions.sessionName === undefined) virtualOptions.sessionName = descriptor.sessionName
|
|
455
|
+
if (virtualOptions.manualReceiveAck === undefined && descriptor.manualReceiveAck) virtualOptions.manualReceiveAck = true
|
|
456
|
+
}
|
|
423
457
|
const allocation = memberAllocation(member)
|
|
424
458
|
/** @type {((error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) | undefined} */
|
|
425
459
|
const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeAllocation(socket, allocation))
|
|
@@ -822,7 +856,8 @@ function normalizeVirtualSocketOptions(options) {
|
|
|
822
856
|
port: target.port,
|
|
823
857
|
...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
|
|
824
858
|
...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
|
|
825
|
-
...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
|
|
859
|
+
...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark}),
|
|
860
|
+
...(options.manualReceiveAck === undefined ? {} : {manualReceiveAck: options.manualReceiveAck})
|
|
826
861
|
}
|
|
827
862
|
}
|
|
828
863
|
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export {createMultiplexedSocketductConnection, MultiplexedSocketductConnection} from "./multiplexed-connection.js"
|
|
2
|
-
export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./multiplexed-transport-set.js"
|
|
2
|
+
export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductRecoveryLookupError, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./multiplexed-transport-set.js"
|
|
3
3
|
export {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "./virtual-socket.js"
|