socketduct 0.1.20 → 0.1.21

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.
@@ -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.
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 already buffered by the receiver
137
- is not delivered twice. The target socket is never reopened and the application
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
@@ -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, name, target, and one original
154
- selector (`gatewayId` or `routeClass`). Register every stream with
155
- `registerRecoveredConnection({...options, recover: true})` before `connect()`;
156
- registration synchronously rejects missing, expired, corrupt, wrong-owner,
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
@@ -299,10 +339,11 @@ Callers cannot override either selection per open. Reverse streams remain
299
339
  sticky to their selected gateway/target but resume transparently after a
300
340
  transient physical transport replacement.
301
341
 
302
- For process replacement, call `recoverConnection()` before `start()` with
303
- exactly the socket's immutable original selector: `gatewayId` for an exact set
304
- or `routeClass` for a route-class set. Recovery derives the original member
305
- identity and spool namespace and never falls back to or probes a sibling.
342
+ For process replacement, call `recoverConnection({identifier})` before
343
+ `start()`. Recovery locates exactly one configured durable member from the
344
+ stable stream identifier, derives its original identity and spool namespace,
345
+ and never falls back to or probes a sibling. The older explicit `gatewayId` or
346
+ `routeClass` form remains available when the caller already stores it.
306
347
 
307
348
  The socket is returned immediately and waits inside Socketduct until the set
308
349
  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()` with exactly each stream's
217
- original gateway ID or reverse route class before `start()`; see the sequence in
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
@@ -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. Persist
222
- an exact set's immutable original selector after connection: `gatewayId` for a
223
- gateway member or `routeClass` for a reverse member. A replacement uses that
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 with exactly its original
231
- `gatewayId` or `routeClass` before calling `start()`:
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
- routeClass: durableRecord.routeClass, // exact sets use gatewayId instead
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 and rejects a missing,
248
- ambiguous, invalid, or unknown original selector, an explicit `recover` option,
249
- or registration after `start()` has begun. It bypasses selection only to reach
250
- that configured member; it never migrates or replays the stream on a sibling.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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 - Original gateway or route-class recovery identity.
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 === hasRouteClass) throw new TypeError("recoverConnection requires exactly one configured gatewayId or routeClass")
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
- const selector = hasGatewayId ? options.gatewayId : options.routeClass
418
- const member = this.#members.find((candidate) => hasGatewayId ? candidate.gatewayId === selector : candidate.routeClass === selector)
419
- if (member === undefined) throw new Error(`Unknown ${hasGatewayId ? "gatewayId" : "routeClass"}: ${selector}`)
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"