@restatedev/restate-sdk-tunnel 0.0.0-dev → 1.15.0-rc.3
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/LICENSE +21 -0
- package/README.md +175 -1
- package/dist/index.cjs +955 -0
- package/dist/index.d.cts +288 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +927 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -9
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
let __restatedev_restate_sdk = require("@restatedev/restate-sdk");
|
|
25
|
+
__restatedev_restate_sdk = __toESM(__restatedev_restate_sdk);
|
|
26
|
+
let node_fs = require("node:fs");
|
|
27
|
+
node_fs = __toESM(node_fs);
|
|
28
|
+
let node_dns = require("node:dns");
|
|
29
|
+
node_dns = __toESM(node_dns);
|
|
30
|
+
let node_net = require("node:net");
|
|
31
|
+
node_net = __toESM(node_net);
|
|
32
|
+
let node_tls = require("node:tls");
|
|
33
|
+
node_tls = __toESM(node_tls);
|
|
34
|
+
let node_http2 = require("node:http2");
|
|
35
|
+
node_http2 = __toESM(node_http2);
|
|
36
|
+
|
|
37
|
+
//#region src/targets.ts
|
|
38
|
+
/**
|
|
39
|
+
* Parse one explicit tunnel-server address: `"host:port"`, or a URL whose
|
|
40
|
+
* scheme picks TLS (`https`) / plaintext (`http`) for that server.
|
|
41
|
+
* Throws on a malformed address.
|
|
42
|
+
*/
|
|
43
|
+
function parseServerAddress(address) {
|
|
44
|
+
if (address.includes("://")) {
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(address);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`tunnel: invalid tunnel server URL ${JSON.stringify(address)}`);
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`tunnel: unsupported tunnel server scheme ${JSON.stringify(url.protocol)} (use http or https)`);
|
|
52
|
+
if (url.pathname !== "/" || url.search !== "") throw new Error(`tunnel: tunnel server URL must not have a path or query: ${JSON.stringify(address)}`);
|
|
53
|
+
const port$1 = url.port !== "" ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
|
|
54
|
+
return {
|
|
55
|
+
host: url.hostname,
|
|
56
|
+
port: port$1,
|
|
57
|
+
servername: url.hostname,
|
|
58
|
+
plaintext: url.protocol === "http:"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const idx = address.lastIndexOf(":");
|
|
62
|
+
if (idx <= 0 || idx === address.length - 1) throw new Error(`tunnel: invalid tunnel server address ${JSON.stringify(address)} (expected "host:port" or a URL)`);
|
|
63
|
+
const host = address.slice(0, idx);
|
|
64
|
+
const port = Number(address.slice(idx + 1));
|
|
65
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`tunnel: invalid port in tunnel server address ${JSON.stringify(address)}`);
|
|
66
|
+
return {
|
|
67
|
+
host,
|
|
68
|
+
port,
|
|
69
|
+
servername: host
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the current set of tunnel servers. Called fresh per connection
|
|
74
|
+
* attempt, so DNS changes are picked up across redials.
|
|
75
|
+
*
|
|
76
|
+
* - Explicit `tunnelServers`: parsed as-is (no DNS here — the dial resolves
|
|
77
|
+
* the hostname).
|
|
78
|
+
* - `srvName` (region-derived or given directly): a DNS SRV lookup, each
|
|
79
|
+
* record expanded to ALL of its addresses (priority asc, weight desc).
|
|
80
|
+
*
|
|
81
|
+
* Error taxonomy (mirrors the Rust resolver): a NEGATIVE answer for an SRV
|
|
82
|
+
* target (the name genuinely has no address — ENOTFOUND/ENODATA) removes
|
|
83
|
+
* that target, and an all-negative answer yields an EMPTY list (the
|
|
84
|
+
* supervisor then reconciles everything away, like Rust's empty set). A
|
|
85
|
+
* TRANSPORT error (EAI_AGAIN, timeouts, SERVFAIL) THROWS instead — the
|
|
86
|
+
* supervisor must keep the existing connections serving and retry, not
|
|
87
|
+
* tear down healthy slots over a resolver blip.
|
|
88
|
+
*/
|
|
89
|
+
async function resolveTargets(spec) {
|
|
90
|
+
if (spec.tunnelServers !== void 0) {
|
|
91
|
+
const targets$1 = spec.tunnelServers.map(parseServerAddress);
|
|
92
|
+
if (targets$1.length === 0) throw new Error("tunnel: tunnelServers is empty");
|
|
93
|
+
return targets$1;
|
|
94
|
+
}
|
|
95
|
+
const srvName = spec.srvName;
|
|
96
|
+
const records = await node_dns.promises.resolveSrv(srvName);
|
|
97
|
+
records.sort((a, b) => a.priority - b.priority || b.weight - a.weight);
|
|
98
|
+
const lookups = await Promise.allSettled(records.map((r) => node_dns.promises.lookup(r.name, { all: true })));
|
|
99
|
+
const targets = [];
|
|
100
|
+
const seen = /* @__PURE__ */ new Set();
|
|
101
|
+
for (let i = 0; i < records.length; i++) {
|
|
102
|
+
const r = records[i];
|
|
103
|
+
const result = lookups[i];
|
|
104
|
+
if (result.status === "rejected") {
|
|
105
|
+
const code = result.reason?.code;
|
|
106
|
+
if (code === "ENOTFOUND" || code === "ENODATA") continue;
|
|
107
|
+
throw result.reason;
|
|
108
|
+
}
|
|
109
|
+
for (const a of result.value) {
|
|
110
|
+
const key = `${a.address}:${r.port}`;
|
|
111
|
+
if (seen.has(key)) continue;
|
|
112
|
+
seen.add(key);
|
|
113
|
+
targets.push({
|
|
114
|
+
host: a.address,
|
|
115
|
+
port: r.port,
|
|
116
|
+
servername: srvName
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return targets;
|
|
121
|
+
}
|
|
122
|
+
/** Stable identity of a target — the unit of one tunnel connection. */
|
|
123
|
+
function targetKey(t) {
|
|
124
|
+
return `${t.host}:${t.port}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/options.ts
|
|
129
|
+
const TUNNEL_NAME_ENV = "RESTATE_INPROC_TUNNEL_NAME";
|
|
130
|
+
const ENVIRONMENT_ID_ENV = "RESTATE_INPROC_ENVIRONMENT_ID";
|
|
131
|
+
const CLOUD_REGION_ENV = "RESTATE_INPROC_CLOUD_REGION";
|
|
132
|
+
const SIGNING_PUBLIC_KEY_ENV = "RESTATE_INPROC_SIGNING_PUBLIC_KEY";
|
|
133
|
+
const AUTH_TOKEN_FILE_ENV = "RESTATE_INPROC_AUTH_TOKEN_FILE";
|
|
134
|
+
/** An env var set to the empty string is treated as unset. */
|
|
135
|
+
function fromEnv(name) {
|
|
136
|
+
const value = process.env[name];
|
|
137
|
+
return value === void 0 || value === "" ? void 0 : value;
|
|
138
|
+
}
|
|
139
|
+
/** Resolve option > environment > throw. */
|
|
140
|
+
function requireConfigured(value, name, envName) {
|
|
141
|
+
const resolved = value !== void 0 && value !== "" ? value : fromEnv(envName);
|
|
142
|
+
if (resolved === void 0) throw new Error(`tunnel: ${name} is required (pass the option or set ${envName})`);
|
|
143
|
+
return resolved;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Both credentials travel as HTTP header values in the handshake. Node
|
|
147
|
+
* silently strips header-illegal characters, which would surface as a
|
|
148
|
+
* baffling `unauthorized` from the server — reject them loudly instead.
|
|
149
|
+
*/
|
|
150
|
+
function requireHeaderSafe(value, what) {
|
|
151
|
+
if (!/^[\x21-\x7e]+$/.test(value)) throw new Error(`tunnel: ${what} contains characters that cannot travel in an HTTP header (whitespace or non-printable)`);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function resolveAuthToken(option) {
|
|
155
|
+
if (option !== void 0 && option !== "") {
|
|
156
|
+
requireHeaderSafe(option, "authToken");
|
|
157
|
+
return () => option;
|
|
158
|
+
}
|
|
159
|
+
const tokenFile = fromEnv(AUTH_TOKEN_FILE_ENV);
|
|
160
|
+
if (tokenFile === void 0) throw new Error(`tunnel: authToken is required (pass the option or set ${AUTH_TOKEN_FILE_ENV})`);
|
|
161
|
+
const readToken = () => {
|
|
162
|
+
const stat = node_fs.statSync(tokenFile);
|
|
163
|
+
if (!stat.isFile()) throw new Error(`tunnel: auth token file ${tokenFile} is not a regular file`);
|
|
164
|
+
if (stat.size > 64 * 1024) throw new Error(`tunnel: auth token file ${tokenFile} is implausibly large for a token (${stat.size} bytes)`);
|
|
165
|
+
const token = node_fs.readFileSync(tokenFile, "utf8").trim();
|
|
166
|
+
if (token === "") throw new Error(`tunnel: auth token file ${tokenFile} is empty`);
|
|
167
|
+
return requireHeaderSafe(token, `auth token file ${tokenFile}`);
|
|
168
|
+
};
|
|
169
|
+
readToken();
|
|
170
|
+
return readToken;
|
|
171
|
+
}
|
|
172
|
+
function positive(value, fallback, name) {
|
|
173
|
+
if (value === void 0) return fallback;
|
|
174
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`tunnel: ${name} must be a positive number`);
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Validate user options and apply defaults. Throws on misconfiguration.
|
|
179
|
+
* Each identity/discovery option falls back to its RESTATE_INPROC_* env var
|
|
180
|
+
* (option > environment > throw), so a pod the restate-operator configured
|
|
181
|
+
* for `tunnelMode: in-process` needs no explicit configuration beyond the
|
|
182
|
+
* auth token.
|
|
183
|
+
*/
|
|
184
|
+
function resolveOptions(options) {
|
|
185
|
+
const hasSrv = options.tunnelServersSrv !== void 0 && options.tunnelServersSrv !== "";
|
|
186
|
+
const hasServers = options.tunnelServers !== void 0 && options.tunnelServers.length > 0;
|
|
187
|
+
let region = options.region;
|
|
188
|
+
if ((region === void 0 || region === "") && !hasSrv && options.tunnelServers === void 0) region = fromEnv(CLOUD_REGION_ENV);
|
|
189
|
+
const hasRegion = region !== void 0 && region !== "";
|
|
190
|
+
const discoveryCount = Number(hasRegion) + Number(hasSrv) + Number(hasServers);
|
|
191
|
+
if (discoveryCount === 0) throw new Error(`tunnel: specify one of \`region\`, \`tunnelServersSrv\` or \`tunnelServers\` (or set ${CLOUD_REGION_ENV})`);
|
|
192
|
+
if (discoveryCount > 1) throw new Error("tunnel: specify exactly one of `region`, `tunnelServersSrv` or `tunnelServers`");
|
|
193
|
+
if (hasRegion && !/^[a-z0-9-]+$/.test(region)) throw new Error(`tunnel: invalid region ${JSON.stringify(region)}`);
|
|
194
|
+
if (hasSrv && !/^[A-Za-z0-9._-]+$/.test(options.tunnelServersSrv)) throw new Error(`tunnel: invalid tunnelServersSrv ${JSON.stringify(options.tunnelServersSrv)}`);
|
|
195
|
+
if (hasServers) for (const address of options.tunnelServers) parseServerAddress(address);
|
|
196
|
+
const environmentId = requireConfigured(options.environmentId, "environmentId", ENVIRONMENT_ID_ENV);
|
|
197
|
+
if (!/^env_[A-Za-z0-9_-]+$/.test(environmentId)) throw new Error("tunnel: environmentId must be `env_` followed by alphanumerics (e.g. env_201k0yd4...)");
|
|
198
|
+
const authToken = resolveAuthToken(options.authToken);
|
|
199
|
+
const signingPublicKey = requireConfigured(options.signingPublicKey, "signingPublicKey", SIGNING_PUBLIC_KEY_ENV);
|
|
200
|
+
if (!signingPublicKey.startsWith("publickeyv1_")) throw new Error("tunnel: signingPublicKey must be a request-identity public key (publickeyv1_...)");
|
|
201
|
+
const tunnelName = requireConfigured(options.tunnelName, "tunnelName", TUNNEL_NAME_ENV);
|
|
202
|
+
if (!/^[A-Za-z0-9._-]+$/.test(tunnelName)) throw new Error(`tunnel: invalid tunnelName ${JSON.stringify(tunnelName)} — use letters, digits, '.', '_' or '-'`);
|
|
203
|
+
const pingIntervalMs = positive(options.pingIntervalMs, 75e3, "pingIntervalMs");
|
|
204
|
+
const pingTimeoutMs = positive(options.pingTimeoutMs, 1e4, "pingTimeoutMs");
|
|
205
|
+
return {
|
|
206
|
+
srvName: hasRegion ? srvNameForRegion(region) : hasSrv ? options.tunnelServersSrv : void 0,
|
|
207
|
+
tunnelServers: hasServers ? options.tunnelServers : void 0,
|
|
208
|
+
environmentId,
|
|
209
|
+
authToken,
|
|
210
|
+
signingPublicKey,
|
|
211
|
+
tunnelName,
|
|
212
|
+
bidirectional: options.bidirectional ?? true,
|
|
213
|
+
resolveIntervalMs: positive(options.resolveIntervalMs, 3e4, "resolveIntervalMs"),
|
|
214
|
+
supportsDrain: options.supportsDrain ?? true,
|
|
215
|
+
drainGraceMs: positive(options.drainGraceMs, 12e4, "drainGraceMs"),
|
|
216
|
+
connectTimeoutMs: positive(options.connectTimeoutMs, 5e3, "connectTimeoutMs"),
|
|
217
|
+
handshakeTimeoutMs: positive(options.handshakeTimeoutMs, 5e3, "handshakeTimeoutMs"),
|
|
218
|
+
reconnectInitialMs: positive(options.reconnectInitialMs, 10, "reconnectInitialMs"),
|
|
219
|
+
reconnectMaxMs: positive(options.reconnectMaxMs, 12e4, "reconnectMaxMs"),
|
|
220
|
+
reconnectFactor: positive(options.reconnectFactor, 2, "reconnectFactor"),
|
|
221
|
+
pingIntervalMs,
|
|
222
|
+
pingTimeoutMs,
|
|
223
|
+
pingMaxMissed: positive(options.pingMaxMissed, 2, "pingMaxMissed"),
|
|
224
|
+
maxConcurrentStreams: positive(options.maxConcurrentStreams, 4096, "maxConcurrentStreams"),
|
|
225
|
+
connectionWindowSize: positive(options.connectionWindowSize, 16 * 1024 * 1024, "connectionWindowSize"),
|
|
226
|
+
maxSessionMemory: positive(options.maxSessionMemory, 256, "maxSessionMemory"),
|
|
227
|
+
tls: options.tls ?? true,
|
|
228
|
+
logger: options.logger ?? (() => {})
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Build the `tls.connect` options for a tunnel target, or `undefined` for a
|
|
233
|
+
* plaintext connection.
|
|
234
|
+
*
|
|
235
|
+
* Always offers ALPN `["h2"]` — the same offer every Rust tunnel client
|
|
236
|
+
* makes — and the connection layer requires the negotiation to succeed:
|
|
237
|
+
* Node's http2 will only run a server session over a TLS socket whose ALPN
|
|
238
|
+
* negotiated `h2`. Tunnel servers advertise it since the standard-h2
|
|
239
|
+
* control-traffic change; older servers (which cleared their ALPN list)
|
|
240
|
+
* cannot serve this client.
|
|
241
|
+
*/
|
|
242
|
+
function buildTlsConnectOptions(tlsOption, servername) {
|
|
243
|
+
if (tlsOption === false) return void 0;
|
|
244
|
+
const base = {
|
|
245
|
+
servername,
|
|
246
|
+
ALPNProtocols: ["h2"]
|
|
247
|
+
};
|
|
248
|
+
if (tlsOption === true) return base;
|
|
249
|
+
return {
|
|
250
|
+
...base,
|
|
251
|
+
...tlsOption.servername !== void 0 && { servername: tlsOption.servername },
|
|
252
|
+
...tlsOption.ca !== void 0 && { ca: tlsOption.ca },
|
|
253
|
+
...tlsOption.cert !== void 0 && { cert: tlsOption.cert },
|
|
254
|
+
...tlsOption.key !== void 0 && { key: tlsOption.key },
|
|
255
|
+
...tlsOption.rejectUnauthorized !== void 0 && { rejectUnauthorized: tlsOption.rejectUnauthorized }
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/** The DNS SRV name for region-based tunnel-server discovery. */
|
|
259
|
+
function srvNameForRegion(region) {
|
|
260
|
+
return `tunnel.${region}.restate.cloud`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/handshake.ts
|
|
265
|
+
const START_TUNNEL_PATH = "/_/start-tunnel";
|
|
266
|
+
/** Handshake deadline — mirrors the tunnel server's own 5s timeout. */
|
|
267
|
+
const HANDSHAKE_TIMEOUT_MS = 5e3;
|
|
268
|
+
/**
|
|
269
|
+
* Run the receiver side of the /_/start-tunnel exchange on its stream.
|
|
270
|
+
* Resolves with an outcome; never rejects.
|
|
271
|
+
*/
|
|
272
|
+
function performHandshake(req, res, creds, timeoutMs = HANDSHAKE_TIMEOUT_MS) {
|
|
273
|
+
return new Promise((resolve) => {
|
|
274
|
+
let settled = false;
|
|
275
|
+
const finish = (outcome) => {
|
|
276
|
+
if (settled) return;
|
|
277
|
+
settled = true;
|
|
278
|
+
clearTimeout(deadline);
|
|
279
|
+
resolve(outcome);
|
|
280
|
+
};
|
|
281
|
+
const deadline = setTimeout(() => {
|
|
282
|
+
finish({
|
|
283
|
+
kind: "retryable",
|
|
284
|
+
reason: `handshake trailers not received within ${timeoutMs}ms`
|
|
285
|
+
});
|
|
286
|
+
req.stream.destroy();
|
|
287
|
+
}, timeoutMs);
|
|
288
|
+
deadline.unref();
|
|
289
|
+
const onTrailers = (trailers) => {
|
|
290
|
+
const status = trailers["tunnel-status"];
|
|
291
|
+
if (status !== "ok") {
|
|
292
|
+
if (status === "unauthorized" || status === "bad-tunnel-name") finish({
|
|
293
|
+
kind: "fatal",
|
|
294
|
+
reason: `tunnel-status: ${String(status)}`
|
|
295
|
+
});
|
|
296
|
+
else finish({
|
|
297
|
+
kind: "retryable",
|
|
298
|
+
reason: `tunnel-status: ${String(status ?? "<missing>")}`
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const tunnelName = trailers["tunnel-name"];
|
|
303
|
+
const proxyUrl = trailers["proxy-url"];
|
|
304
|
+
const tunnelUrl = trailers["tunnel-url"];
|
|
305
|
+
if (typeof tunnelName !== "string" || typeof proxyUrl !== "string" || typeof tunnelUrl !== "string") {
|
|
306
|
+
finish({
|
|
307
|
+
kind: "retryable",
|
|
308
|
+
reason: "handshake ok but proxy-url/tunnel-url/tunnel-name missing"
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (tunnelName !== creds.tunnelName) {
|
|
313
|
+
finish({
|
|
314
|
+
kind: "fatal",
|
|
315
|
+
reason: `tunnel-name mismatch: requested ${JSON.stringify(creds.tunnelName)}, got ${JSON.stringify(tunnelName)}`
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
finish({
|
|
320
|
+
kind: "ok",
|
|
321
|
+
info: {
|
|
322
|
+
tunnelName,
|
|
323
|
+
proxyUrl,
|
|
324
|
+
tunnelUrl
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
req.stream.on("trailers", onTrailers);
|
|
329
|
+
req.on("end", () => {
|
|
330
|
+
if (!settled && req.trailers && Object.keys(req.trailers).length > 0) onTrailers(req.trailers);
|
|
331
|
+
});
|
|
332
|
+
req.on("error", (err) => {
|
|
333
|
+
finish({
|
|
334
|
+
kind: "retryable",
|
|
335
|
+
reason: `handshake stream error: ${err.message}`
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
req.stream.on("close", () => {
|
|
339
|
+
finish({
|
|
340
|
+
kind: "retryable",
|
|
341
|
+
reason: "handshake stream closed before trailers"
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
req.resume();
|
|
345
|
+
res.writeHead(200, {
|
|
346
|
+
authorization: `Bearer ${creds.authToken}`,
|
|
347
|
+
"environment-id": creds.environmentId,
|
|
348
|
+
"tunnel-name": creds.tunnelName,
|
|
349
|
+
...creds.supportsDrain && { "supports-drain": "true" }
|
|
350
|
+
});
|
|
351
|
+
res.end();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/forwarded.ts
|
|
357
|
+
/**
|
|
358
|
+
* Strip the tunnel's forwarded prefix `/<scheme>/<host>/<port>` and return
|
|
359
|
+
* the tail — the path the SDK should see.
|
|
360
|
+
*
|
|
361
|
+
* A forwarded invocation arrives down the tunnel with its destination
|
|
362
|
+
* encoded in the path (`/http/my-service.ns.svc.cluster.local/9080/invoke/...`);
|
|
363
|
+
* the cloud proxy has already stripped the `/<env>/<tunnel>` rendezvous
|
|
364
|
+
* prefix. For an in-process SDK deployment the scheme/host/port are
|
|
365
|
+
* vestigial (the receiver *is* the service), so we drop exactly those three
|
|
366
|
+
* segments and keep the tail (`/discover`, `/invoke/<svc>/<handler>`, …).
|
|
367
|
+
*
|
|
368
|
+
* The tail is passed through without re-encoding: the SDK verifies each
|
|
369
|
+
* request's identity JWT against the signed service-relative path (its
|
|
370
|
+
* routing and verification tolerate extra path *prefixes*, but re-encoding,
|
|
371
|
+
* normalization or case folding of the tail itself would break the match).
|
|
372
|
+
* The query string is preserved (it is not part of `aud`).
|
|
373
|
+
*
|
|
374
|
+
* Returns `null` if the path isn't a forwarded `/<scheme>/<host>/<port>/...`
|
|
375
|
+
* path.
|
|
376
|
+
*/
|
|
377
|
+
function forwardedTail(rawUrl) {
|
|
378
|
+
const qIdx = rawUrl.indexOf("?");
|
|
379
|
+
const path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);
|
|
380
|
+
const query = qIdx === -1 ? "" : rawUrl.slice(qIdx);
|
|
381
|
+
const seg = path.split("/");
|
|
382
|
+
if (seg.length < 4 || seg[1] === "" || seg[2] === "" || !/^\d+$/.test(seg[3])) return null;
|
|
383
|
+
const tail = "/" + seg.slice(4).join("/");
|
|
384
|
+
return query ? tail + query : tail;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/connection.ts
|
|
389
|
+
/**
|
|
390
|
+
* Run one connection attempt. Resolves (never rejects) with the outcome
|
|
391
|
+
* when the connection ends; `slotSignal` aborts the attempt at any phase.
|
|
392
|
+
*/
|
|
393
|
+
function runConnection(target, slotSignal, deps) {
|
|
394
|
+
let authToken;
|
|
395
|
+
try {
|
|
396
|
+
authToken = deps.opts.authToken();
|
|
397
|
+
} catch (err) {
|
|
398
|
+
return Promise.resolve({
|
|
399
|
+
kind: "retryable",
|
|
400
|
+
reason: `auth token unavailable: ${err instanceof Error ? err.message : String(err)}`
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return new ConnectionAttempt(target, slotSignal, deps, authToken).run();
|
|
404
|
+
}
|
|
405
|
+
var ConnectionAttempt = class {
|
|
406
|
+
settled = false;
|
|
407
|
+
/** Set when the handshake confirms ok — the connection's serve epoch. */
|
|
408
|
+
openedAt;
|
|
409
|
+
serving = false;
|
|
410
|
+
/** Assigned when /_/start-tunnel arrives; the gate streams park on (C3). */
|
|
411
|
+
handshakePromise;
|
|
412
|
+
/** Drain handover requested — settle detaches instead of destroying (C2). */
|
|
413
|
+
detachForDrain = false;
|
|
414
|
+
socket;
|
|
415
|
+
session;
|
|
416
|
+
connectTimer;
|
|
417
|
+
firstRequestTimer;
|
|
418
|
+
watchdog;
|
|
419
|
+
resolveRun;
|
|
420
|
+
plaintext;
|
|
421
|
+
log;
|
|
422
|
+
constructor(target, slotSignal, deps, authToken) {
|
|
423
|
+
this.target = target;
|
|
424
|
+
this.slotSignal = slotSignal;
|
|
425
|
+
this.deps = deps;
|
|
426
|
+
this.authToken = authToken;
|
|
427
|
+
this.log = deps.opts.logger;
|
|
428
|
+
this.plaintext = target.plaintext ?? deps.opts.tls === false;
|
|
429
|
+
slotSignal.addEventListener("abort", this.onStop, { once: true });
|
|
430
|
+
const tlsOptions = this.plaintext ? void 0 : buildTlsConnectOptions(deps.opts.tls, target.servername);
|
|
431
|
+
this.socket = this.plaintext ? node_net.connect({
|
|
432
|
+
host: target.host,
|
|
433
|
+
port: target.port
|
|
434
|
+
}) : node_tls.connect({
|
|
435
|
+
host: target.host,
|
|
436
|
+
port: target.port,
|
|
437
|
+
...tlsOptions
|
|
438
|
+
});
|
|
439
|
+
deps.activeSockets.add(this.socket);
|
|
440
|
+
this.connectTimer = setTimeout(() => {
|
|
441
|
+
this.settle({
|
|
442
|
+
kind: "retryable",
|
|
443
|
+
reason: `connect timeout after ${deps.opts.connectTimeoutMs}ms`
|
|
444
|
+
});
|
|
445
|
+
}, deps.opts.connectTimeoutMs);
|
|
446
|
+
this.connectTimer.unref();
|
|
447
|
+
}
|
|
448
|
+
run() {
|
|
449
|
+
return new Promise((resolve) => {
|
|
450
|
+
this.resolveRun = resolve;
|
|
451
|
+
this.socket.on("error", (err) => {
|
|
452
|
+
this.settle({
|
|
453
|
+
kind: "retryable",
|
|
454
|
+
reason: `socket error: ${err.message}`
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
this.socket.on("close", () => {
|
|
458
|
+
this.settle(this.endOutcome("connection closed before handshake completed"));
|
|
459
|
+
});
|
|
460
|
+
this.socket.once(this.plaintext ? "connect" : "secureConnect", () => this.onConnected());
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
onStop = () => this.settle({
|
|
464
|
+
kind: "retryable",
|
|
465
|
+
reason: "tunnel closed"
|
|
466
|
+
});
|
|
467
|
+
uptimeMs() {
|
|
468
|
+
return this.openedAt === void 0 ? 0 : Date.now() - this.openedAt;
|
|
469
|
+
}
|
|
470
|
+
/** The end-of-connection outcome: "served" once established, else retryable. */
|
|
471
|
+
endOutcome(reason) {
|
|
472
|
+
return this.openedAt !== void 0 ? {
|
|
473
|
+
kind: "served",
|
|
474
|
+
uptimeMs: this.uptimeMs()
|
|
475
|
+
} : {
|
|
476
|
+
kind: "retryable",
|
|
477
|
+
reason
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/** C1: the single exit point. C2: drain handover detaches instead. */
|
|
481
|
+
settle(outcome) {
|
|
482
|
+
if (this.settled) return;
|
|
483
|
+
this.settled = true;
|
|
484
|
+
if (this.watchdog !== void 0) clearInterval(this.watchdog);
|
|
485
|
+
if (this.firstRequestTimer !== void 0) clearTimeout(this.firstRequestTimer);
|
|
486
|
+
clearTimeout(this.connectTimer);
|
|
487
|
+
this.slotSignal.removeEventListener("abort", this.onStop);
|
|
488
|
+
if (this.detachForDrain && this.session !== void 0 && !this.session.destroyed) this.deps.draining.add(this.session, this.socket, this.deps.opts.drainGraceMs);
|
|
489
|
+
else {
|
|
490
|
+
this.session?.destroy();
|
|
491
|
+
this.socket.destroy();
|
|
492
|
+
}
|
|
493
|
+
this.deps.activeSockets.delete(this.socket);
|
|
494
|
+
this.resolveRun(outcome);
|
|
495
|
+
}
|
|
496
|
+
onConnected() {
|
|
497
|
+
clearTimeout(this.connectTimer);
|
|
498
|
+
this.socket.setNoDelay(true);
|
|
499
|
+
this.log(`tunnel: connected to ${this.target.host}:${this.target.port}, starting handshake`);
|
|
500
|
+
if (!this.plaintext) {
|
|
501
|
+
if (this.socket.alpnProtocol !== "h2") {
|
|
502
|
+
this.settle({
|
|
503
|
+
kind: "retryable",
|
|
504
|
+
reason: "tunnel server did not negotiate h2 ALPN — it predates standard-h2 control traffic and cannot serve this client"
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const stream = this.socket;
|
|
510
|
+
const h2 = node_http2.createServer({
|
|
511
|
+
maxSessionMemory: this.deps.opts.maxSessionMemory,
|
|
512
|
+
settings: {
|
|
513
|
+
maxConcurrentStreams: this.deps.opts.maxConcurrentStreams,
|
|
514
|
+
initialWindowSize: 1024 * 1024,
|
|
515
|
+
maxFrameSize: 65536
|
|
516
|
+
}
|
|
517
|
+
}, (req, res) => this.handleRequest(req, res));
|
|
518
|
+
h2.on("session", (s) => {
|
|
519
|
+
this.session = s;
|
|
520
|
+
try {
|
|
521
|
+
s.setLocalWindowSize?.(this.deps.opts.connectionWindowSize);
|
|
522
|
+
} catch {}
|
|
523
|
+
s.on("close", () => {
|
|
524
|
+
this.settle(this.endOutcome("session closed before handshake completed"));
|
|
525
|
+
});
|
|
526
|
+
s.on("error", (err) => {
|
|
527
|
+
this.settle(this.endOutcome(`session error: ${err.message}`));
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
h2.on("sessionError", (err) => {
|
|
531
|
+
this.settle(this.endOutcome(`session error: ${err.message}`));
|
|
532
|
+
});
|
|
533
|
+
this.firstRequestTimer = setTimeout(() => {
|
|
534
|
+
if (this.handshakePromise === void 0) this.settle({
|
|
535
|
+
kind: "retryable",
|
|
536
|
+
reason: "server never initiated /_/start-tunnel"
|
|
537
|
+
});
|
|
538
|
+
}, this.deps.opts.handshakeTimeoutMs);
|
|
539
|
+
this.firstRequestTimer.unref();
|
|
540
|
+
h2.emit("connection", stream);
|
|
541
|
+
}
|
|
542
|
+
handleRequest(req, res) {
|
|
543
|
+
const rawPath = (req.url ?? "").split("?")[0];
|
|
544
|
+
if (this.handshakePromise === void 0 && req.method === "GET" && rawPath === START_TUNNEL_PATH) {
|
|
545
|
+
this.startHandshake(req, res);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (rawPath === "/_/health") {
|
|
549
|
+
res.writeHead(200);
|
|
550
|
+
res.end();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (rawPath === "/_/drain-tunnel") {
|
|
554
|
+
this.handleDrainRequest(res);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (this.serving) {
|
|
558
|
+
this.dispatchForwarded(req, res);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (this.handshakePromise === void 0) {
|
|
562
|
+
res.writeHead(503);
|
|
563
|
+
res.end("tunnel: not ready");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
this.parkOnGate(req, res);
|
|
567
|
+
}
|
|
568
|
+
/** First stream: run the handshake; its outcome opens the gate (C3). */
|
|
569
|
+
startHandshake(req, res) {
|
|
570
|
+
if (this.firstRequestTimer !== void 0) clearTimeout(this.firstRequestTimer);
|
|
571
|
+
this.handshakePromise = performHandshake(req, res, {
|
|
572
|
+
authToken: this.authToken,
|
|
573
|
+
environmentId: this.deps.opts.environmentId,
|
|
574
|
+
tunnelName: this.deps.opts.tunnelName,
|
|
575
|
+
supportsDrain: this.deps.opts.supportsDrain
|
|
576
|
+
}, this.deps.opts.handshakeTimeoutMs).then((outcome) => {
|
|
577
|
+
if (this.settled) return { ok: false };
|
|
578
|
+
if (outcome.kind === "ok") {
|
|
579
|
+
this.openedAt = Date.now();
|
|
580
|
+
this.serving = true;
|
|
581
|
+
this.log(`tunnel: established (name=${outcome.info.tunnelName}, proxy=${outcome.info.proxyUrl})`);
|
|
582
|
+
this.deps.onEstablished(outcome.info);
|
|
583
|
+
this.startWatchdog();
|
|
584
|
+
return { ok: true };
|
|
585
|
+
}
|
|
586
|
+
this.settle(outcome);
|
|
587
|
+
return { ok: false };
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/** Strip the destination prefix and hand the stream to the SDK. */
|
|
591
|
+
dispatchForwarded(req, res) {
|
|
592
|
+
const tail = forwardedTail(req.url ?? "");
|
|
593
|
+
if (tail === null) {
|
|
594
|
+
res.writeHead(400);
|
|
595
|
+
res.end("tunnel: malformed forwarded path");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
req.url = tail;
|
|
599
|
+
this.deps.sdkHandler(req, res);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* C3: a stream that raced the handshake parks on its outcome (bounded by
|
|
603
|
+
* handshakeTimeoutMs) rather than rejecting work the cloud sent the
|
|
604
|
+
* moment it registered the tunnel.
|
|
605
|
+
*/
|
|
606
|
+
parkOnGate(req, res) {
|
|
607
|
+
this.handshakePromise.then(({ ok }) => {
|
|
608
|
+
if (this.settled || res.stream.destroyed) return;
|
|
609
|
+
try {
|
|
610
|
+
if (ok) this.dispatchForwarded(req, res);
|
|
611
|
+
else {
|
|
612
|
+
res.writeHead(503);
|
|
613
|
+
res.end("tunnel: not ready");
|
|
614
|
+
}
|
|
615
|
+
} catch {}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
handleDrainRequest(res) {
|
|
619
|
+
res.writeHead(200);
|
|
620
|
+
res.end();
|
|
621
|
+
if (!this.deps.opts.supportsDrain) {
|
|
622
|
+
this.log("tunnel: received /_/drain-tunnel (drain not advertised) — acknowledging");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (this.serving) this.beginDrain();
|
|
626
|
+
else if (this.handshakePromise !== void 0) this.handshakePromise.then(({ ok }) => {
|
|
627
|
+
if (ok) this.beginDrain();
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
/** Handover: detach (C2) and settle so the slot dials a replacement. */
|
|
631
|
+
beginDrain() {
|
|
632
|
+
if (this.settled || this.detachForDrain) return;
|
|
633
|
+
this.log("tunnel: drain requested — opening a replacement connection");
|
|
634
|
+
this.detachForDrain = true;
|
|
635
|
+
this.settle({
|
|
636
|
+
kind: "drained",
|
|
637
|
+
uptimeMs: this.uptimeMs()
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Periodic h2 PING; consecutive misses mean the connection is half-open
|
|
642
|
+
* (the OS may never surface it) — kill and redial. Started once serving.
|
|
643
|
+
*/
|
|
644
|
+
startWatchdog() {
|
|
645
|
+
let missed = 0;
|
|
646
|
+
this.watchdog = setInterval(() => {
|
|
647
|
+
const s = this.session;
|
|
648
|
+
if (s === void 0 || s.destroyed) return;
|
|
649
|
+
let acked = false;
|
|
650
|
+
try {
|
|
651
|
+
s.ping((err) => {
|
|
652
|
+
if (err === null) {
|
|
653
|
+
acked = true;
|
|
654
|
+
missed = 0;
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
} catch {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
setTimeout(() => {
|
|
661
|
+
if (acked || s.destroyed) return;
|
|
662
|
+
missed++;
|
|
663
|
+
if (missed >= this.deps.opts.pingMaxMissed) {
|
|
664
|
+
this.log(`tunnel: ${missed} consecutive pings missed — reconnecting`);
|
|
665
|
+
this.settle({
|
|
666
|
+
kind: "served",
|
|
667
|
+
uptimeMs: this.uptimeMs()
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}, this.deps.opts.pingTimeoutMs).unref();
|
|
671
|
+
}, this.deps.opts.pingIntervalMs);
|
|
672
|
+
this.watchdog.unref();
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/draining.ts
|
|
678
|
+
var DrainingRegistry = class {
|
|
679
|
+
entries = /* @__PURE__ */ new Set();
|
|
680
|
+
/**
|
|
681
|
+
* Take ownership of a detached (draining) connection: let it serve its
|
|
682
|
+
* in-flight streams for up to `graceMs`, then tear it down. The entry
|
|
683
|
+
* removes itself if the session ends earlier on its own.
|
|
684
|
+
*/
|
|
685
|
+
add(session, socket, graceMs) {
|
|
686
|
+
const entry = {
|
|
687
|
+
session,
|
|
688
|
+
socket,
|
|
689
|
+
timer: setTimeout(() => {
|
|
690
|
+
this.entries.delete(entry);
|
|
691
|
+
session.destroy();
|
|
692
|
+
socket.destroy();
|
|
693
|
+
}, graceMs)
|
|
694
|
+
};
|
|
695
|
+
entry.timer.unref();
|
|
696
|
+
this.entries.add(entry);
|
|
697
|
+
session.on("close", () => {
|
|
698
|
+
clearTimeout(entry.timer);
|
|
699
|
+
this.entries.delete(entry);
|
|
700
|
+
socket.destroy();
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
/** Tear down every draining connection. Idempotent. */
|
|
704
|
+
destroyAll() {
|
|
705
|
+
for (const entry of this.entries) {
|
|
706
|
+
clearTimeout(entry.timer);
|
|
707
|
+
entry.session.destroy();
|
|
708
|
+
entry.socket.destroy();
|
|
709
|
+
}
|
|
710
|
+
this.entries.clear();
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region src/backoff.ts
|
|
716
|
+
/**
|
|
717
|
+
* Backoff resets only when a served connection stayed up at least this long
|
|
718
|
+
* (mirrors the Rust client's 5s "opened" guard). Without it, a server that
|
|
719
|
+
* authorizes the handshake but immediately drops the connection would be
|
|
720
|
+
* redialed at the backoff floor forever — a full TLS+h2+auth round trip
|
|
721
|
+
* every ~10ms.
|
|
722
|
+
*/
|
|
723
|
+
const MIN_UPTIME_FOR_BACKOFF_RESET_MS = 5e3;
|
|
724
|
+
/**
|
|
725
|
+
* Jittered exponential backoff: each `next()` returns the current delay
|
|
726
|
+
* with ±50% jitter and advances the schedule toward `maxMs`; `reset()`
|
|
727
|
+
* returns to the floor. Jitter keeps multi-homed slots from redialing in
|
|
728
|
+
* lockstep after a fleet-wide blip (thundering herd).
|
|
729
|
+
*/
|
|
730
|
+
var Backoff = class {
|
|
731
|
+
currentMs;
|
|
732
|
+
constructor(initialMs, factor, maxMs) {
|
|
733
|
+
this.initialMs = initialMs;
|
|
734
|
+
this.factor = factor;
|
|
735
|
+
this.maxMs = maxMs;
|
|
736
|
+
this.currentMs = initialMs;
|
|
737
|
+
}
|
|
738
|
+
next() {
|
|
739
|
+
const d = this.currentMs;
|
|
740
|
+
this.currentMs = Math.min(this.currentMs * this.factor, this.maxMs);
|
|
741
|
+
return d * (.5 + Math.random());
|
|
742
|
+
}
|
|
743
|
+
reset() {
|
|
744
|
+
this.currentMs = this.initialMs;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region src/util.ts
|
|
750
|
+
/** Sleep that wakes early (resolving) when the signal aborts. */
|
|
751
|
+
function delay(ms, signal) {
|
|
752
|
+
return new Promise((resolve) => {
|
|
753
|
+
if (signal.aborted) {
|
|
754
|
+
resolve();
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const t = setTimeout(() => {
|
|
758
|
+
signal.removeEventListener("abort", onAbort);
|
|
759
|
+
resolve();
|
|
760
|
+
}, ms);
|
|
761
|
+
const onAbort = () => {
|
|
762
|
+
clearTimeout(t);
|
|
763
|
+
resolve();
|
|
764
|
+
};
|
|
765
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Race a promise against a signal: resolves `null` the moment the signal
|
|
770
|
+
* aborts, otherwise passes the promise's result through (rejections
|
|
771
|
+
* propagate). The abort listener is removed when the race settles, so
|
|
772
|
+
* repeated calls against a long-lived signal don't accumulate listeners.
|
|
773
|
+
*/
|
|
774
|
+
async function raceAbortable(promise, signal) {
|
|
775
|
+
if (signal.aborted) return null;
|
|
776
|
+
let onAbort;
|
|
777
|
+
const aborted = new Promise((resolve) => {
|
|
778
|
+
onAbort = () => resolve(null);
|
|
779
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
780
|
+
});
|
|
781
|
+
try {
|
|
782
|
+
return await Promise.race([promise, aborted]);
|
|
783
|
+
} finally {
|
|
784
|
+
signal.removeEventListener("abort", onAbort);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
//#endregion
|
|
789
|
+
//#region src/connect.ts
|
|
790
|
+
/**
|
|
791
|
+
* Connect this deployment to a Restate Cloud tunnel and serve `services`
|
|
792
|
+
* over it. Returns immediately; connection management runs in the
|
|
793
|
+
* background until `close()` (or the `signal`) stops it. See
|
|
794
|
+
* {@link TunnelConnection.ready} to await the first successful handshake.
|
|
795
|
+
*/
|
|
796
|
+
function connectTunnel(options) {
|
|
797
|
+
const opts = resolveOptions(options);
|
|
798
|
+
const log = opts.logger;
|
|
799
|
+
const sdkHandler = (0, __restatedev_restate_sdk.createEndpointHandler)({
|
|
800
|
+
services: options.services,
|
|
801
|
+
bidirectional: opts.bidirectional,
|
|
802
|
+
identityKeys: [opts.signingPublicKey]
|
|
803
|
+
});
|
|
804
|
+
let stopped = false;
|
|
805
|
+
let fatalError;
|
|
806
|
+
let connectionCount = 0;
|
|
807
|
+
let lastInfo;
|
|
808
|
+
const activeSockets = /* @__PURE__ */ new Set();
|
|
809
|
+
const draining = new DrainingRegistry();
|
|
810
|
+
const slots = /* @__PURE__ */ new Map();
|
|
811
|
+
const stopController = new AbortController();
|
|
812
|
+
const supervisorWake = new AbortController();
|
|
813
|
+
stopController.signal.addEventListener("abort", () => supervisorWake.abort(), { once: true });
|
|
814
|
+
let readyResolve;
|
|
815
|
+
let readyReject;
|
|
816
|
+
const ready = new Promise((resolve, reject) => {
|
|
817
|
+
readyResolve = resolve;
|
|
818
|
+
readyReject = reject;
|
|
819
|
+
});
|
|
820
|
+
ready.catch(() => {});
|
|
821
|
+
const keepAlive = setInterval(() => {}, 2147483647);
|
|
822
|
+
const connectionDeps = {
|
|
823
|
+
opts,
|
|
824
|
+
sdkHandler,
|
|
825
|
+
draining,
|
|
826
|
+
activeSockets,
|
|
827
|
+
onEstablished: (info) => {
|
|
828
|
+
connectionCount++;
|
|
829
|
+
lastInfo = info;
|
|
830
|
+
readyResolve();
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
const stopAllSlots = () => {
|
|
834
|
+
for (const slot of slots.values()) slot.ctl.abort();
|
|
835
|
+
supervisorWake.abort();
|
|
836
|
+
};
|
|
837
|
+
/** The per-server loop: dial → serve → classify outcome → backoff → redial. */
|
|
838
|
+
const runSlot = async (target, ctl) => {
|
|
839
|
+
const backoff = new Backoff(opts.reconnectInitialMs, opts.reconnectFactor, opts.reconnectMaxMs);
|
|
840
|
+
while (!stopped && !ctl.signal.aborted && fatalError === void 0) {
|
|
841
|
+
const outcome = await runConnection(target, ctl.signal, connectionDeps);
|
|
842
|
+
if (stopped || ctl.signal.aborted) break;
|
|
843
|
+
if (outcome.kind === "fatal") {
|
|
844
|
+
fatalError = /* @__PURE__ */ new Error(`tunnel: ${outcome.reason}`);
|
|
845
|
+
log(`tunnel: FATAL — ${outcome.reason}; stopping all connections`);
|
|
846
|
+
readyReject(fatalError);
|
|
847
|
+
stopAllSlots();
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
if (outcome.kind === "served" || outcome.kind === "drained") {
|
|
851
|
+
const heldLongEnough = outcome.uptimeMs >= MIN_UPTIME_FOR_BACKOFF_RESET_MS;
|
|
852
|
+
if (heldLongEnough) backoff.reset();
|
|
853
|
+
if (outcome.kind === "drained" && heldLongEnough) {
|
|
854
|
+
log("tunnel: draining — reconnecting immediately");
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
log(outcome.kind === "drained" ? "tunnel: drained shortly after connecting — reconnecting with backoff" : "tunnel: connection ended — reconnecting");
|
|
858
|
+
} else log(`tunnel: ${outcome.reason} — reconnecting`);
|
|
859
|
+
await delay(backoff.next(), ctl.signal);
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
const startSlot = (key, target) => {
|
|
863
|
+
const ctl = new AbortController();
|
|
864
|
+
stopController.signal.addEventListener("abort", () => ctl.abort(), {
|
|
865
|
+
once: true,
|
|
866
|
+
signal: ctl.signal
|
|
867
|
+
});
|
|
868
|
+
const slot = {
|
|
869
|
+
ctl,
|
|
870
|
+
done: Promise.resolve()
|
|
871
|
+
};
|
|
872
|
+
slot.done = runSlot(target, ctl).finally(() => {
|
|
873
|
+
if (slots.get(key) === slot) slots.delete(key);
|
|
874
|
+
});
|
|
875
|
+
slots.set(key, slot);
|
|
876
|
+
};
|
|
877
|
+
const loopDone = (async () => {
|
|
878
|
+
while (!stopped && fatalError === void 0) {
|
|
879
|
+
let targets;
|
|
880
|
+
try {
|
|
881
|
+
const resolution = resolveTargets(opts);
|
|
882
|
+
resolution.catch(() => {});
|
|
883
|
+
const raced = await raceAbortable(resolution, supervisorWake.signal);
|
|
884
|
+
if (raced === null) break;
|
|
885
|
+
targets = raced;
|
|
886
|
+
} catch (err) {
|
|
887
|
+
log(`tunnel: target resolution failed: ${err instanceof Error ? err.message : String(err)} — retrying`);
|
|
888
|
+
await delay(Math.min(5e3, opts.resolveIntervalMs), supervisorWake.signal);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (stopped || fatalError !== void 0) break;
|
|
892
|
+
const desired = new Map(targets.map((t) => [targetKey(t), t]));
|
|
893
|
+
for (const [key, target] of desired) if (!slots.has(key)) {
|
|
894
|
+
log(`tunnel: starting connection to ${key}`);
|
|
895
|
+
startSlot(key, target);
|
|
896
|
+
}
|
|
897
|
+
for (const [key, slot] of slots) if (!desired.has(key)) {
|
|
898
|
+
log(`tunnel: ${key} no longer resolves — tearing down`);
|
|
899
|
+
slot.ctl.abort();
|
|
900
|
+
}
|
|
901
|
+
if (opts.srvName === void 0) break;
|
|
902
|
+
await delay(opts.resolveIntervalMs, supervisorWake.signal);
|
|
903
|
+
}
|
|
904
|
+
await Promise.all([...slots.values()].map((s) => s.done));
|
|
905
|
+
draining.destroyAll();
|
|
906
|
+
clearInterval(keepAlive);
|
|
907
|
+
readyReject(fatalError ?? /* @__PURE__ */ new Error("tunnel: closed before the first handshake"));
|
|
908
|
+
})();
|
|
909
|
+
const close = async () => {
|
|
910
|
+
if (!stopped) {
|
|
911
|
+
stopped = true;
|
|
912
|
+
stopController.abort();
|
|
913
|
+
stopAllSlots();
|
|
914
|
+
for (const socket of activeSockets) socket.destroy();
|
|
915
|
+
activeSockets.clear();
|
|
916
|
+
draining.destroyAll();
|
|
917
|
+
clearInterval(keepAlive);
|
|
918
|
+
}
|
|
919
|
+
await loopDone;
|
|
920
|
+
};
|
|
921
|
+
if (options.signal?.aborted) close();
|
|
922
|
+
else options.signal?.addEventListener("abort", () => void close(), { once: true });
|
|
923
|
+
return {
|
|
924
|
+
close,
|
|
925
|
+
get connectionCount() {
|
|
926
|
+
return connectionCount;
|
|
927
|
+
},
|
|
928
|
+
get tunnelName() {
|
|
929
|
+
return lastInfo?.tunnelName;
|
|
930
|
+
},
|
|
931
|
+
get proxyUrl() {
|
|
932
|
+
return lastInfo?.proxyUrl;
|
|
933
|
+
},
|
|
934
|
+
get tunnelUrl() {
|
|
935
|
+
return lastInfo?.tunnelUrl;
|
|
936
|
+
},
|
|
937
|
+
get deploymentUrl() {
|
|
938
|
+
if (lastInfo === void 0) return void 0;
|
|
939
|
+
try {
|
|
940
|
+
const proxy = new URL(lastInfo.proxyUrl);
|
|
941
|
+
if (proxy.port === "") proxy.port = "9080";
|
|
942
|
+
return `${proxy.toString().replace(/\/$/, "")}/http/in-process/9080/`;
|
|
943
|
+
} catch {
|
|
944
|
+
return `${lastInfo.proxyUrl}/http/in-process/9080/`;
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
get error() {
|
|
948
|
+
return fatalError;
|
|
949
|
+
},
|
|
950
|
+
ready
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
//#endregion
|
|
955
|
+
exports.connectTunnel = connectTunnel;
|