socketduct 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,170 @@
1
+ import {negotiateCodec} from "../protocol/compression.js"
2
+
3
+ /**
4
+ * @typedef {import('../sessions/session.js').SessionStore} SessionStore
5
+ * @typedef {{host: string, port: number}} SessionTarget
6
+ * @typedef {{userId: string, username: string}} Principal
7
+ * @typedef {object} TokenAuthenticator
8
+ * @property {(value: string) => Principal | undefined} authenticate - Authenticate a bearer token.
9
+ * @typedef {object} ControlHandlerOptions
10
+ * @property {TokenAuthenticator} tokens - Authentication token store.
11
+ * @property {SessionStore} sessions - Session store.
12
+ * @property {(input: {principal: Principal, target: SessionTarget}) => boolean} [authorizeTarget] - Target authorization policy.
13
+ * @property {string[]} [supportedCodecs] - stream.data codecs this relay accepts, in preference order. Empty or absent disables compression.
14
+ * @property {number[]} [supportedFraming] - Compact framing versions accepted by the relay.
15
+ * @typedef {Record<string, unknown> & {type: string}} ControlMessage
16
+ * @typedef {Record<string, unknown> & {type: string}} ControlResponse
17
+ */
18
+
19
+ /**
20
+ * @param {ControlHandlerOptions} options - Handler dependencies.
21
+ * @param {ControlMessage} message - Incoming control message.
22
+ * @returns {ControlResponse} - Machine-readable response.
23
+ */
24
+ export function handleControlMessage(options, message) {
25
+ if (message.type === "session.create") {
26
+ const framingError = requireFramingV2(message.framing, options.supportedFraming ?? [])
27
+ if (framingError !== undefined) {
28
+ return framingError
29
+ }
30
+
31
+ const principal = authenticate(options.tokens, message.token)
32
+ if (!principal) {
33
+ return errorResponse("AUTH_REQUIRED", "Authentication is required.", false)
34
+ }
35
+
36
+ if (typeof message.name !== "string" || message.name.length === 0) {
37
+ return errorResponse("PROTOCOL_VIOLATION", "Session name is required.", false)
38
+ }
39
+
40
+ const target = parseTarget(message.target)
41
+ if (target === undefined) {
42
+ return errorResponse("PROTOCOL_VIOLATION", "Target host and port are required.", false)
43
+ }
44
+
45
+ if (options.authorizeTarget?.({principal, target}) !== true) {
46
+ return errorResponse("TARGET_UNAUTHORIZED", "Target is not authorized for this principal.", false)
47
+ }
48
+
49
+ /** @type {ReturnType<SessionStore['create']>} */
50
+ let session
51
+ try {
52
+ session = options.sessions.create({
53
+ name: message.name,
54
+ ownerId: principal.userId,
55
+ target,
56
+ ...(typeof message.identifier === "string" ? {identifier: message.identifier} : {})
57
+ })
58
+ } catch {
59
+ return errorResponse("SESSION_EXISTS", "Session identifier already exists.", false)
60
+ }
61
+
62
+ const createdCodec = negotiateCodec(message.compression, options.supportedCodecs ?? [])
63
+
64
+ return {
65
+ type: "session.created",
66
+ identifier: session.identifier,
67
+ name: session.name,
68
+ state: session.state,
69
+ target: session.target,
70
+ ...(createdCodec !== undefined ? {compression: createdCodec} : {}),
71
+ framing: 2
72
+ }
73
+ }
74
+
75
+ if (message.type === "session.resume") {
76
+ const framingError = requireFramingV2(message.framing, options.supportedFraming ?? [])
77
+ if (framingError !== undefined) {
78
+ return framingError
79
+ }
80
+
81
+ const principal = authenticate(options.tokens, message.token)
82
+ if (!principal) {
83
+ return errorResponse("AUTH_REQUIRED", "Authentication is required.", false)
84
+ }
85
+
86
+ if (typeof message.identifier !== "string" || message.identifier.length === 0) {
87
+ return errorResponse("PROTOCOL_VIOLATION", "Session identifier is required.", false)
88
+ }
89
+
90
+ try {
91
+ const session = options.sessions.resume({identifier: message.identifier, ownerId: principal.userId})
92
+ const resumedCodec = negotiateCodec(message.compression, options.supportedCodecs ?? [])
93
+
94
+ return {
95
+ type: "session.resumed",
96
+ identifier: session.identifier,
97
+ name: session.name,
98
+ state: session.state,
99
+ target: session.target,
100
+ serverLastSequence: session.relaySequence,
101
+ serverLastAck: session.clientSequence,
102
+ replayFromSequence: session.clientSequence + 1,
103
+ ...(resumedCodec !== undefined ? {compression: resumedCodec} : {}),
104
+ framing: 2
105
+ }
106
+ } catch (error) {
107
+ const messageText = error instanceof Error ? error.message : String(error)
108
+ const code = messageText.includes("expired") ? "SESSION_EXPIRED" : "SESSION_NOT_FOUND"
109
+ return errorResponse(code, "Session does not exist or is not owned by this user.", false)
110
+ }
111
+ }
112
+
113
+ return errorResponse("PROTOCOL_VIOLATION", `Unsupported message type: ${message.type}`, false)
114
+ }
115
+
116
+ /**
117
+ * @param {unknown} offer - Client framing offer.
118
+ * @param {number[]} supported - Relay framing versions.
119
+ * @returns {ControlResponse | undefined} - Error when framing v2 cannot be negotiated.
120
+ */
121
+ function requireFramingV2(offer, supported) {
122
+ if (Array.isArray(offer) && offer.includes(2) && supported.includes(2)) {
123
+ return undefined
124
+ }
125
+ return errorResponse("PROTOCOL_VIOLATION", "Socketduct protocol framing version 2 is required.", false)
126
+ }
127
+
128
+ /**
129
+ * @param {TokenAuthenticator} tokens - Token store.
130
+ * @param {unknown} token - Token value.
131
+ * @returns {Principal | undefined} - Authenticated principal.
132
+ */
133
+ function authenticate(tokens, token) {
134
+ if (typeof token !== "string") {
135
+ return undefined
136
+ }
137
+ return tokens.authenticate(token)
138
+ }
139
+
140
+ /**
141
+ * @param {unknown} value - Incoming target field.
142
+ * @returns {SessionTarget | undefined} - Validated target.
143
+ */
144
+ function parseTarget(value) {
145
+ if (typeof value !== "object" || value === null) {
146
+ return undefined
147
+ }
148
+
149
+ const candidate = /** @type {{host?: unknown, port?: unknown}} */ (value)
150
+ if (typeof candidate.host !== "string" || candidate.host.length === 0) {
151
+ return undefined
152
+ }
153
+
154
+ const port = candidate.port
155
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65_535) {
156
+ return undefined
157
+ }
158
+
159
+ return {host: candidate.host, port}
160
+ }
161
+
162
+ /**
163
+ * @param {string} code - Error code.
164
+ * @param {string} message - Error message.
165
+ * @param {boolean} retryable - Retry hint.
166
+ * @returns {ControlResponse} - Error response.
167
+ */
168
+ export function errorResponse(code, message, retryable) {
169
+ return {type: "error", code, message, retryable}
170
+ }
@@ -0,0 +1,64 @@
1
+ import cluster from "node:cluster"
2
+ import {availableParallelism} from "node:os"
3
+
4
+ /**
5
+ * @typedef {object} ClusterLike
6
+ * @property {boolean} isPrimary - Whether this process is the primary process.
7
+ * @property {(env?: Record<string, string>) => WorkerLike} fork - Forks a worker process.
8
+ * @property {(event: 'exit', listener: (worker: WorkerLike, code: number | null, signal: string | null) => void) => void} on - Registers a cluster event listener.
9
+ * @typedef {object} WorkerLike
10
+ * @property {number | string | undefined} [id] - Worker identifier.
11
+ * @typedef {object} LoggerLike
12
+ * @property {(message: string) => void} [info] - Info logger.
13
+ * @property {(message: string) => void} [warn] - Warning logger.
14
+ * @typedef {object} PrimaryOptions
15
+ * @property {number} [workers] - Configured worker count.
16
+ * @property {ClusterLike} [clusterApi] - Cluster API implementation.
17
+ * @property {LoggerLike} [logger] - Logger implementation.
18
+ * @property {() => number} [availableCpus] - CPU-count provider.
19
+ * @typedef {object} PrimaryStartResult
20
+ * @property {'primary' | 'worker'} role - Process role.
21
+ * @property {number} workers - Worker count.
22
+ */
23
+
24
+ /**
25
+ * @param {number | undefined} configuredWorkers - Explicit worker count.
26
+ * @param {() => number} [availableCpus] - CPU-count provider.
27
+ * @returns {number} - Safe worker count.
28
+ */
29
+ export function resolveWorkerCount(configuredWorkers, availableCpus = availableParallelism) {
30
+ if (configuredWorkers !== undefined) {
31
+ if (!Number.isInteger(configuredWorkers) || configuredWorkers < 1) {
32
+ throw new Error(`Invalid worker count: ${configuredWorkers}`)
33
+ }
34
+ return configuredWorkers
35
+ }
36
+
37
+ return Math.max(1, availableCpus())
38
+ }
39
+
40
+ /**
41
+ * @param {PrimaryOptions} [options] - Primary start options.
42
+ * @returns {PrimaryStartResult} - Start summary.
43
+ */
44
+ export function startPrimary(options = {}) {
45
+ const clusterApi = options.clusterApi ?? cluster
46
+ const logger = options.logger ?? console
47
+ const workers = resolveWorkerCount(options.workers, options.availableCpus)
48
+
49
+ if (!clusterApi.isPrimary) {
50
+ return {role: "worker", workers: 0}
51
+ }
52
+
53
+ for (let index = 0; index < workers; index += 1) {
54
+ clusterApi.fork({SOCKETDUCT_WORKER_INDEX: String(index)})
55
+ }
56
+
57
+ clusterApi.on("exit", (worker, code, signal) => {
58
+ logger.warn?.(`Socketduct worker ${String(worker.id ?? "unknown")} exited with code ${String(code)} signal ${String(signal)}; restarting`)
59
+ clusterApi.fork()
60
+ })
61
+
62
+ logger.info?.(`Socketduct primary started ${workers} worker(s)`)
63
+ return {role: "primary", workers}
64
+ }
@@ -0,0 +1,124 @@
1
+ import {StaticTokenStore} from "../auth/static-token-store.js"
2
+ import {isLoopbackHost} from "../config/environment.js"
3
+ import {startTcpRelay} from "../proxy/tcp-relay.js"
4
+ import {startMultiplexedTcpRelay} from "../proxy/multiplexed-relay.js"
5
+ import {SessionStore} from "../sessions/session.js"
6
+
7
+ /**
8
+ * @typedef {import('../config/environment.js').RelayEnvironmentConfig} RelayEnvironmentConfig
9
+ * @typedef {import('../config/environment.js').TargetEndpoint} TargetEndpoint
10
+ * @typedef {object} RelayRuntimeHandle
11
+ * @property {{server: import('node:net').Server | import('node:tls').Server, port: number, status: () => object, close: () => Promise<void>}} relay - Relay server handle.
12
+ * @property {() => Promise<void>} close - Close function.
13
+ */
14
+
15
+ /**
16
+ * @param {RelayEnvironmentConfig} config - Relay runtime configuration.
17
+ * @returns {Promise<RelayRuntimeHandle>} Started relay runtime.
18
+ */
19
+ export async function startRelayRuntime(config) {
20
+ validateRelayConfig(config)
21
+
22
+ const host = config.relayHost
23
+ const publicBind = !isLoopbackHost(host)
24
+ const tlsEnabled = config.relayTls !== undefined
25
+
26
+ if (publicBind && !tlsEnabled && !config.allowInsecurePublic) {
27
+ throw new Error(
28
+ `Relay binds to public host ${host} without TLS. Set SOCKETDUCT_TLS_CERT_FILE + SOCKETDUCT_TLS_KEY_FILE, or explicitly opt out with SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1.`
29
+ )
30
+ }
31
+
32
+ /** @type {{certFile: string, keyFile: string, clientCaFile?: string} | undefined} */
33
+ let tlsOptions
34
+ if (tlsEnabled) {
35
+ const tls = /** @type {import('../config/environment.js').RelayTlsConfig} */ (config.relayTls)
36
+ /** @type {{certFile: string, keyFile: string, clientCaFile?: string}} */
37
+ const opts = {certFile: tls.certFile, keyFile: tls.keyFile}
38
+ if (tls.clientCaFile !== undefined) {
39
+ opts.clientCaFile = tls.clientCaFile
40
+ }
41
+ tlsOptions = opts
42
+ }
43
+
44
+ const tokens = new StaticTokenStore(config.authTokens)
45
+ const sessions = new SessionStore()
46
+ const allowedTargets = new Set(config.targetAllowlist.map(formatTarget))
47
+ /** @type {import('../protocol/compression.js').CompressionLimits} */
48
+ const compressionLimits = {}
49
+ if (config.minCompressBytes !== undefined) {
50
+ compressionLimits.minCompressBytes = config.minCompressBytes
51
+ }
52
+ if (config.maxDecompressedBytes !== undefined) {
53
+ compressionLimits.maxDecompressedBytes = config.maxDecompressedBytes
54
+ }
55
+ if (config.maxExpansionRatio !== undefined) {
56
+ compressionLimits.maxExpansionRatio = config.maxExpansionRatio
57
+ }
58
+ if (config.protocolVersion === 3) {
59
+ const relay = await startMultiplexedTcpRelay({
60
+ host,
61
+ port: config.relayPort,
62
+ resumeWindowMs: config.resumeWindowMs,
63
+ maxStreamBufferBytes: config.maxTargetBufferBytes,
64
+ maxConnectionBufferBytes: config.maxTotalBufferBytes,
65
+ maxTotalBufferBytes: config.maxTotalBufferBytes,
66
+ handshakeTimeoutMs: config.handshakeTimeoutMs,
67
+ maxInputBytesPerConnection: config.maxInputBytesPerConnection,
68
+ ...(config.initialTargetConnectTimeoutMs === undefined ? {} : {targetConnectTimeoutMs: config.initialTargetConnectTimeoutMs}),
69
+ tokens,
70
+ authorizeTarget: ({target}) => allowedTargets.has(formatTarget(target)),
71
+ ...(tlsOptions === undefined ? {} : {tls: tlsOptions})
72
+ })
73
+ return {relay, close: relay.close}
74
+ }
75
+
76
+ /** @type {import('../proxy/tcp-relay.js').TcpRelayOptions} */
77
+ const relayOptions = {
78
+ host,
79
+ port: config.relayPort,
80
+ resumeWindowMs: config.resumeWindowMs,
81
+ maxTargetBufferBytes: config.maxTargetBufferBytes,
82
+ maxTotalBufferBytes: config.maxTotalBufferBytes,
83
+ ...(config.initialTargetRetryDelayMs === undefined ? {} : {initialTargetRetryDelayMs: config.initialTargetRetryDelayMs}),
84
+ ...(config.initialTargetRetryMaxDelayMs === undefined ? {} : {initialTargetRetryMaxDelayMs: config.initialTargetRetryMaxDelayMs}),
85
+ ...(config.initialTargetConnectTimeoutMs === undefined ? {} : {initialTargetConnectTimeoutMs: config.initialTargetConnectTimeoutMs}),
86
+ handshakeTimeoutMs: config.handshakeTimeoutMs,
87
+ maxInputBytesPerConnection: config.maxInputBytesPerConnection,
88
+ compressionEnabled: config.compression !== "off",
89
+ compressionLimits,
90
+ tokens,
91
+ sessions,
92
+ authorizeTarget: ({target}) => allowedTargets.has(formatTarget(target))
93
+ }
94
+ if (tlsOptions !== undefined) {
95
+ relayOptions.tls = tlsOptions
96
+ }
97
+ const relay = await startTcpRelay(relayOptions)
98
+
99
+ return {
100
+ relay,
101
+ close: relay.close
102
+ }
103
+ }
104
+
105
+ /**
106
+ * @param {RelayEnvironmentConfig} config - Relay runtime configuration.
107
+ * @returns {void}
108
+ */
109
+ function validateRelayConfig(config) {
110
+ if (config.authTokens.length === 0) {
111
+ throw new Error("SOCKETDUCT_AUTH_TOKENS must contain at least one username:token entry in relay mode")
112
+ }
113
+ if (config.targetAllowlist.length === 0) {
114
+ throw new Error("SOCKETDUCT_TARGET_ALLOWLIST must contain at least one host:port entry in relay mode")
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @param {TargetEndpoint} target - Target endpoint.
120
+ * @returns {string} Canonical allowlist key.
121
+ */
122
+ function formatTarget(target) {
123
+ return `${target.host}:${target.port}`
124
+ }
@@ -0,0 +1,39 @@
1
+ import {createServer} from "node:net"
2
+
3
+ /**
4
+ * @typedef {object} WorkerServerOptions
5
+ * @property {string} [host] - Listen host.
6
+ * @property {number} [port] - Listen port.
7
+ * @property {(socket: import('node:net').Socket) => void} connectionHandler - Socket connection handler.
8
+ * @typedef {object} WorkerServerHandle
9
+ * @property {import('node:net').Server} server - TCP server.
10
+ * @property {() => Promise<void>} close - Close function.
11
+ */
12
+
13
+ /**
14
+ * @param {WorkerServerOptions} options - Worker server options.
15
+ * @returns {Promise<WorkerServerHandle>} - Started worker server.
16
+ */
17
+ export async function startWorkerServer(options) {
18
+ const server = createServer(options.connectionHandler)
19
+ await new Promise((resolve, reject) => {
20
+ server.once("error", reject)
21
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
22
+ server.off("error", reject)
23
+ resolve(undefined)
24
+ })
25
+ })
26
+
27
+ return {
28
+ server,
29
+ close: () => new Promise((resolve, reject) => {
30
+ server.close((error) => {
31
+ if (error) {
32
+ reject(error)
33
+ return
34
+ }
35
+ resolve()
36
+ })
37
+ })
38
+ }
39
+ }
@@ -0,0 +1,212 @@
1
+ import {randomBytes} from "node:crypto"
2
+
3
+ /**
4
+ * @typedef {'creating' | 'active' | 'interrupted' | 'resuming' | 'closed' | 'expired'} SessionState
5
+ * @typedef {object} Session
6
+ * @property {string} identifier - identifier value.
7
+ * @property {string} name - name value.
8
+ * @property {string} ownerId - ownerId value.
9
+ * @property {SessionState} state - state value.
10
+ * @property {number} clientSequence - clientSequence value.
11
+ * @property {number} relaySequence - relaySequence value.
12
+ * @property {{host: string, port: number}} target - Target endpoint for this session.
13
+ * @property {number} createdAt - createdAt value.
14
+ * @property {number} updatedAt - updatedAt value.
15
+ * @property {number} [expiresAt] - expiresAt value.
16
+ * @typedef {object} CreateSessionInput
17
+ * @property {string} name - name value.
18
+ * @property {string} ownerId - ownerId value.
19
+ * @property {{host: string, port: number}} target - Target endpoint for this session.
20
+ * @property {string} [identifier] - identifier value.
21
+ * @property {number} [clientSequence] - clientSequence value.
22
+ * @property {number} [relaySequence] - relaySequence value.
23
+ * @property {number} [createdAt] - createdAt value.
24
+ * @property {number} [updatedAt] - updatedAt value.
25
+ * @property {number} [expiresAt] - expiresAt value.
26
+ * @typedef {object} ResumeSessionInput
27
+ * @property {string} identifier - identifier value.
28
+ * @property {string} ownerId - ownerId value.
29
+ * @typedef {object} SessionStoreOptions
30
+ * @property {() => number} [now] - now value.
31
+ */
32
+
33
+ /** @type {Map<SessionState, Set<SessionState>>} */
34
+ const LEGAL_TRANSITIONS = new Map([
35
+ ["creating", new Set(["active", "closed"])],
36
+ ["active", new Set(["interrupted", "closed", "expired"])],
37
+ ["interrupted", new Set(["resuming", "closed", "expired"])],
38
+ ["resuming", new Set(["active", "interrupted", "closed", "expired"])],
39
+ ["closed", new Set()],
40
+ ["expired", new Set()]
41
+ ])
42
+
43
+ /**
44
+ * @param {CreateSessionInput} input - input value.
45
+ * @returns {Session} - Result value.
46
+ */
47
+ export function createSession(input) {
48
+ const now = input.createdAt ?? Date.now()
49
+ return {
50
+ identifier: input.identifier ?? generateSessionIdentifier(),
51
+ name: input.name,
52
+ ownerId: input.ownerId,
53
+ state: "creating",
54
+ clientSequence: input.clientSequence ?? 0,
55
+ relaySequence: input.relaySequence ?? 0,
56
+ target: input.target,
57
+ createdAt: now,
58
+ updatedAt: input.updatedAt ?? now,
59
+ ...(input.expiresAt === undefined ? {} : {expiresAt: input.expiresAt})
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {Session} session - session value.
65
+ * @param {SessionState} nextState - nextState value.
66
+ * @param {number} [now] - now value.
67
+ * @returns {Session} - Result value.
68
+ */
69
+ export function transitionSession(session, nextState, now = Date.now()) {
70
+ const allowed = LEGAL_TRANSITIONS.get(session.state) ?? new Set()
71
+ if (!allowed.has(nextState)) {
72
+ throw new Error(`Illegal session state transition: ${session.state} -> ${nextState}`)
73
+ }
74
+
75
+ return {
76
+ ...session,
77
+ state: nextState,
78
+ updatedAt: now
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {SessionStore} store - store value.
84
+ * @param {ResumeSessionInput} input - input value.
85
+ * @returns {Session} - Result value.
86
+ */
87
+ export function resumeSession(store, input) {
88
+ return store.resume(input)
89
+ }
90
+
91
+ export class SessionStore {
92
+ /** @type {Map<string, Session>} */
93
+ #sessions = new Map()
94
+
95
+ /** @type {() => number} */
96
+ #now
97
+
98
+ /** @param {SessionStoreOptions} [options] - Store options. */
99
+ constructor(options = {}) {
100
+ this.#now = options.now ?? Date.now
101
+ }
102
+
103
+ /**
104
+ * @param {CreateSessionInput} input - input value.
105
+ * @returns {Session} - Result value.
106
+ */
107
+ create(input) {
108
+ const now = this.#now()
109
+ const session = createSession({
110
+ ...input,
111
+ createdAt: input.createdAt ?? now,
112
+ updatedAt: input.updatedAt ?? now
113
+ })
114
+
115
+ if (this.#sessions.has(session.identifier)) {
116
+ throw new Error(`Session already exists: ${session.identifier}`)
117
+ }
118
+
119
+ this.#sessions.set(session.identifier, session)
120
+ return session
121
+ }
122
+
123
+ /**
124
+ * @param {string} identifier - identifier value.
125
+ * @returns {Session | undefined} - Result value.
126
+ */
127
+ get(identifier) {
128
+ return this.#sessions.get(identifier)
129
+ }
130
+
131
+ /**
132
+ * @param {string} identifier - identifier value.
133
+ * @param {SessionState} nextState - nextState value.
134
+ * @returns {Session} - Result value.
135
+ */
136
+ transition(identifier, nextState) {
137
+ const session = this.#requireSession(identifier)
138
+ const transitioned = transitionSession(session, nextState, this.#now())
139
+ this.#sessions.set(identifier, transitioned)
140
+ return transitioned
141
+ }
142
+
143
+ /**
144
+ * @param {string} identifier - Session identifier.
145
+ * @param {{clientSequence?: number, relaySequence?: number}} sequences - Committed sequence positions.
146
+ * @returns {Session} - Updated session.
147
+ */
148
+ updateSequences(identifier, sequences) {
149
+ const session = this.#requireSession(identifier)
150
+ const updated = {
151
+ ...session,
152
+ clientSequence: Math.max(session.clientSequence, sequences.clientSequence ?? session.clientSequence),
153
+ relaySequence: Math.max(session.relaySequence, sequences.relaySequence ?? session.relaySequence),
154
+ updatedAt: this.#now()
155
+ }
156
+ this.#sessions.set(identifier, updated)
157
+ return updated
158
+ }
159
+
160
+ /**
161
+ * @param {ResumeSessionInput} input - input value.
162
+ * @returns {Session} - Result value.
163
+ */
164
+ resume(input) {
165
+ const session = this.#requireSession(input.identifier)
166
+
167
+ if (session.expiresAt !== undefined && session.expiresAt <= this.#now()) {
168
+ const expired = {...session, state: /** @type {const} */ ("expired"), updatedAt: this.#now()}
169
+ this.#sessions.set(input.identifier, expired)
170
+ throw new Error(`Session expired: ${input.identifier}`)
171
+ }
172
+
173
+ if (session.ownerId !== input.ownerId) {
174
+ throw new Error(`Session does not belong to user: ${input.identifier}`)
175
+ }
176
+
177
+ if (session.state === "creating") {
178
+ throw new Error(`Session is not resumable before target readiness: ${input.identifier}`)
179
+ }
180
+
181
+ const resumeSource = session.state === "interrupted" ? session : transitionSession(session, "interrupted", this.#now())
182
+ const resumed = transitionSession(resumeSource, "resuming", this.#now())
183
+ this.#sessions.set(input.identifier, resumed)
184
+ return resumed
185
+ }
186
+
187
+ /**
188
+ * @param {string} identifier - Session identifier.
189
+ * @returns {boolean} - True when the session was in the store.
190
+ */
191
+ delete(identifier) {
192
+ return this.#sessions.delete(identifier)
193
+ }
194
+
195
+ /**
196
+ * @param {string} identifier - identifier value.
197
+ * @returns {Session} - Result value.
198
+ */
199
+ #requireSession(identifier) {
200
+ const session = this.#sessions.get(identifier)
201
+ if (!session) {
202
+ throw new Error(`Session not found: ${identifier}`)
203
+ }
204
+
205
+ return session
206
+ }
207
+ }
208
+
209
+ /** @returns {string} - Opaque token. */
210
+ function generateSessionIdentifier() {
211
+ return randomBytes(24).toString("base64url")
212
+ }