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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/no-undefined-types, jsdoc/require-returns */
|
|
2
|
+
import {SocketductStreamError, VirtualSocket} from "./virtual-socket.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} PendingByteBudget
|
|
6
|
+
* @property {(bytes: number) => void} reserveMemory - Reserve pending application bytes.
|
|
7
|
+
* @property {(bytes: number) => void} releaseMemory - Release pending application bytes.
|
|
8
|
+
* @typedef {object} DeferredVirtualSocketOptions
|
|
9
|
+
* @property {PendingByteBudget} budget - Set-wide byte budget.
|
|
10
|
+
* @property {() => void} onCancel - Remove an unassigned socket from the set queue.
|
|
11
|
+
* @property {() => void} ref - Reference set resources.
|
|
12
|
+
* @property {() => void} unref - Unreference set resources.
|
|
13
|
+
* @typedef {object} WriteReservation
|
|
14
|
+
* @property {number} bytes - Reserved bytes.
|
|
15
|
+
* @property {boolean} held - Whether the reservation is still charged.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Bridges one not-yet-assigned socket into its eventual member VirtualSocket. */
|
|
19
|
+
class DeferredSocketOwner {
|
|
20
|
+
/** @type {PendingByteBudget} */
|
|
21
|
+
#budget
|
|
22
|
+
|
|
23
|
+
/** @type {() => void} */
|
|
24
|
+
#onCancel
|
|
25
|
+
|
|
26
|
+
/** @type {() => void} */
|
|
27
|
+
#ref
|
|
28
|
+
|
|
29
|
+
/** @type {() => void} */
|
|
30
|
+
#unref
|
|
31
|
+
|
|
32
|
+
/** @type {DeferredVirtualSocket | undefined} */
|
|
33
|
+
#outer
|
|
34
|
+
|
|
35
|
+
/** @type {VirtualSocket | undefined} */
|
|
36
|
+
#inner
|
|
37
|
+
|
|
38
|
+
/** @type {WriteReservation[]} */
|
|
39
|
+
#writeReservations = []
|
|
40
|
+
|
|
41
|
+
/** @type {{data: Buffer, callback: (error?: Error | null) => void, reservation: WriteReservation} | undefined} */
|
|
42
|
+
#activeWrite
|
|
43
|
+
|
|
44
|
+
/** @type {((error?: Error | null) => void) | undefined} */
|
|
45
|
+
#activeFinal
|
|
46
|
+
|
|
47
|
+
/** @type {boolean} */
|
|
48
|
+
#cancelled = false
|
|
49
|
+
|
|
50
|
+
/** @param {DeferredVirtualSocketOptions} options - Set-owned deferred socket policy. */
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.#budget = options.budget
|
|
53
|
+
this.#onCancel = options.onCancel
|
|
54
|
+
this.#ref = options.ref
|
|
55
|
+
this.#unref = options.unref
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {DeferredVirtualSocket} socket - Public socket facade. */
|
|
59
|
+
attach(socket) {
|
|
60
|
+
this.#outer = socket
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {number} bytes - Application bytes entering the pending socket. @returns {WriteReservation} */
|
|
64
|
+
reserveWrite(bytes) {
|
|
65
|
+
this.#budget.reserveMemory(bytes)
|
|
66
|
+
const reservation = {bytes, held: true}
|
|
67
|
+
this.#writeReservations.push(reservation)
|
|
68
|
+
return reservation
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {WriteReservation} reservation - Reservation whose write settled or was rejected. */
|
|
72
|
+
releaseWrite(reservation) {
|
|
73
|
+
if (!reservation.held) return
|
|
74
|
+
reservation.held = false
|
|
75
|
+
this.#budget.releaseMemory(reservation.bytes)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {WriteReservation} reservation - Reservation for a write rejected before entering the stream. */
|
|
79
|
+
rollbackWrite(reservation) {
|
|
80
|
+
const index = this.#writeReservations.indexOf(reservation)
|
|
81
|
+
if (index !== -1) this.#writeReservations.splice(index, 1)
|
|
82
|
+
this.releaseWrite(reservation)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Assigns exactly one authenticated ready-member socket.
|
|
87
|
+
* @param {VirtualSocket} inner - Selected member socket.
|
|
88
|
+
* @returns {boolean} Whether the facade accepted the member.
|
|
89
|
+
*/
|
|
90
|
+
assign(inner) {
|
|
91
|
+
if (this.#inner !== undefined) throw new Error("Deferred Socketduct socket is already assigned")
|
|
92
|
+
if (this.#cancelled || this.#outer === undefined || this.#outer.destroyed) {
|
|
93
|
+
inner.destroy()
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
const outer = this.#outer
|
|
97
|
+
this.#inner = inner
|
|
98
|
+
inner.on("data", (chunk) => {
|
|
99
|
+
if (!outer._receive(Buffer.from(chunk))) inner.pause()
|
|
100
|
+
})
|
|
101
|
+
inner.once("end", () => outer._receiveEnd())
|
|
102
|
+
inner.on("error", (error) => outer._receiveError(error))
|
|
103
|
+
inner.once("close", () => {
|
|
104
|
+
if (outer.destroyed) return
|
|
105
|
+
if (outer.connecting) outer._receiveError(new SocketductStreamError("Selected Socketduct member closed before connecting", "ERR_SOCKET_CLOSED_BEFORE_CONNECTION"))
|
|
106
|
+
else outer.destroy()
|
|
107
|
+
})
|
|
108
|
+
inner.on("ready", () => outer._markConnected())
|
|
109
|
+
if (!inner.connecting) queueMicrotask(() => outer._markConnected())
|
|
110
|
+
if (this.#activeWrite !== undefined) {
|
|
111
|
+
const active = this.#activeWrite
|
|
112
|
+
this.#activeWrite = undefined
|
|
113
|
+
this.releaseWrite(active.reservation)
|
|
114
|
+
inner.write(active.data, active.callback)
|
|
115
|
+
}
|
|
116
|
+
if (this.#activeFinal !== undefined) {
|
|
117
|
+
const callback = this.#activeFinal
|
|
118
|
+
this.#activeFinal = undefined
|
|
119
|
+
inner.end(callback)
|
|
120
|
+
}
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @returns {{address?: string, port?: number}} Physical endpoint metadata. */
|
|
125
|
+
physicalAddress() {
|
|
126
|
+
if (this.#inner === undefined) return {}
|
|
127
|
+
const address = this.#inner.address()
|
|
128
|
+
return "address" in address ? {address: address.address, port: address.port} : {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @returns {number} Assigned protocol stream ID, or zero while unassigned. */
|
|
132
|
+
streamId() {
|
|
133
|
+
return this.#inner?.streamId ?? 0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {number} _streamId - Facade stream ID.
|
|
138
|
+
* @param {Buffer} data - Application bytes.
|
|
139
|
+
* @param {(error?: Error | null) => void} callback - Completion.
|
|
140
|
+
*/
|
|
141
|
+
_writeStream(_streamId, data, callback) {
|
|
142
|
+
const reservation = this.#writeReservations.shift()
|
|
143
|
+
if (reservation === undefined) throw new Error("Deferred Socketduct write has no byte reservation")
|
|
144
|
+
if (this.#inner === undefined) {
|
|
145
|
+
this.#activeWrite = {data, callback, reservation}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
this.releaseWrite(reservation)
|
|
149
|
+
this.#inner.write(data, callback)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {number} _streamId - Facade stream ID.
|
|
154
|
+
* @param {(error?: Error | null) => void} callback - Completion.
|
|
155
|
+
*/
|
|
156
|
+
_endStream(_streamId, callback) {
|
|
157
|
+
if (this.#inner === undefined) {
|
|
158
|
+
this.#activeFinal = callback
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
this.#inner.end(callback)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {number} _streamId - Facade stream ID.
|
|
166
|
+
* @param {Error | null} error - Destroy cause.
|
|
167
|
+
*/
|
|
168
|
+
_destroyStream(_streamId, error) {
|
|
169
|
+
if (this.#cancelled) return
|
|
170
|
+
this.#cancelled = true
|
|
171
|
+
this.#onCancel()
|
|
172
|
+
for (const reservation of this.#writeReservations) this.releaseWrite(reservation)
|
|
173
|
+
this.#writeReservations = []
|
|
174
|
+
if (this.#activeWrite !== undefined) {
|
|
175
|
+
const active = this.#activeWrite
|
|
176
|
+
this.#activeWrite = undefined
|
|
177
|
+
this.releaseWrite(active.reservation)
|
|
178
|
+
active.callback(error ?? new SocketductStreamError("Deferred Socketduct socket was destroyed", "ERR_STREAM_DESTROYED"))
|
|
179
|
+
}
|
|
180
|
+
if (this.#activeFinal !== undefined) {
|
|
181
|
+
const callback = this.#activeFinal
|
|
182
|
+
this.#activeFinal = undefined
|
|
183
|
+
callback(error ?? new SocketductStreamError("Deferred Socketduct socket was destroyed", "ERR_STREAM_DESTROYED"))
|
|
184
|
+
}
|
|
185
|
+
if (this.#inner !== undefined && !this.#inner.destroyed) this.#inner.destroy(error ?? undefined)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @param {number} _streamId - Facade stream ID. */
|
|
189
|
+
_resumeReadable(_streamId) {
|
|
190
|
+
void _streamId
|
|
191
|
+
if (this.#inner !== undefined) this.#inner.resume()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** References the assigned member or the whole set while pending. */
|
|
195
|
+
ref() {
|
|
196
|
+
if (this.#inner === undefined) this.#ref()
|
|
197
|
+
else this.#inner.ref()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Unreferences the assigned member or the whole set while pending. */
|
|
201
|
+
unref() {
|
|
202
|
+
if (this.#inner === undefined) this.#unref()
|
|
203
|
+
else this.#inner.unref()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A normal VirtualSocket that delays member ownership until readiness. */
|
|
208
|
+
export class DeferredVirtualSocket extends VirtualSocket {
|
|
209
|
+
/** @type {DeferredSocketOwner} */
|
|
210
|
+
#owner
|
|
211
|
+
|
|
212
|
+
/** @type {string | undefined} */
|
|
213
|
+
#gatewayId
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} socketOptions - Target/socket options.
|
|
217
|
+
* @param {DeferredVirtualSocketOptions} deferredOptions - Set-owned wait policy.
|
|
218
|
+
*/
|
|
219
|
+
constructor(socketOptions, deferredOptions) {
|
|
220
|
+
const owner = new DeferredSocketOwner(deferredOptions)
|
|
221
|
+
super(/** @type {import('./virtual-socket.js').VirtualSocketOwner} */ (owner), 0, socketOptions)
|
|
222
|
+
this.#owner = owner
|
|
223
|
+
owner.attach(this)
|
|
224
|
+
Object.defineProperty(this, "gatewayId", {get: () => this.#gatewayId, enumerable: true, configurable: false})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @returns {string | undefined} Immutable assignment selected only after readiness. */
|
|
228
|
+
get gatewayId() {
|
|
229
|
+
return this.#gatewayId
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @override
|
|
234
|
+
* @returns {number} Assigned protocol stream ID, or zero while unassigned.
|
|
235
|
+
*/
|
|
236
|
+
get streamId() {
|
|
237
|
+
return this.#owner.streamId()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {VirtualSocket} socket - Selected ready-member socket.
|
|
242
|
+
* @param {string} gatewayId - Stable selected gateway ID.
|
|
243
|
+
* @returns {boolean} Whether assignment succeeded before cancellation.
|
|
244
|
+
*/
|
|
245
|
+
assign(socket, gatewayId) {
|
|
246
|
+
if (this.#gatewayId !== undefined) throw new Error("Deferred Socketduct socket gateway is already assigned")
|
|
247
|
+
if (!this.#owner.assign(socket)) return false
|
|
248
|
+
this.#gatewayId = gatewayId
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Hard-bounds bytes retained before member assignment while retaining Writable backpressure.
|
|
254
|
+
* @override
|
|
255
|
+
* @param {string | NodeJS.ArrayBufferView} chunk - Application bytes.
|
|
256
|
+
* @param {BufferEncoding | ((error?: Error | null) => void)} [encoding] - Encoding or completion.
|
|
257
|
+
* @param {(error?: Error | null) => void} [callback] - Completion.
|
|
258
|
+
* @returns {boolean} Writable backpressure state.
|
|
259
|
+
*/
|
|
260
|
+
write(chunk, encoding, callback) {
|
|
261
|
+
const selectedEncoding = typeof encoding === "string" ? encoding : undefined
|
|
262
|
+
const selectedCallback = typeof encoding === "function" ? encoding : callback
|
|
263
|
+
if (typeof chunk !== "string" && !ArrayBuffer.isView(chunk)) {
|
|
264
|
+
return selectedEncoding === undefined
|
|
265
|
+
? super.write(chunk, selectedCallback)
|
|
266
|
+
: super.write(chunk, selectedEncoding, selectedCallback)
|
|
267
|
+
}
|
|
268
|
+
const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk, selectedEncoding) : chunk.byteLength
|
|
269
|
+
/** @type {WriteReservation} */
|
|
270
|
+
let reservation
|
|
271
|
+
try {
|
|
272
|
+
reservation = this.#owner.reserveWrite(bytes)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
275
|
+
queueMicrotask(() => {
|
|
276
|
+
if (selectedCallback !== undefined) selectedCallback(failure)
|
|
277
|
+
this.destroy(failure)
|
|
278
|
+
})
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
const completed = (/** @type {Error | null | undefined} */ error) => {
|
|
282
|
+
this.#owner.releaseWrite(reservation)
|
|
283
|
+
if (selectedCallback !== undefined) selectedCallback(error)
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
return selectedEncoding === undefined
|
|
287
|
+
? super.write(chunk, completed)
|
|
288
|
+
: super.write(chunk, selectedEncoding, completed)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.#owner.rollbackWrite(reservation)
|
|
291
|
+
throw error
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Routes final application bytes through the same bounded reservation as write().
|
|
297
|
+
* @override
|
|
298
|
+
* @param {string | NodeJS.ArrayBufferView | (() => void)} [chunk] - Final application bytes or completion.
|
|
299
|
+
* @param {BufferEncoding | (() => void)} [encoding] - Encoding or completion.
|
|
300
|
+
* @param {() => void} [callback] - Completion after finish.
|
|
301
|
+
* @returns {this} This socket.
|
|
302
|
+
*/
|
|
303
|
+
end(chunk, encoding, callback) {
|
|
304
|
+
if (typeof chunk === "function") return super.end(chunk)
|
|
305
|
+
const selectedCallback = typeof encoding === "function" ? encoding : callback
|
|
306
|
+
if (chunk !== undefined) {
|
|
307
|
+
if (typeof encoding === "string") this.write(chunk, encoding)
|
|
308
|
+
else this.write(chunk)
|
|
309
|
+
}
|
|
310
|
+
return selectedCallback === undefined ? super.end() : super.end(selectedCallback)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {Agent} from "node:http"
|
|
2
|
+
|
|
3
|
+
import {connectSocketduct} from "./socketduct-socket.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('./socketduct-socket.js').ConnectSocketductOptions} ConnectSocketductOptions
|
|
7
|
+
* @typedef {import('./socketduct-socket.js').SocketductSocket} SocketductSocket
|
|
8
|
+
* @typedef {import('./socketduct-socket.js').SocketductTlsOptions} SocketductTlsOptions
|
|
9
|
+
* @typedef {object} SocketductHttpAgentOptions
|
|
10
|
+
* @property {{host: string, port: number, token: string}} relay - Relay connection options.
|
|
11
|
+
* @property {{host: string, port: number}} target - Target requested for every HTTP connection.
|
|
12
|
+
* @property {string} spoolDirectory - Spool directory.
|
|
13
|
+
* @property {{initialDelayMs?: number}} [reconnect] - Reconnect options.
|
|
14
|
+
* @property {string} [sessionNamePrefix] - Session name prefix.
|
|
15
|
+
* @property {string} [sessionName] - Fixed session name.
|
|
16
|
+
* @property {boolean} [keepAlive] - Keep sockets alive between HTTP requests.
|
|
17
|
+
* @property {number} [maxSockets] - Maximum sockets.
|
|
18
|
+
* @property {number} [maxFreeSockets] - Maximum free sockets.
|
|
19
|
+
* @property {SocketductTlsOptions} [tls] - TLS client options.
|
|
20
|
+
* @property {number} [maxBytes] - Maximum total spool bytes per session.
|
|
21
|
+
* @property {number} [maxAgeMs] - Maximum age of a spool session before operations are rejected.
|
|
22
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export class SocketductHttpAgent extends Agent {
|
|
26
|
+
/** @type {SocketductHttpAgentOptions} */
|
|
27
|
+
#options
|
|
28
|
+
|
|
29
|
+
/** @type {number} */
|
|
30
|
+
#connectionId = 0
|
|
31
|
+
|
|
32
|
+
/** @param {SocketductHttpAgentOptions} options - Agent options. */
|
|
33
|
+
constructor(options) {
|
|
34
|
+
super({
|
|
35
|
+
keepAlive: options.keepAlive,
|
|
36
|
+
maxSockets: options.maxSockets,
|
|
37
|
+
maxFreeSockets: options.maxFreeSockets
|
|
38
|
+
})
|
|
39
|
+
this.#options = options
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @override
|
|
44
|
+
* @param {import('node:http').ClientRequestArgs} _options - HTTP connection options supplied by Node.
|
|
45
|
+
* @param {(error: Error | null, socket: SocketductSocket) => void} [callback] - Optional socket callback.
|
|
46
|
+
* @returns {SocketductSocket} - Socketduct-backed HTTP socket.
|
|
47
|
+
*/
|
|
48
|
+
createConnection(_options, callback) {
|
|
49
|
+
this.#connectionId += 1
|
|
50
|
+
const sessionName = this.#options.sessionName
|
|
51
|
+
?? `${this.#options.sessionNamePrefix ?? "socketduct-http"}-${this.#connectionId}`
|
|
52
|
+
/** @type {ConnectSocketductOptions} */
|
|
53
|
+
const socketOptions = {
|
|
54
|
+
relay: this.#options.relay,
|
|
55
|
+
sessionName,
|
|
56
|
+
target: this.#options.target,
|
|
57
|
+
spoolDirectory: this.#options.spoolDirectory
|
|
58
|
+
}
|
|
59
|
+
if (this.#options.reconnect !== undefined) {
|
|
60
|
+
socketOptions.reconnect = this.#options.reconnect
|
|
61
|
+
}
|
|
62
|
+
if (this.#options.tls !== undefined) {
|
|
63
|
+
socketOptions.tls = this.#options.tls
|
|
64
|
+
}
|
|
65
|
+
if (this.#options.maxBytes !== undefined) {
|
|
66
|
+
socketOptions.maxBytes = this.#options.maxBytes
|
|
67
|
+
}
|
|
68
|
+
if (this.#options.maxAgeMs !== undefined) {
|
|
69
|
+
socketOptions.maxAgeMs = this.#options.maxAgeMs
|
|
70
|
+
}
|
|
71
|
+
if (this.#options.logger !== undefined) {
|
|
72
|
+
socketOptions.logger = this.#options.logger
|
|
73
|
+
}
|
|
74
|
+
/** @type {SocketductSocket} */
|
|
75
|
+
const socket = connectSocketduct(socketOptions)
|
|
76
|
+
if (callback !== undefined) {
|
|
77
|
+
let settled = false
|
|
78
|
+
/**
|
|
79
|
+
* @param {Error | null} error - Connection result.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
const settle = (error) => {
|
|
83
|
+
if (settled) return
|
|
84
|
+
settled = true
|
|
85
|
+
socket.off("connect", onConnect)
|
|
86
|
+
socket.off("error", onError)
|
|
87
|
+
callback(error, socket)
|
|
88
|
+
}
|
|
89
|
+
/** @returns {void} */
|
|
90
|
+
const onConnect = () => settle(null)
|
|
91
|
+
/**
|
|
92
|
+
* @param {Error} error - Pre-connect Socketduct failure.
|
|
93
|
+
* @returns {void}
|
|
94
|
+
*/
|
|
95
|
+
const onError = (error) => settle(error)
|
|
96
|
+
socket.once("connect", onConnect)
|
|
97
|
+
socket.once("error", onError)
|
|
98
|
+
}
|
|
99
|
+
return socket
|
|
100
|
+
}
|
|
101
|
+
}
|