@restatedev/restate-sdk-tunnel 0.0.0-dev → 1.15.0-rc.4

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