@rstreamlabs/tunnels 3.9.0 → 3.10.1

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 CHANGED
@@ -5,6 +5,14 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res, err) => function __init() {
9
+ if (err) throw err[0];
10
+ try {
11
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
+ } catch (e) {
13
+ throw err = [e], e;
14
+ }
15
+ };
8
16
  var __export = (target, all) => {
9
17
  for (var name in all)
10
18
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -27,6 +35,116 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
35
  ));
28
36
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
37
 
38
+ // src/node-engine-http.ts
39
+ var node_engine_http_exports = {};
40
+ __export(node_engine_http_exports, {
41
+ NodeEngineHTTP: () => NodeEngineHTTP
42
+ });
43
+ var import_node_https, import_node_https2, responseHeaders, responseBody, NodeEngineHTTP;
44
+ var init_node_engine_http = __esm({
45
+ "src/node-engine-http.ts"() {
46
+ "use strict";
47
+ import_node_https = require("https");
48
+ import_node_https2 = require("https");
49
+ responseHeaders = (response) => {
50
+ const headers = new Headers();
51
+ for (const [name, value] of Object.entries(response.headers)) {
52
+ if (Array.isArray(value))
53
+ for (const item of value) headers.append(name, item);
54
+ else if (value !== void 0) headers.set(name, value);
55
+ }
56
+ return headers;
57
+ };
58
+ responseBody = (response) => {
59
+ const state = { ended: false };
60
+ return new ReadableStream({
61
+ start(controller) {
62
+ response.on("data", (chunk) => {
63
+ controller.enqueue(new Uint8Array(chunk));
64
+ response.pause();
65
+ });
66
+ response.once("end", () => {
67
+ if (!state.ended) {
68
+ state.ended = true;
69
+ controller.close();
70
+ }
71
+ });
72
+ response.once("error", (error) => {
73
+ if (!state.ended) {
74
+ state.ended = true;
75
+ controller.error(error);
76
+ }
77
+ });
78
+ response.once("aborted", () => {
79
+ if (!state.ended) {
80
+ state.ended = true;
81
+ controller.error(new Error("Engine response was interrupted."));
82
+ }
83
+ });
84
+ },
85
+ pull() {
86
+ response.resume();
87
+ },
88
+ cancel() {
89
+ state.ended = true;
90
+ response.destroy();
91
+ }
92
+ });
93
+ };
94
+ NodeEngineHTTP = class {
95
+ constructor(tls) {
96
+ this.tls = tls;
97
+ }
98
+ tls;
99
+ agent = new import_node_https.Agent({
100
+ keepAlive: true,
101
+ maxSockets: 16,
102
+ maxFreeSockets: 4,
103
+ maxTotalSockets: 16
104
+ });
105
+ fetch = async (input, init) => {
106
+ const incoming = new Request(input, init);
107
+ const url = new URL(incoming.url);
108
+ if (url.protocol !== "https:" || url.username || url.password)
109
+ throw new Error(
110
+ "Engine HTTP requests require HTTPS without URL credentials."
111
+ );
112
+ const body = incoming.body ? new Uint8Array(await incoming.arrayBuffer()) : void 0;
113
+ incoming.signal.throwIfAborted();
114
+ return new Promise((resolve, reject) => {
115
+ const outgoing = (0, import_node_https2.request)(
116
+ url,
117
+ {
118
+ ...this.tls,
119
+ servername: url.hostname,
120
+ agent: this.agent,
121
+ method: incoming.method,
122
+ headers: Object.fromEntries(incoming.headers),
123
+ signal: incoming.signal
124
+ },
125
+ (response) => {
126
+ const status = response.statusCode ?? 502;
127
+ const empty = incoming.method === "HEAD" || [204, 205, 304].includes(status);
128
+ if (empty) response.resume();
129
+ resolve(
130
+ new Response(empty ? null : responseBody(response), {
131
+ status,
132
+ headers: responseHeaders(response)
133
+ })
134
+ );
135
+ }
136
+ );
137
+ outgoing.once("error", reject);
138
+ outgoing.end(body);
139
+ });
140
+ };
141
+ close() {
142
+ this.agent.destroy();
143
+ }
144
+ };
145
+ }
146
+ });
147
+
30
148
  // src/index.ts
31
149
  var index_exports = {};
32
150
  __export(index_exports, {
@@ -96,6 +214,7 @@ __export(index_exports, {
96
214
  streamWebTTYMetadataSchema: () => streamWebTTYMetadataSchema,
97
215
  tunnelFilterSchema: () => tunnelFilterSchema,
98
216
  turnCredentialModeSchema: () => turnCredentialModeSchema,
217
+ validateTURNCredentialsRequest: () => validateTURNCredentialsRequest,
99
218
  webTTYCapabilitiesSchema: () => webTTYCapabilitiesSchema,
100
219
  webTTYContextMetadataSchema: () => webTTYContextMetadataSchema,
101
220
  webTTYControlRequestSchema: () => webTTYControlRequestSchema,
@@ -1301,6 +1420,19 @@ var watchParamsSchema = z7.object({
1301
1420
  tunnels: tunnelFilterSchema.optional()
1302
1421
  });
1303
1422
  var enginePermissions = {
1423
+ "turn.credentials.create": {
1424
+ name: "TURN Credentials: Create",
1425
+ description: "Issue short-lived TURN credentials through either the Control plane or Engine API.",
1426
+ rules: [
1427
+ {
1428
+ resource: [
1429
+ "api.turn-server.credentials",
1430
+ "engine.turn-server.credentials"
1431
+ ],
1432
+ action: "create"
1433
+ }
1434
+ ]
1435
+ },
1304
1436
  "tunnels.resources.read-only": {
1305
1437
  name: "Discovery: Read-Only",
1306
1438
  description: "Read active clients, active tunnels, and live state events.",
@@ -1380,13 +1512,13 @@ var engineOpenApiDocument = {
1380
1512
  }
1381
1513
  }
1382
1514
  ],
1383
- security: [
1384
- {
1385
- bearerAuth: []
1386
- }
1387
- ],
1515
+ security: [{ bearerAuth: [] }, { mutualTLS: [] }],
1388
1516
  components: {
1389
1517
  securitySchemes: {
1518
+ mutualTLS: {
1519
+ type: "mutualTLS",
1520
+ description: "EE project API only, on the dedicated authority returned by anonymous HTTPS discovery. Present one registered client certificate without a bearer or query token. Ordinary browser-facing hosts never request a client certificate."
1521
+ },
1390
1522
  bearerAuth: {
1391
1523
  type: "http",
1392
1524
  scheme: "bearer",
@@ -1454,6 +1586,64 @@ var engineOpenApiDocument = {
1454
1586
  Forbidden: errorResponse("The token is not authorized.")
1455
1587
  },
1456
1588
  schemas: {
1589
+ EngineDiscovery: {
1590
+ type: "object",
1591
+ required: ["version", "projectEndpoint", "mtlsApiUrl", "capabilities"],
1592
+ properties: {
1593
+ version: { type: "integer", const: 1 },
1594
+ projectEndpoint: { type: "string" },
1595
+ mtlsApiUrl: {
1596
+ type: "string",
1597
+ format: "uri",
1598
+ example: "https://project.mtls.engine.example/api"
1599
+ },
1600
+ capabilities: {
1601
+ type: "array",
1602
+ items: { type: "string" },
1603
+ examples: [["engine-api-mtls", "turn-credentials-v2"]]
1604
+ }
1605
+ }
1606
+ },
1607
+ CreateTURNCredentials: {
1608
+ type: "object",
1609
+ additionalProperties: false,
1610
+ properties: {
1611
+ ttlSeconds: {
1612
+ type: "integer",
1613
+ minimum: 1,
1614
+ maximum: 3600,
1615
+ default: 600
1616
+ }
1617
+ }
1618
+ },
1619
+ TURNCredentials: {
1620
+ type: "object",
1621
+ required: ["username", "credential", "urls", "ttl"],
1622
+ properties: {
1623
+ username: {
1624
+ type: "string",
1625
+ maxLength: 512,
1626
+ description: "Opaque short-lived v2 issued identity. Do not parse in clients."
1627
+ },
1628
+ credential: {
1629
+ type: "string",
1630
+ maxLength: 1024,
1631
+ description: "Short-lived TURN password."
1632
+ },
1633
+ urls: {
1634
+ type: "array",
1635
+ minItems: 1,
1636
+ maxItems: 16,
1637
+ items: { type: "string", maxLength: 2048 }
1638
+ },
1639
+ ttl: {
1640
+ type: "integer",
1641
+ minimum: 1,
1642
+ maximum: 3600,
1643
+ description: "Effective remaining lifetime in seconds, clipped to identity, source credential and certificate expiry."
1644
+ }
1645
+ }
1646
+ },
1457
1647
  Client: jsonSchemaFor(clientSchema),
1458
1648
  Event: jsonSchemaFor(wsEventsSchema),
1459
1649
  ListClientsParams: jsonSchemaFor(listClientsParamsSchema),
@@ -1488,6 +1678,133 @@ var engineOpenApiDocument = {
1488
1678
  "read-managed-webtty-recordings": ["webtty.logs.read-only"]
1489
1679
  },
1490
1680
  paths: {
1681
+ "/.well-known/rstream/engine": {
1682
+ get: {
1683
+ tags: ["Discovery"],
1684
+ summary: "Discover the dedicated project mTLS API.",
1685
+ operationId: "get_engine_discovery",
1686
+ description: "Anonymous GET on the ordinary project Engine authority. Follow the returned HTTPS URL; do not infer an mTLS hostname. Cache per client using Cache-Control (normally one hour). No certificate, bearer token, cookie or redirect is required. Available only where an explicit EE mapping is configured.",
1687
+ security: [],
1688
+ responses: {
1689
+ "200": {
1690
+ description: "Project-bound discovery. Capabilities describe enabled server features; callers still need the corresponding rights.",
1691
+ headers: {
1692
+ "Cache-Control": {
1693
+ schema: { type: "string" },
1694
+ example: "public, max-age=3600"
1695
+ }
1696
+ },
1697
+ content: {
1698
+ "application/json": {
1699
+ schema: { $ref: "#/components/schemas/EngineDiscovery" }
1700
+ }
1701
+ }
1702
+ },
1703
+ "404": {
1704
+ description: "Dedicated mTLS API is not configured on this authority."
1705
+ }
1706
+ }
1707
+ }
1708
+ },
1709
+ "/api/turn-server/credentials": {
1710
+ post: {
1711
+ tags: ["TURN"],
1712
+ summary: "Issue short-lived project TURN credentials.",
1713
+ operationId: "create_engine_turn_credentials",
1714
+ description: "EE managed projects. Same request and response contract as Control plane project TURN issuance. Empty body is valid. Issued credentials require turn.credentials.create only; turn.relay.allocate applies to local PAT/APP derivation. Issuance does not allocate a relay and does not bypass relay quotas or revocation checks.",
1715
+ security: [{ bearerAuth: [] }, { mutualTLS: [] }],
1716
+ requestBody: {
1717
+ required: false,
1718
+ content: {
1719
+ "application/json": {
1720
+ schema: { $ref: "#/components/schemas/CreateTURNCredentials" }
1721
+ }
1722
+ }
1723
+ },
1724
+ responses: {
1725
+ "200": {
1726
+ description: "Short-lived project TURN credentials.",
1727
+ headers: {
1728
+ "Cache-Control": {
1729
+ schema: { type: "string" },
1730
+ example: "no-store"
1731
+ }
1732
+ },
1733
+ content: {
1734
+ "application/json": {
1735
+ schema: { $ref: "#/components/schemas/TURNCredentials" }
1736
+ }
1737
+ }
1738
+ },
1739
+ "400": {
1740
+ description: "Malformed, unknown or out-of-range request fields; body exceeds 4 KiB.",
1741
+ headers: {
1742
+ "Cache-Control": {
1743
+ schema: { type: "string" },
1744
+ example: "no-store"
1745
+ }
1746
+ }
1747
+ },
1748
+ "401": {
1749
+ description: "Missing, invalid, expired or mixed authentication proofs.",
1750
+ headers: {
1751
+ "Cache-Control": {
1752
+ schema: { type: "string" },
1753
+ example: "no-store"
1754
+ }
1755
+ }
1756
+ },
1757
+ "403": {
1758
+ description: "Missing turn.credentials.create permission, workspace membership or project resource scope.",
1759
+ headers: {
1760
+ "Cache-Control": {
1761
+ schema: { type: "string" },
1762
+ example: "no-store"
1763
+ }
1764
+ }
1765
+ },
1766
+ "404": {
1767
+ description: "Project not found.",
1768
+ headers: {
1769
+ "Cache-Control": {
1770
+ schema: { type: "string" },
1771
+ example: "no-store"
1772
+ }
1773
+ }
1774
+ },
1775
+ "409": {
1776
+ description: "Project or serving infrastructure is inactive.",
1777
+ headers: {
1778
+ "Cache-Control": {
1779
+ schema: { type: "string" },
1780
+ example: "no-store"
1781
+ }
1782
+ }
1783
+ },
1784
+ "429": {
1785
+ description: "Rate limit exceeded; do not retry through another issuer.",
1786
+ headers: {
1787
+ "Cache-Control": {
1788
+ schema: { type: "string" },
1789
+ example: "no-store"
1790
+ }
1791
+ }
1792
+ },
1793
+ "503": {
1794
+ description: "Authentication backend, serving metadata or issued keyring unavailable.",
1795
+ headers: {
1796
+ "Cache-Control": {
1797
+ schema: { type: "string" },
1798
+ example: "no-store"
1799
+ }
1800
+ }
1801
+ }
1802
+ },
1803
+ "x-rstream-required-rules": [
1804
+ { resource: "engine.turn-server.credentials", action: "create" }
1805
+ ]
1806
+ }
1807
+ },
1491
1808
  "/api/openapi.json": {
1492
1809
  get: {
1493
1810
  tags: ["Discovery"],
@@ -1514,7 +1831,7 @@ var engineOpenApiDocument = {
1514
1831
  summary: "List visible engine clients.",
1515
1832
  operationId: "list_engine_clients",
1516
1833
  parameters: [{ $ref: "#/components/parameters/ListClientsParams" }],
1517
- security: [{ bearerAuth: [] }],
1834
+ security: [{ bearerAuth: [] }, { mutualTLS: [] }],
1518
1835
  responses: {
1519
1836
  "200": jsonArrayResponse(
1520
1837
  "Visible clients.",
@@ -1533,7 +1850,7 @@ var engineOpenApiDocument = {
1533
1850
  summary: "List visible engine tunnels.",
1534
1851
  operationId: "list_engine_tunnels",
1535
1852
  parameters: [{ $ref: "#/components/parameters/ListTunnelsParams" }],
1536
- security: [{ bearerAuth: [] }],
1853
+ security: [{ bearerAuth: [] }, { mutualTLS: [] }],
1537
1854
  responses: {
1538
1855
  "200": jsonArrayResponse(
1539
1856
  "Visible tunnels. Fine-grained list scopes may filter objects and project fields.",
@@ -1553,7 +1870,7 @@ var engineOpenApiDocument = {
1553
1870
  summary: "Subscribe to visible engine state through Server-Sent Events.",
1554
1871
  operationId: "watch_engine_events_sse",
1555
1872
  parameters: [{ $ref: "#/components/parameters/WatchParams" }],
1556
- security: [{ bearerAuth: [] }],
1873
+ security: [{ bearerAuth: [] }, { mutualTLS: [] }],
1557
1874
  responses: {
1558
1875
  "200": streamResponse(
1559
1876
  "SSE stream. Each event is emitted as a data line containing a JSON Event object."
@@ -1572,7 +1889,7 @@ var engineOpenApiDocument = {
1572
1889
  summary: "Subscribe to visible engine state through a WebSocket upgrade.",
1573
1890
  operationId: "watch_engine_events_websocket",
1574
1891
  parameters: [{ $ref: "#/components/parameters/WatchParams" }],
1575
- security: [{ bearerAuth: [] }],
1892
+ security: [{ bearerAuth: [] }, { mutualTLS: [] }],
1576
1893
  responses: {
1577
1894
  "101": {
1578
1895
  description: "WebSocket connection accepted. Frames contain JSON Event objects."
@@ -1596,6 +1913,113 @@ var engineOpenApiJson = `${JSON.stringify(
1596
1913
 
1597
1914
  // src/resolution.ts
1598
1915
  var import_rstream3 = require("@rstreamlabs/rstream");
1916
+
1917
+ // src/http-lifetime.ts
1918
+ var requestLifetime = (parents, milliseconds) => {
1919
+ const controller = new AbortController();
1920
+ const signals = parents.filter((signal) => signal != null);
1921
+ const listeners = signals.map((signal) => ({
1922
+ signal,
1923
+ abort: () => controller.abort(signal.reason)
1924
+ }));
1925
+ for (const listener of listeners) {
1926
+ if (listener.signal.aborted) listener.abort();
1927
+ else
1928
+ listener.signal.addEventListener("abort", listener.abort, { once: true });
1929
+ }
1930
+ const timer = setTimeout(
1931
+ () => controller.abort(
1932
+ new DOMException("Engine request timed out.", "TimeoutError")
1933
+ ),
1934
+ milliseconds
1935
+ );
1936
+ return {
1937
+ signal: controller.signal,
1938
+ stopTimer: () => clearTimeout(timer),
1939
+ abort: () => controller.abort(
1940
+ new DOMException("Engine request closed.", "AbortError")
1941
+ ),
1942
+ close: () => {
1943
+ clearTimeout(timer);
1944
+ for (const listener of listeners)
1945
+ listener.signal.removeEventListener("abort", listener.abort);
1946
+ }
1947
+ };
1948
+ };
1949
+ var readChunks = async (reader, limit, chunks, size) => {
1950
+ const result = await reader.read();
1951
+ if (result.done) return chunks;
1952
+ const total = size + result.value.byteLength;
1953
+ if (total > limit) {
1954
+ await reader.cancel();
1955
+ throw new Error("HTTP response exceeds the size limit.");
1956
+ }
1957
+ chunks.push(result.value);
1958
+ return readChunks(reader, limit, chunks, total);
1959
+ };
1960
+ var readBoundedText = async (response, limit) => {
1961
+ if (Number(response.headers.get("content-length")) > limit) {
1962
+ await response.body?.cancel();
1963
+ throw new Error("HTTP response exceeds the size limit.");
1964
+ }
1965
+ if (!response.body) return "";
1966
+ const reader = response.body.getReader();
1967
+ const chunks = await readChunks(reader, limit, [], 0).finally(
1968
+ () => reader.releaseLock()
1969
+ );
1970
+ const bytes = new Uint8Array(
1971
+ chunks.reduce((size, chunk) => size + chunk.byteLength, 0)
1972
+ );
1973
+ chunks.reduce((offset, chunk) => {
1974
+ bytes.set(chunk, offset);
1975
+ return offset + chunk.byteLength;
1976
+ }, 0);
1977
+ return new TextDecoder().decode(bytes);
1978
+ };
1979
+ var waitWithSignal = async (promise, signal) => {
1980
+ if (!signal) return promise;
1981
+ if (signal.aborted) {
1982
+ void promise.catch(() => void 0);
1983
+ signal.throwIfAborted();
1984
+ }
1985
+ return new Promise((resolve, reject) => {
1986
+ const abort = () => reject(signal.reason);
1987
+ signal.addEventListener("abort", abort, { once: true });
1988
+ void promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
1989
+ });
1990
+ };
1991
+
1992
+ // src/control-plane-fetch.ts
1993
+ function createBoundedControlPlaneFetch(timeoutMilliseconds, parent, fetch2 = globalThis.fetch, maxResponseBytes = 16384) {
1994
+ return async (input, init) => {
1995
+ const request2 = new Request(input, init);
1996
+ const headers = new Headers(request2.headers);
1997
+ headers.set("Connection", "close");
1998
+ const lifetime = requestLifetime(
1999
+ [parent, request2.signal],
2000
+ timeoutMilliseconds
2001
+ );
2002
+ try {
2003
+ lifetime.signal.throwIfAborted();
2004
+ const response = await fetch2(input, {
2005
+ ...init,
2006
+ redirect: "manual",
2007
+ headers,
2008
+ signal: lifetime.signal
2009
+ });
2010
+ const responseBody2 = response.body === null ? null : await readBoundedText(response, maxResponseBytes);
2011
+ return new Response(responseBody2, {
2012
+ headers: Object.fromEntries(response.headers.entries()),
2013
+ status: response.status,
2014
+ statusText: response.statusText
2015
+ });
2016
+ } finally {
2017
+ lifetime.close();
2018
+ }
2019
+ };
2020
+ }
2021
+
2022
+ // src/resolution.ts
1599
2023
  var import_rstream4 = require("@rstreamlabs/rstream");
1600
2024
  var import_rstream5 = require("@rstreamlabs/rstream");
1601
2025
  var import_rstream6 = require("@rstreamlabs/rstream");
@@ -1729,6 +2153,12 @@ async function resolveManagedTunnelsProject(config) {
1729
2153
  );
1730
2154
  }
1731
2155
  const controlPlane = new import_rstream7.RstreamClient({
2156
+ fetch: createBoundedControlPlaneFetch(
2157
+ 1e4,
2158
+ void 0,
2159
+ globalThis.fetch,
2160
+ 1024 * 1024
2161
+ ),
1732
2162
  apiUrl: resolveTunnelsAPIURL(config.apiUrl),
1733
2163
  controlPlaneHeaders: config.controlPlaneHeaders,
1734
2164
  credentials
@@ -1769,62 +2199,231 @@ var RstreamTunnelsResource = class {
1769
2199
  };
1770
2200
 
1771
2201
  // src/tunnels.ts
1772
- var import_rstream12 = require("@rstreamlabs/rstream");
1773
- var import_rstream13 = require("@rstreamlabs/rstream");
1774
2202
  var import_rstream14 = require("@rstreamlabs/rstream");
2203
+ var import_rstream15 = require("@rstreamlabs/rstream");
1775
2204
 
1776
- // src/turn.ts
2205
+ // src/engine-http.ts
1777
2206
  var import_rstream8 = require("@rstreamlabs/rstream");
1778
- var import_rstream9 = require("@rstreamlabs/rstream");
1779
- var import_rstream10 = require("@rstreamlabs/rstream");
1780
- var import_turn = require("@rstreamlabs/rstream/turn");
1781
-
1782
- // src/control-plane-fetch.ts
1783
- function createRequestLifetime(parent, timeoutMilliseconds) {
1784
- const controller = new AbortController();
1785
- const abort = () => controller.abort(parent.reason);
1786
- if (parent.aborted) {
1787
- abort();
1788
- } else {
1789
- parent.addEventListener("abort", abort, { once: true });
1790
- }
1791
- const timeout = setTimeout(
1792
- () => controller.abort(new Error("Control plane request timed out.")),
1793
- timeoutMilliseconds
1794
- );
1795
- return {
1796
- close: () => {
1797
- clearTimeout(timeout);
1798
- parent.removeEventListener("abort", abort);
1799
- },
1800
- signal: controller.signal
1801
- };
1802
- }
1803
- function createBoundedControlPlaneFetch(timeoutMilliseconds) {
1804
- return async (input, init) => {
1805
- const request = new Request(input, init);
1806
- const headers = new Headers(request.headers);
1807
- headers.set("Connection", "close");
1808
- const lifetime = createRequestLifetime(request.signal, timeoutMilliseconds);
2207
+ var import_zod = require("zod");
2208
+ var discoverySchema = import_zod.z.object({
2209
+ version: import_zod.z.literal(1),
2210
+ projectEndpoint: import_zod.z.string(),
2211
+ mtlsApiUrl: import_zod.z.string(),
2212
+ capabilities: import_zod.z.array(import_zod.z.string()).max(32)
2213
+ });
2214
+ var hasClientCertificate = (tls) => tls?.cert !== void 0 || tls?.key !== void 0;
2215
+ var validateMTLSURL = (target, source) => {
2216
+ const url = new URL(target);
2217
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/api" || url.hostname.split(".")[0] !== source.hostname.split(".")[0])
2218
+ throw new Error("Invalid discovered mTLS Engine API URL.");
2219
+ normalizeEngineAddress(url.host);
2220
+ return url.href;
2221
+ };
2222
+ var discoveryCacheMilliseconds = (response) => {
2223
+ const cache = response.headers.get("cache-control") ?? "";
2224
+ if (/\bno-(?:store|cache)\b/i.test(cache)) return 0;
2225
+ const seconds = cache.match(/\bmax-age\s*=\s*"?(\d+)/i)?.[1];
2226
+ return Math.min(seconds === void 0 ? 3600 : Number(seconds), 86400) * 1e3;
2227
+ };
2228
+ var EngineHTTP = class {
2229
+ constructor(options) {
2230
+ this.options = options;
2231
+ if (hasClientCertificate(options.tls) && (!options.tls?.cert || !options.tls?.key))
2232
+ throw new Error("mTLS requires both a certificate and its private key.");
2233
+ }
2234
+ options;
2235
+ closed = new AbortController();
2236
+ authenticated;
2237
+ anonymous;
2238
+ discovery;
2239
+ flight;
2240
+ get usesMTLS() {
2241
+ return hasClientCertificate(this.options.tls);
2242
+ }
2243
+ async close() {
2244
+ this.closed.abort(new Error("Engine HTTP client is closed."));
2245
+ this.discovery = void 0;
2246
+ await Promise.all([
2247
+ this.authenticated?.then((client) => client.close()),
2248
+ this.anonymous?.then((client) => client.close())
2249
+ ]);
2250
+ }
2251
+ async transport(anonymous) {
2252
+ this.closed.signal.throwIfAborted();
2253
+ if (!anonymous && this.options.fetch) return this.options.fetch;
2254
+ if (!this.options.tls) return globalThis.fetch;
2255
+ if (anonymous) {
2256
+ this.anonymous ??= Promise.resolve().then(() => (init_node_engine_http(), node_engine_http_exports)).then(
2257
+ ({ NodeEngineHTTP: NodeEngineHTTP2 }) => new NodeEngineHTTP2({
2258
+ ca: this.options.tls?.ca,
2259
+ rejectUnauthorized: true
2260
+ })
2261
+ );
2262
+ return (await this.anonymous).fetch;
2263
+ }
2264
+ this.authenticated ??= Promise.resolve().then(() => (init_node_engine_http(), node_engine_http_exports)).then(
2265
+ ({ NodeEngineHTTP: NodeEngineHTTP2 }) => new NodeEngineHTTP2(this.options.tls ?? {})
2266
+ );
2267
+ return (await this.authenticated).fetch;
2268
+ }
2269
+ async apiURL(engine, signal) {
2270
+ this.closed.signal.throwIfAborted();
2271
+ signal?.throwIfAborted();
2272
+ const source = new URL(`https://${normalizeEngineAddress(engine)}`);
2273
+ if (!this.usesMTLS) return `https://${normalizeEngineAddress(engine)}/api`;
2274
+ if (this.options.mtlsApiUrl)
2275
+ return validateMTLSURL(this.options.mtlsApiUrl, source);
2276
+ if (this.discovery?.source === source.origin && this.discovery.expiresAt > Date.now())
2277
+ return this.discovery.url;
2278
+ const flight = this.flight?.source === source.origin ? this.flight : { source: source.origin, promise: this.discover(source) };
2279
+ this.flight = flight;
1809
2280
  try {
1810
- const response = await globalThis.fetch(request, {
2281
+ const discovery = await waitWithSignal(flight.promise, signal);
2282
+ this.discovery = discovery;
2283
+ return discovery.url;
2284
+ } finally {
2285
+ void flight.promise.then(
2286
+ () => {
2287
+ if (this.flight === flight) this.flight = void 0;
2288
+ },
2289
+ () => {
2290
+ if (this.flight === flight) this.flight = void 0;
2291
+ }
2292
+ );
2293
+ }
2294
+ }
2295
+ async discover(source) {
2296
+ const lifetime = requestLifetime([this.closed.signal], 5e3);
2297
+ try {
2298
+ const fetch2 = await this.transport(true);
2299
+ const response = await fetch2(
2300
+ new URL("/.well-known/rstream/engine", source),
2301
+ {
2302
+ method: "GET",
2303
+ credentials: "omit",
2304
+ redirect: "manual",
2305
+ signal: lifetime.signal
2306
+ }
2307
+ );
2308
+ if (!response.ok) {
2309
+ await response.body?.cancel();
2310
+ throw new import_rstream8.RstreamHTTPError(
2311
+ response.status,
2312
+ "mTLS Engine discovery failed."
2313
+ );
2314
+ }
2315
+ const text = await readBoundedText(response, 16384);
2316
+ const value = JSON.parse(text);
2317
+ const document = discoverySchema.parse(value);
2318
+ if (document.projectEndpoint !== source.hostname.split(".")[0] || !document.capabilities.includes("engine-api-mtls"))
2319
+ throw new Error(
2320
+ "Engine discovery does not advertise mTLS for this project."
2321
+ );
2322
+ return {
2323
+ source: source.origin,
2324
+ url: validateMTLSURL(document.mtlsApiUrl, source),
2325
+ expiresAt: Date.now() + discoveryCacheMilliseconds(response)
2326
+ };
2327
+ } finally {
2328
+ lifetime.close();
2329
+ }
2330
+ }
2331
+ async eventStream(engine, options) {
2332
+ const lifetime = requestLifetime(
2333
+ [this.closed.signal, options.signal],
2334
+ 1e4
2335
+ );
2336
+ try {
2337
+ const base = await this.apiURL(engine, lifetime.signal);
2338
+ const headers = new Headers(options.headers);
2339
+ if (this.usesMTLS && headers.has("authorization"))
2340
+ throw new Error("Use a token or mTLS, not both.");
2341
+ headers.set("Accept", "text/event-stream");
2342
+ const fetch2 = await this.transport(false);
2343
+ const response = await fetch2(`${base}/sse`, {
2344
+ method: "GET",
1811
2345
  headers,
2346
+ credentials: "omit",
2347
+ redirect: "manual",
1812
2348
  signal: lifetime.signal
1813
2349
  });
1814
- const responseBody = response.body === null ? null : new Uint8Array(await response.arrayBuffer());
1815
- return new Response(responseBody, {
1816
- headers: Object.fromEntries(response.headers.entries()),
2350
+ if (!response.ok)
2351
+ throw new import_rstream8.RstreamHTTPError(
2352
+ response.status,
2353
+ await readBoundedText(response, 16384)
2354
+ );
2355
+ if (!response.headers.get("content-type")?.startsWith("text/event-stream"))
2356
+ throw new Error("Engine returned an invalid event stream.");
2357
+ lifetime.stopTimer();
2358
+ return {
2359
+ response,
2360
+ close: () => {
2361
+ lifetime.abort();
2362
+ lifetime.close();
2363
+ }
2364
+ };
2365
+ } catch (error) {
2366
+ lifetime.abort();
2367
+ lifetime.close();
2368
+ if (!(error instanceof import_rstream8.RstreamHTTPError)) this.discovery = void 0;
2369
+ throw error;
2370
+ }
2371
+ }
2372
+ async response(engine, path, options = {}, maxBytes = 16 * 1024 * 1024) {
2373
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\"))
2374
+ throw new Error("Engine request path must be a relative absolute path.");
2375
+ const lifetime = requestLifetime(
2376
+ [this.closed.signal, options.signal],
2377
+ 1e4
2378
+ );
2379
+ try {
2380
+ const base = await this.apiURL(engine, lifetime.signal);
2381
+ const url = new URL(`${base}${path}`);
2382
+ if (url.origin !== new URL(base).origin || !url.pathname.startsWith("/api/") || url.username || url.password)
2383
+ throw new Error(
2384
+ "Engine request path must remain inside the Engine API."
2385
+ );
2386
+ const headers = new Headers(options.headers);
2387
+ if (this.usesMTLS && (headers.has("authorization") || url.searchParams.has("rstream.token")))
2388
+ throw new Error("Use a token or mTLS, not both.");
2389
+ const fetch2 = await this.transport(false);
2390
+ const response = await fetch2(`${base}${path}`, {
2391
+ ...options,
2392
+ headers,
2393
+ credentials: "omit",
2394
+ redirect: "manual",
2395
+ signal: lifetime.signal
2396
+ });
2397
+ const text = await readBoundedText(
2398
+ response,
2399
+ response.ok ? maxBytes : 16384
2400
+ );
2401
+ if (!response.ok) throw new import_rstream8.RstreamHTTPError(response.status, text);
2402
+ return new Response([204, 205].includes(response.status) ? null : text, {
1817
2403
  status: response.status,
1818
- statusText: response.statusText
2404
+ headers: response.headers
1819
2405
  });
2406
+ } catch (error) {
2407
+ if (!(error instanceof import_rstream8.RstreamHTTPError)) this.discovery = void 0;
2408
+ throw error;
1820
2409
  } finally {
1821
2410
  lifetime.close();
1822
2411
  }
1823
- };
1824
- }
2412
+ }
2413
+ };
2414
+
2415
+ // src/tunnels.ts
2416
+ var import_rstream16 = require("@rstreamlabs/rstream");
2417
+ var import_rstream17 = require("@rstreamlabs/rstream");
1825
2418
 
1826
2419
  // src/turn.ts
1827
- var z8 = __toESM(require("zod"));
2420
+ var import_rstream9 = require("@rstreamlabs/rstream");
2421
+ var import_rstream10 = require("@rstreamlabs/rstream");
2422
+ var import_rstream11 = require("@rstreamlabs/rstream");
2423
+ var import_rstream12 = require("@rstreamlabs/rstream");
2424
+ var import_rstream13 = require("@rstreamlabs/rstream");
2425
+ var import_turn = require("@rstreamlabs/rstream/turn");
2426
+ var z9 = __toESM(require("zod"));
1828
2427
  var import_crypto = __toESM(require("crypto"));
1829
2428
  var defaultTURNPort = 3478;
1830
2429
  var defaultTURNSPort = 5349;
@@ -1833,20 +2432,31 @@ var maxTURNCredentialTTLSeconds = 60 * 60;
1833
2432
  var maxTURNKeyringBytes = 8 * 1024;
1834
2433
  var defaultAPIRequestTimeoutMilliseconds = 1e4;
1835
2434
  var maxAPIRequestTimeoutMilliseconds = 6e4;
1836
- var turnTokenClaimsSchema = z8.object({
1837
- exp: z8.number().int().optional(),
1838
- iat: z8.number().int().optional(),
1839
- token_endpoint: z8.string().regex(/^[0-9a-f]{8}$/i).optional(),
1840
- tokendpoint: z8.string().regex(/^[0-9a-f]{8}$/i).optional(),
1841
- type: z8.enum(["app", "auth", "pat"])
2435
+ var turnTokenClaimsSchema = z9.object({
2436
+ permissions: z9.array(z9.string()).nullable().optional(),
2437
+ exp: z9.number().int().optional(),
2438
+ iat: z9.number().int().optional(),
2439
+ token_endpoint: z9.string().regex(/^[A-Za-z0-9_.-]{1,128}$/).optional(),
2440
+ tokendpoint: z9.string().regex(/^[A-Za-z0-9_.-]{1,128}$/).optional(),
2441
+ type: z9.enum(["app", "auth", "pat"])
1842
2442
  });
1843
- var turnCredentialModeSchema = z8.enum(["api", "app", "pat"]);
2443
+ var turnCredentialModeSchema = z9.enum([
2444
+ "auto",
2445
+ "api",
2446
+ "engine",
2447
+ "app",
2448
+ "pat"
2449
+ ]);
1844
2450
  function normalizeOptionalString2(value) {
1845
2451
  const normalized = value?.trim();
1846
2452
  return normalized ? normalized : void 0;
1847
2453
  }
1848
2454
  function normalizeClusterDomain(clusterDomain) {
1849
2455
  const normalized = normalizeOptionalString2(clusterDomain);
2456
+ if (normalized && (normalized.length > 253 || !normalized.split(".").every(
2457
+ (label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)
2458
+ )))
2459
+ throw new Error("TURN domain and realm must be valid DNS names.");
1850
2460
  return normalized?.toLowerCase();
1851
2461
  }
1852
2462
  function normalizeNow(now) {
@@ -1961,14 +2571,27 @@ function requireOption(value, message) {
1961
2571
  }
1962
2572
  throw new Error(message);
1963
2573
  }
1964
- function resolveMode(credentials, mode) {
1965
- if (mode !== void 0) {
1966
- return turnCredentialModeSchema.parse(mode);
1967
- }
1968
- if ((0, import_rstream8.isClientCredentials)(credentials)) {
1969
- return "app";
2574
+ function automaticPATClaims(token) {
2575
+ try {
2576
+ return parseTURNTokenClaims(token);
2577
+ } catch {
2578
+ return void 0;
1970
2579
  }
1971
- return "api";
2580
+ }
2581
+ function resolveMode(options) {
2582
+ const requested = turnCredentialModeSchema.parse(options.mode ?? "auto");
2583
+ if (requested !== "auto") return requested;
2584
+ if (options.client?.usesMTLS || hasClientCertificate(options.tls))
2585
+ return "engine";
2586
+ const claims = (0, import_rstream11.isTokenCredentials)(options.credentials) ? automaticPATClaims(options.credentials.token) : void 0;
2587
+ if (claims?.token_endpoint && claims.tokendpoint && claims.token_endpoint !== claims.tokendpoint)
2588
+ throw new Error("Conflicting PAT token endpoints.");
2589
+ const localTarget = options.projectEndpoint && (options.clusterDomain || options.turnDomain && options.turnRealm);
2590
+ if (!localTarget) return "auto";
2591
+ if ((0, import_rstream9.isClientCredentials)(options.credentials)) return "app";
2592
+ if (claims?.type === "pat" && claims.exp !== void 0 && (claims.token_endpoint || claims.tokendpoint) && (claims.permissions === null || claims.permissions?.includes("turn.relay.allocate")))
2593
+ return "pat";
2594
+ return "auto";
1972
2595
  }
1973
2596
  async function readBoundedTURNKeyring(reader, chunks = [], size = 0) {
1974
2597
  const result = await reader.read();
@@ -1988,7 +2611,7 @@ async function readBoundedTURNKeyring(reader, chunks = [], size = 0) {
1988
2611
  }
1989
2612
  function parseTURNServerPublicKeyHex(value) {
1990
2613
  const normalized = value.trim();
1991
- if (normalized.length === 0 || normalized.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(normalized)) {
2614
+ if (normalized.length === 0 || normalized.length > maxTURNKeyringBytes || normalized.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(normalized)) {
1992
2615
  throw new Error("TURN keyring must contain hexadecimal DER key material.");
1993
2616
  }
1994
2617
  try {
@@ -2011,6 +2634,10 @@ function parseTURNServerPublicKeyHex(value) {
2011
2634
  }
2012
2635
  }
2013
2636
  function parseAPPPrivateKey(value) {
2637
+ if (value.length > 16384 || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value))
2638
+ throw new Error(
2639
+ "TURN APP client secret must contain hexadecimal DER key material."
2640
+ );
2014
2641
  try {
2015
2642
  const key = import_crypto.default.createPrivateKey({
2016
2643
  key: Buffer.from(value, "hex"),
@@ -2046,19 +2673,38 @@ async function loadTURNServerPublicKeyHex(clusterDomain, options) {
2046
2673
  `/keyrings/turn/${clusterDomain}.spki.der.hex`,
2047
2674
  keyringBaseURL
2048
2675
  );
2049
- const response = await fetch(url, { redirect: "manual" });
2050
- if (response.status >= 300 && response.status < 400) {
2676
+ if (url.protocol !== "https:" || url.username || url.password)
2051
2677
  throw new Error(
2052
- "TURN keyring request was redirected; redirects are refused."
2678
+ "TURN keyring requests require HTTPS without URL credentials."
2053
2679
  );
2680
+ const lifetime = requestLifetime(
2681
+ [options.signal],
2682
+ normalizeAPIRequestTimeoutMilliseconds(options.requestTimeoutMilliseconds)
2683
+ );
2684
+ try {
2685
+ const response = await fetch(url, {
2686
+ redirect: "manual",
2687
+ credentials: "omit",
2688
+ signal: lifetime.signal
2689
+ });
2690
+ if (response.status >= 300 && response.status < 400) {
2691
+ await response.body?.cancel();
2692
+ throw new Error(
2693
+ "TURN keyring request was redirected; redirects are refused."
2694
+ );
2695
+ }
2696
+ if (!response.ok) {
2697
+ await response.body?.cancel();
2698
+ throw new Error(`TURN keyring request returned HTTP ${response.status}.`);
2699
+ }
2700
+ if (!response.body) {
2701
+ throw new Error("TURN keyring response body is missing.");
2702
+ }
2703
+ const reader = response.body.getReader();
2704
+ return (await readBoundedTURNKeyring(reader).finally(() => reader.releaseLock())).trim();
2705
+ } finally {
2706
+ lifetime.close();
2054
2707
  }
2055
- if (!response.ok) {
2056
- throw new Error(`TURN keyring request returned HTTP ${response.status}.`);
2057
- }
2058
- if (!response.body) {
2059
- throw new Error("TURN keyring response body is missing.");
2060
- }
2061
- return (await readBoundedTURNKeyring(response.body.getReader())).trim();
2062
2708
  }
2063
2709
  async function createAPITURNCredentials(options) {
2064
2710
  const projectId = normalizeOptionalString2(options.projectId);
@@ -2072,11 +2718,21 @@ async function createAPITURNCredentials(options) {
2072
2718
  options.requestTimeoutMilliseconds
2073
2719
  );
2074
2720
  const params = createAPITURNCredentialsParams(options.ttlSeconds);
2075
- const request = options.fetch ?? createBoundedControlPlaneFetch(requestTimeoutMilliseconds);
2076
- const client = new import_rstream10.RstreamClient({
2721
+ const request2 = createBoundedControlPlaneFetch(
2722
+ requestTimeoutMilliseconds,
2723
+ options.signal,
2724
+ options.fetch
2725
+ );
2726
+ const client = new import_rstream12.RstreamClient({
2727
+ controlPlaneHeaders: options.controlPlaneHeaders,
2077
2728
  apiUrl: options.apiUrl,
2078
- credentials: options.credentials,
2079
- fetch: request
2729
+ credentials: (0, import_rstream9.isClientCredentials)(options.credentials) ? {
2730
+ token: (0, import_rstream10.createClientCredentialsToken)(options.credentials, {
2731
+ expiresInSeconds: 60,
2732
+ claims: { permissions: ["turn.credentials.create"] }
2733
+ }).token
2734
+ } : options.credentials,
2735
+ fetch: request2
2080
2736
  });
2081
2737
  if (projectId) {
2082
2738
  return await client.tunnels.projects.createTurnCredentials(
@@ -2119,6 +2775,8 @@ function createPATTURNCredentials(options) {
2119
2775
  const claimsTokenEndpoint = normalizeOptionalString2(
2120
2776
  claims.token_endpoint ?? claims.tokendpoint
2121
2777
  );
2778
+ if (claims.token_endpoint && claims.tokendpoint && claims.token_endpoint !== claims.tokendpoint)
2779
+ throw new Error("Conflicting PAT token endpoints.");
2122
2780
  const configuredTokenEndpoint = normalizeOptionalString2(
2123
2781
  options.tokenEndpoint
2124
2782
  );
@@ -2128,6 +2786,8 @@ function createPATTURNCredentials(options) {
2128
2786
  "TURN PAT mode requires a PAT token carrying a token endpoint."
2129
2787
  );
2130
2788
  }
2789
+ requireTURNReference(projectEndpoint);
2790
+ requireTURNReference(tokenEndpoint);
2131
2791
  if (configuredTokenEndpoint !== void 0 && claimsTokenEndpoint !== void 0 && claimsTokenEndpoint !== configuredTokenEndpoint) {
2132
2792
  throw new Error("Token endpoint does not match the PAT token.");
2133
2793
  }
@@ -2172,6 +2832,8 @@ async function createAPPTURNCredentials(options) {
2172
2832
  if (!clientId) {
2173
2833
  throw new Error("Client ID is required for TURN APP mode.");
2174
2834
  }
2835
+ requireTURNReference(projectEndpoint);
2836
+ requireTURNReference(clientId);
2175
2837
  const clientSecret = normalizeOptionalString2(options.clientSecret);
2176
2838
  if (!clientSecret) {
2177
2839
  throw new Error("Client secret is required for TURN APP mode.");
@@ -2204,32 +2866,47 @@ async function createAPPTURNCredentials(options) {
2204
2866
  username
2205
2867
  });
2206
2868
  }
2207
- async function createTURNCredentials(options) {
2869
+ async function createTURNCredentialsForLifetime(options) {
2208
2870
  const credentials = options.credentials;
2209
- const mode = resolveMode(credentials, options.mode);
2871
+ const mode = resolveMode(options);
2210
2872
  const projectEndpoint = normalizeOptionalString2(options.projectEndpoint);
2211
- if (mode === "api") {
2873
+ if (mode === "engine") return createEngineTURNCredentials(options);
2874
+ if (options.client?.usesMTLS || hasClientCertificate(options.tls))
2875
+ throw new Error(
2876
+ "Certificate authentication requires automatic or Engine TURN mode."
2877
+ );
2878
+ if (mode === "api" || mode === "auto") {
2212
2879
  const controlPlaneCredentials = options.controlPlaneCredentials ?? credentials;
2213
2880
  if (!controlPlaneCredentials) {
2881
+ if (mode === "auto" && (options.client || options.engine))
2882
+ return createEngineTURNCredentials(options);
2214
2883
  throw new Error(
2215
2884
  "Control-plane credentials are required for TURN API mode."
2216
2885
  );
2217
2886
  }
2218
- return await createAPITURNCredentials({
2219
- apiUrl: options.apiUrl,
2220
- credentials: controlPlaneCredentials,
2221
- fetch: options.fetch,
2222
- projectEndpoint,
2223
- projectId: options.projectId,
2224
- requestTimeoutMilliseconds: options.requestTimeoutMilliseconds,
2225
- ttlSeconds: options.ttlSeconds
2226
- });
2887
+ try {
2888
+ return await createAPITURNCredentials({
2889
+ controlPlaneHeaders: options.controlPlaneHeaders,
2890
+ apiUrl: options.apiUrl,
2891
+ credentials: controlPlaneCredentials,
2892
+ fetch: options.fetch,
2893
+ projectEndpoint,
2894
+ projectId: options.projectId,
2895
+ requestTimeoutMilliseconds: mode === "auto" ? Math.min(options.requestTimeoutMilliseconds ?? 1e4, 5e3) : options.requestTimeoutMilliseconds,
2896
+ signal: options.signal,
2897
+ ttlSeconds: options.ttlSeconds
2898
+ });
2899
+ } catch (error) {
2900
+ if (mode !== "auto" || !projectEndpoint || !(options.client?.configuredEngine || options.engine) || !turnEngineFallbackAllowed(error, options.signal))
2901
+ throw error;
2902
+ return createEngineTURNCredentials(options);
2903
+ }
2227
2904
  }
2228
2905
  const legacyDomain = normalizeClusterDomain(options.clusterDomain);
2229
2906
  const turnDomain = normalizeClusterDomain(options.turnDomain) ?? legacyDomain ?? (projectEndpoint && options.engine ? deriveClusterDomainFromEngine(projectEndpoint, options.engine) : void 0);
2230
2907
  const turnRealm = normalizeClusterDomain(options.turnRealm) ?? legacyDomain;
2231
2908
  if (mode === "pat") {
2232
- if (!(0, import_rstream9.isTokenCredentials)(credentials)) {
2909
+ if (!(0, import_rstream11.isTokenCredentials)(credentials)) {
2233
2910
  throw new Error("A PAT token is required for TURN PAT mode.");
2234
2911
  }
2235
2912
  return createPATTURNCredentials({
@@ -2253,10 +2930,12 @@ async function createTURNCredentials(options) {
2253
2930
  turnsPort: options.turnsPort
2254
2931
  });
2255
2932
  }
2256
- if (!(0, import_rstream8.isClientCredentials)(credentials)) {
2933
+ if (!(0, import_rstream9.isClientCredentials)(credentials)) {
2257
2934
  throw new Error("Client credentials are required for TURN APP mode.");
2258
2935
  }
2259
2936
  return await createAPPTURNCredentials({
2937
+ signal: options.signal,
2938
+ requestTimeoutMilliseconds: options.requestTimeoutMilliseconds,
2260
2939
  apiUrl: options.apiUrl,
2261
2940
  clientId: credentials.clientId,
2262
2941
  clientSecret: credentials.clientSecret,
@@ -2280,25 +2959,139 @@ async function createTURNCredentials(options) {
2280
2959
  turnsPort: options.turnsPort
2281
2960
  });
2282
2961
  }
2962
+ function requireTURNReference(value) {
2963
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(value) || value === "-")
2964
+ throw new Error("Invalid TURN project or credential reference.");
2965
+ }
2966
+ function turnEngineFallbackAllowed(error, signal) {
2967
+ if (signal?.aborted) return false;
2968
+ if (error instanceof import_rstream13.RstreamHTTPError)
2969
+ return [502, 503, 504].includes(error.status);
2970
+ if (error instanceof DOMException && error.name === "TimeoutError")
2971
+ return true;
2972
+ if (typeof error !== "object" || error === null) return false;
2973
+ if ("code" in error && typeof error.code === "string")
2974
+ return [
2975
+ "ENOTFOUND",
2976
+ "EAI_AGAIN",
2977
+ "ECONNREFUSED",
2978
+ "ECONNRESET",
2979
+ "ETIMEDOUT",
2980
+ "EHOSTUNREACH",
2981
+ "ENETUNREACH",
2982
+ "UND_ERR_CONNECT_TIMEOUT",
2983
+ "UND_ERR_HEADERS_TIMEOUT",
2984
+ "UND_ERR_SOCKET"
2985
+ ].includes(error.code);
2986
+ return "cause" in error && error.cause !== error ? turnEngineFallbackAllowed(error.cause, signal) : false;
2987
+ }
2988
+ async function createEngineTURNCredentials(options) {
2989
+ const client = options.client ?? new RstreamTunnelsClient({
2990
+ credentials: options.credentials,
2991
+ tls: options.tls,
2992
+ mtlsApiUrl: options.mtlsApiUrl,
2993
+ engine: options.engine,
2994
+ projectEndpoint: options.projectEndpoint,
2995
+ projectId: options.projectId
2996
+ });
2997
+ try {
2998
+ const engine = await client.getEngine();
2999
+ const endpoint = new URL(`https://${engine}`).hostname.split(".")[0];
3000
+ if (options.projectEndpoint && options.projectEndpoint !== endpoint || options.projectId && !options.projectEndpoint)
3001
+ throw new Error("TURN project endpoint must match the Engine client.");
3002
+ if (options.engine && options.engine !== engine)
3003
+ throw new Error("TURN Engine must match the reusable client.");
3004
+ const value = await client.request(
3005
+ "/turn-server/credentials",
3006
+ {
3007
+ method: "POST",
3008
+ signal: options.signal,
3009
+ headers: { "Content-Type": "application/json" },
3010
+ body: JSON.stringify(
3011
+ createAPITURNCredentialsParams(options.ttlSeconds)
3012
+ )
3013
+ },
3014
+ 16384
3015
+ );
3016
+ return import_turn.turnCredentialsSchema.parse(value);
3017
+ } finally {
3018
+ if (!options.client) await client.close();
3019
+ }
3020
+ }
3021
+ function validateTURNCredentialsRequest(options) {
3022
+ normalizeTTLSeconds(options.ttlSeconds);
3023
+ turnCredentialModeSchema.parse(options.mode ?? "auto");
3024
+ return normalizeAPIRequestTimeoutMilliseconds(
3025
+ options.requestTimeoutMilliseconds
3026
+ );
3027
+ }
3028
+ async function createTURNCredentials(options) {
3029
+ const lifetime = requestLifetime(
3030
+ [options.signal],
3031
+ validateTURNCredentialsRequest(options)
3032
+ );
3033
+ try {
3034
+ lifetime.signal.throwIfAborted();
3035
+ if ((options.client?.usesMTLS || hasClientCertificate(options.tls)) && options.credentials)
3036
+ throw new Error("Use a token or mTLS, not both.");
3037
+ if (options.client && options.credentials && !sameTURNCredentials(options.client.credentials, options.credentials))
3038
+ throw new Error(
3039
+ "TURN credentials must match the reusable Engine client."
3040
+ );
3041
+ return await waitWithSignal(
3042
+ createTURNCredentialsForLifetime({
3043
+ ...options,
3044
+ credentials: options.credentials ?? options.client?.credentials,
3045
+ signal: lifetime.signal
3046
+ }),
3047
+ lifetime.signal
3048
+ );
3049
+ } finally {
3050
+ lifetime.close();
3051
+ }
3052
+ }
3053
+ function sameTURNCredentials(left, right) {
3054
+ if ((0, import_rstream11.isTokenCredentials)(left) && (0, import_rstream11.isTokenCredentials)(right))
3055
+ return left.token === right.token;
3056
+ return (0, import_rstream9.isClientCredentials)(left) && (0, import_rstream9.isClientCredentials)(right) && left.clientId === right.clientId && left.clientSecret === right.clientSecret;
3057
+ }
2283
3058
 
2284
3059
  // src/turn-resource.ts
2285
- var import_rstream11 = require("@rstreamlabs/rstream");
2286
3060
  var RstreamTURNResource = class {
2287
3061
  client;
2288
3062
  constructor(client) {
2289
3063
  this.client = client;
2290
3064
  }
2291
3065
  async createCredentials(options = {}) {
3066
+ const lifetime = requestLifetime(
3067
+ [options.signal],
3068
+ validateTURNCredentialsRequest(options)
3069
+ );
3070
+ try {
3071
+ lifetime.signal.throwIfAborted();
3072
+ return await waitWithSignal(
3073
+ this.createForLifetime({ ...options, signal: lifetime.signal }),
3074
+ lifetime.signal
3075
+ );
3076
+ } finally {
3077
+ lifetime.close();
3078
+ }
3079
+ }
3080
+ async createForLifetime(options) {
2292
3081
  const credentials = options.credentials ?? this.client.credentials;
2293
- const localMode = options.mode === "app" || options.mode === "pat" || options.mode === void 0 && (0, import_rstream11.isClientCredentials)(credentials);
2294
- const target = localMode && (options.turnDomain === void 0 || options.turnRealm === void 0) && options.clusterDomain === void 0 ? await this.client.getTURNTarget() : void 0;
2295
- const engine = options.engine ?? (options.clusterDomain === void 0 && options.turnDomain === void 0 && target === void 0 ? await this.client.getEngine().catch(() => void 0) : void 0);
3082
+ const localMode = options.mode === "app" || options.mode === "pat";
3083
+ const target = localMode && !this.client.usesMTLS && (options.turnDomain === void 0 || options.turnRealm === void 0) && options.clusterDomain === void 0 ? await this.client.getTURNTarget() : void 0;
2296
3084
  return await createTURNCredentials({
3085
+ client: this.client,
3086
+ signal: options.signal,
2297
3087
  apiUrl: options.apiUrl ?? this.client.apiUrl,
3088
+ fetch: options.fetch,
3089
+ requestTimeoutMilliseconds: options.requestTimeoutMilliseconds,
2298
3090
  clusterDomain: options.clusterDomain,
3091
+ controlPlaneHeaders: options.controlPlaneHeaders ?? this.client.controlPlaneHeaders,
2299
3092
  controlPlaneCredentials: options.controlPlaneCredentials ?? this.client.controlPlaneCredentials,
2300
3093
  credentials,
2301
- engine,
3094
+ engine: options.engine,
2302
3095
  keyringBaseUrl: options.keyringBaseUrl,
2303
3096
  mode: options.mode,
2304
3097
  now: options.now,
@@ -2467,31 +3260,52 @@ var engineAuthTokenScopes = {
2467
3260
  var RstreamTunnelsClient = class {
2468
3261
  config;
2469
3262
  managedProject;
3263
+ http;
2470
3264
  constructor(config) {
2471
3265
  this.config = config;
3266
+ if (hasClientCertificate(config?.tls) && config?.credentials)
3267
+ throw new Error("Use a token or mTLS, not both.");
3268
+ this.http = new EngineHTTP(config ?? {});
2472
3269
  }
2473
3270
  get apiUrl() {
2474
3271
  return resolveTunnelsAPIURL(this.config?.apiUrl);
2475
3272
  }
2476
3273
  get credentials() {
3274
+ if (this.http.usesMTLS) return void 0;
2477
3275
  return resolveTunnelsCredentials(this.config?.credentials);
2478
3276
  }
2479
3277
  get controlPlaneCredentials() {
3278
+ if (this.http.usesMTLS) return void 0;
2480
3279
  return resolveControlPlaneCredentials(
2481
3280
  this.config?.controlPlaneCredentials,
2482
3281
  this.credentials
2483
3282
  );
2484
3283
  }
3284
+ get controlPlaneHeaders() {
3285
+ return this.config?.controlPlaneHeaders;
3286
+ }
2485
3287
  get projectEndpoint() {
2486
3288
  return this.config?.projectEndpoint;
2487
3289
  }
3290
+ get configuredEngine() {
3291
+ return this.config?.engine ?? (0, import_rstream17.readEnvironment)().engine;
3292
+ }
3293
+ get usesMTLS() {
3294
+ return this.http.usesMTLS;
3295
+ }
3296
+ async close() {
3297
+ await this.http.close();
3298
+ }
3299
+ async getEngineAPIURL(signal) {
3300
+ return this.http.apiURL(await this.getEngine(), signal);
3301
+ }
2488
3302
  async getEngine() {
2489
3303
  const region = resolveTunnelsRegion(this.config?.region);
2490
3304
  const projectEndpoint = normalizeOptionalString3(
2491
3305
  this.config?.projectEndpoint
2492
3306
  );
2493
3307
  if (region !== void 0) {
2494
- if (normalizeOptionalString3(this.config?.engine) !== void 0 || (0, import_rstream14.readEnvironment)().engine !== void 0) {
3308
+ if (normalizeOptionalString3(this.config?.engine) !== void 0 || (0, import_rstream17.readEnvironment)().engine !== void 0) {
2495
3309
  throw new Error(
2496
3310
  "Region selection cannot be combined with an explicit engine override."
2497
3311
  );
@@ -2503,17 +3317,17 @@ var RstreamTunnelsClient = class {
2503
3317
  }
2504
3318
  const project = await this.getManagedProject();
2505
3319
  if (project !== void 0) {
2506
- const engine = (0, import_rstream12.getTunnelsProjectEngine)(project, region);
3320
+ const engine = (0, import_rstream14.getTunnelsProjectEngine)(project, region);
2507
3321
  if (engine) return normalizeEngineAddress(engine);
2508
3322
  }
2509
3323
  throw new Error(
2510
3324
  "Failed to resolve the selected regional engine from the managed tunnels project."
2511
3325
  );
2512
3326
  }
2513
- if (projectEndpoint !== void 0 && normalizeOptionalString3(this.config?.engine) === void 0 && (0, import_rstream14.readEnvironment)().engine === void 0) {
3327
+ if (projectEndpoint !== void 0 && normalizeOptionalString3(this.config?.engine) === void 0 && (0, import_rstream17.readEnvironment)().engine === void 0) {
2514
3328
  const project = await this.getManagedProject();
2515
3329
  if (project !== void 0) {
2516
- const engine = (0, import_rstream12.getTunnelsProjectEngine)(project);
3330
+ const engine = (0, import_rstream14.getTunnelsProjectEngine)(project);
2517
3331
  if (engine) {
2518
3332
  return normalizeEngineAddress(engine);
2519
3333
  }
@@ -2554,6 +3368,10 @@ var RstreamTunnelsClient = class {
2554
3368
  };
2555
3369
  }
2556
3370
  async getManagedProject() {
3371
+ if (this.usesMTLS)
3372
+ throw new Error(
3373
+ "mTLS project resolution requires the configured Engine address; the Control plane does not accept certificates."
3374
+ );
2557
3375
  const projectEndpoint = normalizeOptionalString3(
2558
3376
  this.config?.projectEndpoint
2559
3377
  );
@@ -2629,7 +3447,7 @@ var RstreamTunnelsClient = class {
2629
3447
  if (!credentials) {
2630
3448
  return void 0;
2631
3449
  }
2632
- if (!(0, import_rstream13.isClientCredentials)(credentials)) {
3450
+ if (!(0, import_rstream16.isClientCredentials)(credentials)) {
2633
3451
  return credentials.token;
2634
3452
  }
2635
3453
  const tokenParams = await this.getEngineAuthTokenParams();
@@ -2638,26 +3456,37 @@ var RstreamTunnelsClient = class {
2638
3456
  engine
2639
3457
  })).token;
2640
3458
  }
2641
- async request(path, options) {
3459
+ async openEventStream(signal) {
3460
+ const engine = await this.getEngine();
3461
+ const token = await this.getToken(engine);
3462
+ return this.http.eventStream(engine, {
3463
+ signal,
3464
+ headers: token ? { Authorization: `Bearer ${token}` } : void 0
3465
+ });
3466
+ }
3467
+ async request(path, options, maxResponseBytes) {
2642
3468
  const engine = normalizeEngineAddress(await this.getEngine());
2643
3469
  if (!path.startsWith("/") || path.startsWith("//")) {
2644
3470
  throw new Error("Engine request path must be a relative absolute path.");
2645
3471
  }
2646
- const url = `https://${engine}/api${path}`;
2647
3472
  const headers = new Headers(options?.headers || {});
2648
- const token = await this.getToken(engine);
3473
+ const credentials = this.credentials;
3474
+ const token = path === "/turn-server/credentials" && (0, import_rstream16.isClientCredentials)(credentials) ? (0, import_rstream15.createClientCredentialsToken)(credentials, {
3475
+ expiresInSeconds: 60,
3476
+ claims: { permissions: ["turn.credentials.create"] }
3477
+ }).token : await this.getToken(engine);
2649
3478
  if (token) {
2650
3479
  headers.set("Authorization", `Bearer ${token}`);
2651
3480
  }
2652
- const response = await fetch(url, {
2653
- ...options,
2654
- headers,
2655
- redirect: options?.redirect ?? "manual"
2656
- });
2657
- if (!response.ok) {
2658
- const errorText = await response.text();
2659
- throw new Error(`HTTP error ${response.status}: ${errorText}`);
2660
- }
3481
+ const response = await this.http.response(
3482
+ engine,
3483
+ path,
3484
+ {
3485
+ ...options,
3486
+ headers
3487
+ },
3488
+ maxResponseBytes
3489
+ );
2661
3490
  return await response.json();
2662
3491
  }
2663
3492
  };
@@ -2754,6 +3583,8 @@ function hasWatchOnlyPermissions(permissions) {
2754
3583
  );
2755
3584
  }
2756
3585
  var Watch = class {
3586
+ abort = new AbortController();
3587
+ closeStream;
2757
3588
  connection = null;
2758
3589
  connectionState = "preparing";
2759
3590
  config;
@@ -2768,7 +3599,32 @@ var Watch = class {
2768
3599
  }
2769
3600
  this.connectionState = "connecting";
2770
3601
  try {
3602
+ if (this.config.client?.usesMTLS) {
3603
+ if (this.config.auth !== void 0)
3604
+ throw new Error("Watch: Use a token or mTLS, not both.");
3605
+ if (normalizeTransport(this.config.transport) !== "sse")
3606
+ throw new Error(
3607
+ "Watch: Node.js certificate authentication currently requires SSE."
3608
+ );
3609
+ const stream = await this.config.client.openEventStream(
3610
+ this.abort.signal
3611
+ );
3612
+ if (this.abort.signal.aborted) {
3613
+ stream.close();
3614
+ this.abort.signal.throwIfAborted();
3615
+ }
3616
+ this.closeStream = stream.close;
3617
+ if (!stream.response.body)
3618
+ throw new Error("Watch: Engine event stream is empty.");
3619
+ this.connectionState = "connected";
3620
+ this.events.onConnect?.();
3621
+ void this.readCertificateEvents(stream.response.body).finally(
3622
+ () => this.disconnect()
3623
+ );
3624
+ return;
3625
+ }
2771
3626
  const token = typeof this.config.auth === "function" ? await this.config.auth() : this.config.auth;
3627
+ if (!token) throw new Error("Watch: Authentication is required.");
2772
3628
  validateWatchQueryToken(token);
2773
3629
  const engine = await resolveTunnelsEngine({
2774
3630
  apiUrl: this.config.apiUrl,
@@ -2807,10 +3663,39 @@ var Watch = class {
2807
3663
  };
2808
3664
  }
2809
3665
  } catch (error) {
2810
- this.connectionState = "preparing";
3666
+ this.closeStream?.();
3667
+ this.closeStream = void 0;
3668
+ this.connectionState = this.abort.signal.aborted ? "closed" : "preparing";
2811
3669
  throw error;
2812
3670
  }
2813
3671
  }
3672
+ async readCertificateEvents(body) {
3673
+ const reader = body.getReader();
3674
+ const decoder = new TextDecoder();
3675
+ const state = { buffered: "" };
3676
+ try {
3677
+ while (!this.abort.signal.aborted) {
3678
+ const chunk = await reader.read();
3679
+ if (chunk.done) return;
3680
+ state.buffered += decoder.decode(chunk.value, { stream: true });
3681
+ const frames = state.buffered.split(/\r?\n\r?\n/);
3682
+ state.buffered = frames.pop() ?? "";
3683
+ if (state.buffered.length > 1024 * 1024)
3684
+ throw new Error("Watch event exceeds the size limit.");
3685
+ for (const frame of frames) {
3686
+ if (frame.length > 1024 * 1024)
3687
+ throw new Error("Watch event exceeds the size limit.");
3688
+ const data = frame.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
3689
+ if (data) this.handleMessage(data);
3690
+ if (this.abort.signal.aborted) return;
3691
+ }
3692
+ }
3693
+ } catch {
3694
+ this.disconnect();
3695
+ } finally {
3696
+ reader.releaseLock();
3697
+ }
3698
+ }
2814
3699
  handleMessage(data) {
2815
3700
  try {
2816
3701
  const parsed = wsEventsSchema.parse(JSON.parse(data));
@@ -2820,6 +3705,9 @@ var Watch = class {
2820
3705
  }
2821
3706
  }
2822
3707
  disconnect() {
3708
+ this.abort.abort();
3709
+ this.closeStream?.();
3710
+ this.closeStream = void 0;
2823
3711
  if (this.connection) {
2824
3712
  this.connection.onerror = null;
2825
3713
  this.connection.onmessage = null;
@@ -2835,8 +3723,8 @@ var Watch = class {
2835
3723
  };
2836
3724
 
2837
3725
  // src/webtty.ts
2838
- var z9 = __toESM(require("zod"));
2839
- var osFamilySchema = z9.enum([
3726
+ var z10 = __toESM(require("zod"));
3727
+ var osFamilySchema = z10.enum([
2840
3728
  "linux",
2841
3729
  "macos",
2842
3730
  "windows",
@@ -2844,55 +3732,55 @@ var osFamilySchema = z9.enum([
2844
3732
  "openbsd",
2845
3733
  "freebsd"
2846
3734
  ]);
2847
- var webttyCapabilitySchema = z9.enum(["exec", "fs"]);
2848
- var webttyFSModeSchema = z9.enum(["read-write", "read-only"]);
2849
- var webttyEncryptionPolicySchema = z9.enum([
3735
+ var webttyCapabilitySchema = z10.enum(["exec", "fs"]);
3736
+ var webttyFSModeSchema = z10.enum(["read-write", "read-only"]);
3737
+ var webttyEncryptionPolicySchema = z10.enum([
2850
3738
  "disabled",
2851
3739
  "explicit_key",
2852
3740
  "workspace_managed"
2853
3741
  ]);
2854
- var webttyTransportSchema = z9.enum([
3742
+ var webttyTransportSchema = z10.enum([
2855
3743
  "plain",
2856
3744
  "websocket",
2857
3745
  "webtransport"
2858
3746
  ]);
2859
- var webttyServerSchema = z9.object({
2860
- tunnel_id: z9.string(),
2861
- tunnel_name: z9.string().optional(),
2862
- target: z9.string().optional(),
2863
- rstream_url: z9.string().optional(),
2864
- status: z9.string().optional(),
2865
- publish: z9.boolean().optional(),
2866
- workspace_id: z9.string().optional(),
2867
- project_id: z9.string().optional(),
2868
- tunnel_protocol: z9.enum(["http", "webtty"]).optional(),
2869
- tunnel_type: z9.enum(["bytestream", "datagram"]).optional(),
2870
- http_version: z9.string().optional(),
3747
+ var webttyServerSchema = z10.object({
3748
+ tunnel_id: z10.string(),
3749
+ tunnel_name: z10.string().optional(),
3750
+ target: z10.string().optional(),
3751
+ rstream_url: z10.string().optional(),
3752
+ status: z10.string().optional(),
3753
+ publish: z10.boolean().optional(),
3754
+ workspace_id: z10.string().optional(),
3755
+ project_id: z10.string().optional(),
3756
+ tunnel_protocol: z10.enum(["http", "webtty"]).optional(),
3757
+ tunnel_type: z10.enum(["bytestream", "datagram"]).optional(),
3758
+ http_version: z10.string().optional(),
2871
3759
  transport: webttyTransportSchema.optional(),
2872
- transport_error: z9.string().optional(),
2873
- managed: z9.boolean(),
2874
- host: z9.string(),
2875
- token_auth: z9.boolean(),
2876
- server_id: z9.string().optional(),
2877
- server_name: z9.string().optional(),
2878
- host_key_id: z9.string().optional(),
2879
- e2e: z9.enum(["disabled", "required"]).optional(),
2880
- client_proof: z9.enum(["none", "required"]).optional(),
3760
+ transport_error: z10.string().optional(),
3761
+ managed: z10.boolean(),
3762
+ host: z10.string(),
3763
+ token_auth: z10.boolean(),
3764
+ server_id: z10.string().optional(),
3765
+ server_name: z10.string().optional(),
3766
+ host_key_id: z10.string().optional(),
3767
+ e2e: z10.enum(["disabled", "required"]).optional(),
3768
+ client_proof: z10.enum(["none", "required"]).optional(),
2881
3769
  encryption_policy: webttyEncryptionPolicySchema.optional(),
2882
- capabilities: z9.array(webttyCapabilitySchema).optional(),
2883
- exec_path: z9.string().optional(),
2884
- fs_path: z9.string().optional(),
3770
+ capabilities: z10.array(webttyCapabilitySchema).optional(),
3771
+ exec_path: z10.string().optional(),
3772
+ fs_path: z10.string().optional(),
2885
3773
  fs_mode: webttyFSModeSchema.optional(),
2886
- fs_backend: z9.enum(["webdav", "webrtc"]).optional(),
3774
+ fs_backend: z10.enum(["webdav", "webrtc"]).optional(),
2887
3775
  os_family: osFamilySchema.optional(),
2888
- arch: z9.string().optional(),
2889
- os_id: z9.string().optional(),
2890
- os_version_id: z9.string().optional(),
2891
- os_version_codename: z9.string().optional(),
2892
- os_pretty_name: z9.string().optional(),
2893
- kernel_release: z9.string().optional(),
2894
- hostname: z9.string().optional(),
2895
- labels: z9.record(z9.string(), z9.string().optional()).optional()
3776
+ arch: z10.string().optional(),
3777
+ os_id: z10.string().optional(),
3778
+ os_version_id: z10.string().optional(),
3779
+ os_version_codename: z10.string().optional(),
3780
+ os_pretty_name: z10.string().optional(),
3781
+ kernel_release: z10.string().optional(),
3782
+ hostname: z10.string().optional(),
3783
+ labels: z10.record(z10.string(), z10.string().optional()).optional()
2896
3784
  });
2897
3785
  function isWebTTYCapability(value) {
2898
3786
  return webttyCapabilitySchema.safeParse(value).success;
@@ -3091,6 +3979,7 @@ function resolveWebTTYBrowserEndpoint(server) {
3091
3979
  streamWebTTYMetadataSchema,
3092
3980
  tunnelFilterSchema,
3093
3981
  turnCredentialModeSchema,
3982
+ validateTURNCredentialsRequest,
3094
3983
  webTTYCapabilitiesSchema,
3095
3984
  webTTYContextMetadataSchema,
3096
3985
  webTTYControlRequestSchema,