@limrun/api 0.48.4 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/client.d.mts +6 -3
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +6 -3
  5. package/client.d.ts.map +1 -1
  6. package/client.js +4 -4
  7. package/client.js.map +1 -1
  8. package/client.mjs +2 -2
  9. package/client.mjs.map +1 -1
  10. package/destination-tunnel-dialer.d.mts +30 -2
  11. package/destination-tunnel-dialer.d.mts.map +1 -1
  12. package/destination-tunnel-dialer.d.ts +30 -2
  13. package/destination-tunnel-dialer.d.ts.map +1 -1
  14. package/destination-tunnel-dialer.js +349 -65
  15. package/destination-tunnel-dialer.js.map +1 -1
  16. package/destination-tunnel-dialer.mjs +345 -64
  17. package/destination-tunnel-dialer.mjs.map +1 -1
  18. package/destination-tunnel-har.d.mts +23 -0
  19. package/destination-tunnel-har.d.mts.map +1 -0
  20. package/destination-tunnel-har.d.ts +23 -0
  21. package/destination-tunnel-har.d.ts.map +1 -0
  22. package/destination-tunnel-har.js +164 -0
  23. package/destination-tunnel-har.js.map +1 -0
  24. package/destination-tunnel-har.mjs +160 -0
  25. package/destination-tunnel-har.mjs.map +1 -0
  26. package/destination-tunnel-inspection.d.mts +71 -0
  27. package/destination-tunnel-inspection.d.mts.map +1 -0
  28. package/destination-tunnel-inspection.d.ts +71 -0
  29. package/destination-tunnel-inspection.d.ts.map +1 -0
  30. package/destination-tunnel-inspection.js +267 -0
  31. package/destination-tunnel-inspection.js.map +1 -0
  32. package/destination-tunnel-inspection.mjs +261 -0
  33. package/destination-tunnel-inspection.mjs.map +1 -0
  34. package/destination-tunnel.d.mts +119 -14
  35. package/destination-tunnel.d.mts.map +1 -1
  36. package/destination-tunnel.d.ts +119 -14
  37. package/destination-tunnel.d.ts.map +1 -1
  38. package/destination-tunnel.js +407 -98
  39. package/destination-tunnel.js.map +1 -1
  40. package/destination-tunnel.mjs +374 -73
  41. package/destination-tunnel.mjs.map +1 -1
  42. package/index.d.mts +6 -0
  43. package/index.d.mts.map +1 -1
  44. package/index.d.ts +6 -0
  45. package/index.d.ts.map +1 -1
  46. package/index.js +23 -2
  47. package/index.js.map +1 -1
  48. package/index.mjs +5 -0
  49. package/index.mjs.map +1 -1
  50. package/instance-client.d.mts +105 -1
  51. package/instance-client.d.mts.map +1 -1
  52. package/instance-client.d.ts +105 -1
  53. package/instance-client.d.ts.map +1 -1
  54. package/instance-client.js +83 -1
  55. package/instance-client.js.map +1 -1
  56. package/instance-client.mjs +83 -1
  57. package/instance-client.mjs.map +1 -1
  58. package/internal/destination-tunnel-management.d.mts +4 -3
  59. package/internal/destination-tunnel-management.d.mts.map +1 -1
  60. package/internal/destination-tunnel-management.d.ts +4 -3
  61. package/internal/destination-tunnel-management.d.ts.map +1 -1
  62. package/internal/destination-tunnel-management.js +64 -46
  63. package/internal/destination-tunnel-management.js.map +1 -1
  64. package/internal/destination-tunnel-management.mjs +55 -37
  65. package/internal/destination-tunnel-management.mjs.map +1 -1
  66. package/internal/destination-tunnel-url.d.mts +2 -0
  67. package/internal/destination-tunnel-url.d.mts.map +1 -1
  68. package/internal/destination-tunnel-url.d.ts +2 -0
  69. package/internal/destination-tunnel-url.d.ts.map +1 -1
  70. package/internal/destination-tunnel-url.js +45 -5
  71. package/internal/destination-tunnel-url.js.map +1 -1
  72. package/internal/destination-tunnel-url.mjs +44 -5
  73. package/internal/destination-tunnel-url.mjs.map +1 -1
  74. package/internal/destination-tunnel-wire-reader.d.mts +23 -0
  75. package/internal/destination-tunnel-wire-reader.d.mts.map +1 -0
  76. package/internal/destination-tunnel-wire-reader.d.ts +23 -0
  77. package/internal/destination-tunnel-wire-reader.d.ts.map +1 -0
  78. package/internal/destination-tunnel-wire-reader.js +110 -0
  79. package/internal/destination-tunnel-wire-reader.js.map +1 -0
  80. package/internal/destination-tunnel-wire-reader.mjs +93 -0
  81. package/internal/destination-tunnel-wire-reader.mjs.map +1 -0
  82. package/internal/persist-option.d.mts +14 -0
  83. package/internal/persist-option.d.mts.map +1 -0
  84. package/internal/persist-option.d.ts +14 -0
  85. package/internal/persist-option.d.ts.map +1 -0
  86. package/internal/persist-option.js +18 -0
  87. package/internal/persist-option.js.map +1 -0
  88. package/internal/persist-option.mjs +15 -0
  89. package/internal/persist-option.mjs.map +1 -0
  90. package/internal/proxy-transport.d.mts +27 -0
  91. package/internal/proxy-transport.d.mts.map +1 -1
  92. package/internal/proxy-transport.d.ts +27 -0
  93. package/internal/proxy-transport.d.ts.map +1 -1
  94. package/internal/proxy-transport.js +206 -0
  95. package/internal/proxy-transport.js.map +1 -1
  96. package/internal/proxy-transport.mjs +205 -0
  97. package/internal/proxy-transport.mjs.map +1 -1
  98. package/internal/session-stream.d.mts +39 -0
  99. package/internal/session-stream.d.mts.map +1 -0
  100. package/internal/session-stream.d.ts +39 -0
  101. package/internal/session-stream.d.ts.map +1 -0
  102. package/internal/session-stream.js +144 -0
  103. package/internal/session-stream.js.map +1 -0
  104. package/internal/session-stream.mjs +139 -0
  105. package/internal/session-stream.mjs.map +1 -0
  106. package/internal/tslib.js +4 -4
  107. package/ios-client.d.mts +61 -4
  108. package/ios-client.d.mts.map +1 -1
  109. package/ios-client.d.ts +61 -4
  110. package/ios-client.d.ts.map +1 -1
  111. package/ios-client.js +48 -2
  112. package/ios-client.js.map +1 -1
  113. package/ios-client.mjs +48 -2
  114. package/ios-client.mjs.map +1 -1
  115. package/package.json +22 -1
  116. package/resources/android-instances-helpers.d.mts +25 -0
  117. package/resources/android-instances-helpers.d.mts.map +1 -0
  118. package/resources/android-instances-helpers.d.ts +25 -0
  119. package/resources/android-instances-helpers.d.ts.map +1 -0
  120. package/resources/android-instances-helpers.js +37 -0
  121. package/resources/android-instances-helpers.js.map +1 -0
  122. package/resources/android-instances-helpers.mjs +33 -0
  123. package/resources/android-instances-helpers.mjs.map +1 -0
  124. package/resources/index.d.mts +5 -2
  125. package/resources/index.d.mts.map +1 -1
  126. package/resources/index.d.ts +5 -2
  127. package/resources/index.d.ts.map +1 -1
  128. package/resources/index.js +5 -5
  129. package/resources/index.js.map +1 -1
  130. package/resources/index.mjs +2 -2
  131. package/resources/index.mjs.map +1 -1
  132. package/resources/ios-instances-helpers.d.mts +25 -0
  133. package/resources/ios-instances-helpers.d.mts.map +1 -0
  134. package/resources/ios-instances-helpers.d.ts +25 -0
  135. package/resources/ios-instances-helpers.d.ts.map +1 -0
  136. package/resources/ios-instances-helpers.js +37 -0
  137. package/resources/ios-instances-helpers.js.map +1 -0
  138. package/resources/ios-instances-helpers.mjs +33 -0
  139. package/resources/ios-instances-helpers.mjs.map +1 -0
  140. package/resources/session-artifacts.d.mts +70 -0
  141. package/resources/session-artifacts.d.mts.map +1 -0
  142. package/resources/session-artifacts.d.ts +70 -0
  143. package/resources/session-artifacts.d.ts.map +1 -0
  144. package/resources/session-artifacts.js +8 -0
  145. package/resources/session-artifacts.js.map +1 -0
  146. package/resources/session-artifacts.mjs +7 -0
  147. package/resources/session-artifacts.mjs.map +1 -0
  148. package/src/client.ts +4 -2
  149. package/src/destination-tunnel-dialer.ts +403 -64
  150. package/src/destination-tunnel-har.ts +200 -0
  151. package/src/destination-tunnel-inspection.ts +367 -0
  152. package/src/destination-tunnel.ts +510 -88
  153. package/src/index.ts +52 -0
  154. package/src/instance-client.ts +271 -5
  155. package/src/internal/destination-tunnel-management.ts +67 -43
  156. package/src/internal/destination-tunnel-url.ts +61 -5
  157. package/src/internal/destination-tunnel-wire-reader.ts +109 -0
  158. package/src/internal/persist-option.ts +26 -0
  159. package/src/internal/proxy-transport.ts +248 -0
  160. package/src/internal/session-stream.ts +157 -0
  161. package/src/ios-client.ts +127 -8
  162. package/src/resources/android-instances-helpers.ts +45 -0
  163. package/src/resources/index.ts +9 -2
  164. package/src/resources/ios-instances-helpers.ts +45 -0
  165. package/src/resources/session-artifacts.ts +95 -0
  166. package/src/version.ts +1 -1
  167. package/version.d.mts +1 -1
  168. package/version.d.ts +1 -1
  169. package/version.js +1 -1
  170. package/version.mjs +1 -1
@@ -1,9 +1,14 @@
1
1
  function deriveDestinationTunnelEndpointURL(apiUrl: string, suffix?: string): URL {
2
2
  const url = new URL(apiUrl);
3
- if (url.protocol !== 'https:' && url.protocol !== 'http:') {
3
+ if (
4
+ url.protocol !== 'https:' &&
5
+ url.protocol !== 'http:' &&
6
+ url.protocol !== 'wss:' &&
7
+ url.protocol !== 'ws:'
8
+ ) {
4
9
  throw new Error(`Unsupported apiUrl protocol for tunnel: ${url.protocol}`);
5
10
  }
6
- const tunnelPath = `${url.pathname.replace(/\/+$/, '')}/tunnel`;
11
+ const tunnelPath = `${withoutTrailingSlashes(url.pathname)}/tunnel`;
7
12
  url.pathname = suffix === undefined ? tunnelPath : `${tunnelPath}/${suffix}`;
8
13
  url.search = '';
9
14
  url.hash = '';
@@ -12,14 +17,65 @@ function deriveDestinationTunnelEndpointURL(apiUrl: string, suffix?: string): UR
12
17
 
13
18
  export function deriveDestinationTunnelURL(apiUrl: string): string {
14
19
  const url = deriveDestinationTunnelEndpointURL(apiUrl);
15
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
20
+ url.protocol = url.protocol === 'https:' || url.protocol === 'wss:' ? 'wss:' : 'ws:';
16
21
  return url.toString();
17
22
  }
18
23
 
19
24
  export function deriveDestinationTunnelStatusURL(apiUrl: string): URL {
20
- return deriveDestinationTunnelEndpointURL(apiUrl, 'status');
25
+ const url = deriveDestinationTunnelEndpointURL(apiUrl, 'status');
26
+ url.protocol =
27
+ url.protocol === 'wss:' ? 'https:'
28
+ : url.protocol === 'ws:' ? 'http:'
29
+ : url.protocol;
30
+ return url;
21
31
  }
22
32
 
23
33
  export function deriveDestinationTunnelStopURL(apiUrl: string, tunnelId: string): URL {
24
- return deriveDestinationTunnelEndpointURL(apiUrl, encodeURIComponent(tunnelId));
34
+ const url = deriveDestinationTunnelEndpointURL(apiUrl, encodeURIComponent(tunnelId));
35
+ url.protocol =
36
+ url.protocol === 'wss:' ? 'https:'
37
+ : url.protocol === 'ws:' ? 'http:'
38
+ : url.protocol;
39
+ return url;
40
+ }
41
+
42
+ export function deriveDestinationTunnelInspectionURL(
43
+ tunnelUrl: string,
44
+ tunnelId: string,
45
+ token: string,
46
+ ): string;
47
+ export function deriveDestinationTunnelInspectionURL(
48
+ tunnelUrl: string,
49
+ tunnelId: string,
50
+ afterSequence?: number,
51
+ token?: string,
52
+ ): string;
53
+ export function deriveDestinationTunnelInspectionURL(
54
+ tunnelUrl: string,
55
+ tunnelId: string,
56
+ afterSequenceOrToken: number | string = 0,
57
+ explicitToken?: string,
58
+ ): string {
59
+ const afterSequence = typeof afterSequenceOrToken === 'number' ? afterSequenceOrToken : 0;
60
+ const token = typeof afterSequenceOrToken === 'string' ? afterSequenceOrToken : explicitToken;
61
+ if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
62
+ throw new Error('afterSequence must be a safe non-negative integer');
63
+ }
64
+ const url = new URL(tunnelUrl);
65
+ url.protocol =
66
+ url.protocol === 'wss:' ? 'https:'
67
+ : url.protocol === 'ws:' ? 'http:'
68
+ : url.protocol;
69
+ url.pathname = `${withoutTrailingSlashes(url.pathname)}/${encodeURIComponent(tunnelId)}/inspection/events`;
70
+ url.search = '';
71
+ if (afterSequence > 0) url.searchParams.set('after-sequence', String(afterSequence));
72
+ if (token !== undefined) url.searchParams.set('token', token);
73
+ url.hash = '';
74
+ return url.toString();
75
+ }
76
+
77
+ function withoutTrailingSlashes(path: string): string {
78
+ let end = path.length;
79
+ while (end > 0 && path.charCodeAt(end - 1) === 47) end--;
80
+ return path.slice(0, end);
25
81
  }
@@ -0,0 +1,109 @@
1
+ export class DestinationTunnelProtocolError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = 'DestinationTunnelProtocolError';
5
+ }
6
+ }
7
+
8
+ export function readRecord(value: unknown, name: string): Record<string, unknown> {
9
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
10
+ throw new DestinationTunnelProtocolError(`${name} must be an object`);
11
+ }
12
+ return value as Record<string, unknown>;
13
+ }
14
+
15
+ export function readArray(record: Record<string, unknown>, key: string): unknown[] {
16
+ const value = record[key];
17
+ if (!Array.isArray(value)) {
18
+ throw new DestinationTunnelProtocolError(`${key} must be an array`);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function readString(record: Record<string, unknown>, key: string): string {
24
+ const value = record[key];
25
+ if (typeof value !== 'string') {
26
+ throw new DestinationTunnelProtocolError(`${key} must be a string`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ export function readNonEmptyString(record: Record<string, unknown>, key: string): string {
32
+ const value = readString(record, key);
33
+ if (value.length === 0) {
34
+ throw new DestinationTunnelProtocolError(`${key} must not be empty`);
35
+ }
36
+ return value;
37
+ }
38
+
39
+ export function readBoolean(record: Record<string, unknown>, key: string): boolean {
40
+ const value = record[key];
41
+ if (typeof value !== 'boolean') {
42
+ throw new DestinationTunnelProtocolError(`${key} must be a boolean`);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ export function readInteger(record: Record<string, unknown>, key: string): number {
48
+ const value = record[key];
49
+ if (!Number.isInteger(value)) {
50
+ throw new DestinationTunnelProtocolError(`${key} must be an integer`);
51
+ }
52
+ return value as number;
53
+ }
54
+
55
+ export function readFiniteNumber(record: Record<string, unknown>, key: string): number {
56
+ const value = record[key];
57
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
58
+ throw new DestinationTunnelProtocolError(`${key} must be a finite number`);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ export function readSafeNonNegativeInteger(value: unknown, name: string): number {
64
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
65
+ throw new DestinationTunnelProtocolError(`${name} must be a safe non-negative integer`);
66
+ }
67
+ return value as number;
68
+ }
69
+
70
+ export function readOptionalString(
71
+ record: Record<string, unknown>,
72
+ key: string,
73
+ ): { [K in typeof key]?: string } {
74
+ return record[key] === undefined ? {} : { [key]: readString(record, key) };
75
+ }
76
+
77
+ export function readOptionalBoolean(
78
+ record: Record<string, unknown>,
79
+ key: string,
80
+ ): { [K in typeof key]?: boolean } {
81
+ return record[key] === undefined ? {} : { [key]: readBoolean(record, key) };
82
+ }
83
+
84
+ export function readOptionalNonNegativeInteger(
85
+ record: Record<string, unknown>,
86
+ key: string,
87
+ ): { [K in typeof key]?: number } {
88
+ const value = record[key];
89
+ if (value === undefined) return {};
90
+ if (!Number.isInteger(value) || (value as number) < 0) {
91
+ throw new DestinationTunnelProtocolError(`${key} must be a non-negative integer`);
92
+ }
93
+ return { [key]: value as number };
94
+ }
95
+
96
+ export function decodeUtf8(value: Buffer, name: string): string {
97
+ try {
98
+ return new TextDecoder('utf-8', { fatal: true }).decode(value);
99
+ } catch {
100
+ throw new DestinationTunnelProtocolError(`${name} must be valid UTF-8`);
101
+ }
102
+ }
103
+
104
+ export function toBuffer(data: Buffer | ArrayBuffer | Buffer[]): Buffer {
105
+ if (Buffer.isBuffer(data)) return data;
106
+ if (Array.isArray(data)) return Buffer.concat(data);
107
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
108
+ throw new DestinationTunnelProtocolError('unsupported WebSocket payload type');
109
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a capture is persisted to Limrun's bucket after it stops.
3
+ * `true` persists with the server default TTL (72h); pass an object to set
4
+ * the TTL in seconds (capped server-side).
5
+ */
6
+ export type PersistOption = boolean | { ttlSeconds?: number };
7
+
8
+ /** Wire fields a persist option turns into on a start-capture message. */
9
+ export function persistFields(persist: PersistOption | undefined): {
10
+ persist?: boolean;
11
+ ttlSeconds?: number;
12
+ } {
13
+ if (!persist) {
14
+ return {};
15
+ }
16
+ if (persist === true) {
17
+ return { persist: true };
18
+ }
19
+ if (
20
+ persist.ttlSeconds !== undefined &&
21
+ (!Number.isInteger(persist.ttlSeconds) || persist.ttlSeconds <= 0)
22
+ ) {
23
+ throw new Error('ttlSeconds must be a positive integer');
24
+ }
25
+ return { persist: true, ...(persist.ttlSeconds !== undefined && { ttlSeconds: persist.ttlSeconds }) };
26
+ }
@@ -1,4 +1,6 @@
1
1
  import type { Agent as HttpAgent } from 'http';
2
+ import net from 'net';
3
+ import tls from 'tls';
2
4
 
3
5
  import { HttpsProxyAgent } from 'https-proxy-agent';
4
6
  import { getProxyForUrl } from 'proxy-from-env';
@@ -11,6 +13,29 @@ import type { Fetch } from './builtin-types';
11
13
  // the first to give up. undici's defaults are 300s, which would abort e.g. an
12
14
  // instance-side artifact upload that takes longer.
13
15
  const longRequestTimeouts = { headersTimeout: 3_600_000, bodyTimeout: 3_600_000 };
16
+ const maxConnectResponseHeaderBytes = 64 * 1024;
17
+
18
+ export interface TcpConnectOptions {
19
+ /** Host actually sent to connect(2), or in the CONNECT authority. */
20
+ host: string;
21
+ port: number;
22
+ /**
23
+ * Original destination used only for HTTP_PROXY/HTTPS_PROXY/NO_PROXY
24
+ * selection. This may differ from host when the caller resolved a domain
25
+ * locally before dialing it.
26
+ */
27
+ proxyLookupHost?: string;
28
+ proxyLookupProtocol: 'http:' | 'https:';
29
+ timeoutMs: number;
30
+ signal?: AbortSignal;
31
+ }
32
+
33
+ export interface TcpConnectResult {
34
+ socket: net.Socket;
35
+ connectMs: number;
36
+ /** Address of the destination, never the address of an HTTP proxy. */
37
+ remoteAddress?: string;
38
+ }
14
39
 
15
40
  class NodeProxyTransport {
16
41
  private envHttpProxyAgent: EnvHttpProxyAgent | undefined;
@@ -63,6 +88,21 @@ class NodeProxyTransport {
63
88
  return agent;
64
89
  }
65
90
 
91
+ /**
92
+ * Opens a paused TCP stream to a destination, tunneling through the proxy
93
+ * selected by proxy-from-env when applicable. The caller owns the returned
94
+ * socket and must resume it after installing data/end/error handlers.
95
+ */
96
+ connectTcp(options: TcpConnectOptions): Promise<TcpConnectResult> {
97
+ const lookupHost = options.proxyLookupHost ?? options.host;
98
+ const lookupUrl = `${options.proxyLookupProtocol}//${formatAuthority(lookupHost, options.port)}`;
99
+ const proxyUrl = getProxyForUrl(lookupUrl);
100
+ if (!proxyUrl) {
101
+ return connectDirect(options);
102
+ }
103
+ return connectThroughProxy(proxyUrl, options);
104
+ }
105
+
66
106
  private getEnvHttpProxyAgent(): EnvHttpProxyAgent {
67
107
  this.envHttpProxyAgent ??= new EnvHttpProxyAgent();
68
108
  return this.envHttpProxyAgent;
@@ -89,3 +129,211 @@ class NodeProxyTransport {
89
129
  }
90
130
 
91
131
  export const nodeProxyTransport = new NodeProxyTransport();
132
+
133
+ function connectDirect(options: TcpConnectOptions): Promise<TcpConnectResult> {
134
+ const startedAt = Date.now();
135
+ return new Promise((resolve, reject) => {
136
+ let settled = false;
137
+ const socket = net.createConnection({
138
+ host: options.host,
139
+ port: options.port,
140
+ allowHalfOpen: true,
141
+ });
142
+ const timer = setTimeout(() => {
143
+ fail(codedError(`connection to ${formatAuthority(options.host, options.port)} timed out`, 'ETIMEDOUT'));
144
+ }, options.timeoutMs);
145
+ timer.unref();
146
+
147
+ const cleanup = (): void => {
148
+ clearTimeout(timer);
149
+ socket.removeListener('connect', onConnect);
150
+ socket.removeListener('error', fail);
151
+ options.signal?.removeEventListener('abort', onAbort);
152
+ };
153
+ const fail = (error: Error): void => {
154
+ if (settled) return;
155
+ settled = true;
156
+ cleanup();
157
+ socket.destroy();
158
+ reject(error);
159
+ };
160
+ const onAbort = (): void => fail(codedError('TCP connection cancelled', 'ECANCELED'));
161
+ const onConnect = (): void => {
162
+ if (settled) return;
163
+ settled = true;
164
+ socket.pause();
165
+ cleanup();
166
+ resolve({
167
+ socket,
168
+ connectMs: Date.now() - startedAt,
169
+ ...(socket.remoteAddress === undefined ? {} : { remoteAddress: socket.remoteAddress }),
170
+ });
171
+ };
172
+
173
+ socket.once('connect', onConnect);
174
+ socket.once('error', fail);
175
+ if (options.signal?.aborted) {
176
+ onAbort();
177
+ } else {
178
+ options.signal?.addEventListener('abort', onAbort, { once: true });
179
+ }
180
+ });
181
+ }
182
+
183
+ function connectThroughProxy(proxyUrlString: string, options: TcpConnectOptions): Promise<TcpConnectResult> {
184
+ const startedAt = Date.now();
185
+ return new Promise((resolve, reject) => {
186
+ let settled = false;
187
+ let response = Buffer.alloc(0);
188
+ let proxyUrl: URL;
189
+ try {
190
+ proxyUrl = new URL(proxyUrlString);
191
+ } catch {
192
+ reject(codedError(`invalid proxy URL: ${proxyUrlString}`, 'EPROXY'));
193
+ return;
194
+ }
195
+ if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:') {
196
+ reject(codedError(`unsupported proxy protocol ${proxyUrl.protocol}`, 'EPROXY'));
197
+ return;
198
+ }
199
+
200
+ const proxyPort =
201
+ proxyUrl.port ? Number(proxyUrl.port)
202
+ : proxyUrl.protocol === 'https:' ? 443
203
+ : 80;
204
+ const proxyHost = unbracketHost(proxyUrl.hostname);
205
+ const socket =
206
+ proxyUrl.protocol === 'https:' ?
207
+ tls.connect({
208
+ host: proxyHost,
209
+ port: proxyPort,
210
+ rejectUnauthorized: true,
211
+ ...(net.isIP(proxyHost) ? {} : { servername: proxyHost }),
212
+ ALPNProtocols: ['http/1.1'],
213
+ })
214
+ : net.createConnection({
215
+ host: proxyHost,
216
+ port: proxyPort,
217
+ allowHalfOpen: true,
218
+ });
219
+ socket.allowHalfOpen = true;
220
+ const connectedEvent = proxyUrl.protocol === 'https:' ? 'secureConnect' : 'connect';
221
+ const timer = setTimeout(() => {
222
+ fail(
223
+ codedError(
224
+ `proxy connection to ${formatAuthority(options.host, options.port)} timed out`,
225
+ 'ETIMEDOUT',
226
+ ),
227
+ );
228
+ }, options.timeoutMs);
229
+ timer.unref();
230
+
231
+ const cleanup = (): void => {
232
+ clearTimeout(timer);
233
+ socket.removeListener(connectedEvent, onProxyConnected);
234
+ socket.removeListener('data', onData);
235
+ socket.removeListener('end', onUnexpectedClose);
236
+ socket.removeListener('close', onUnexpectedClose);
237
+ socket.removeListener('error', fail);
238
+ options.signal?.removeEventListener('abort', onAbort);
239
+ };
240
+ const fail = (error: Error): void => {
241
+ if (settled) return;
242
+ settled = true;
243
+ cleanup();
244
+ socket.destroy();
245
+ reject(error);
246
+ };
247
+ const onAbort = (): void => fail(codedError('proxy connection cancelled', 'ECANCELED'));
248
+ const onUnexpectedClose = (): void =>
249
+ fail(codedError('proxy closed before completing CONNECT', 'ECONNRESET'));
250
+ const onProxyConnected = (): void => {
251
+ const authority = formatAuthority(options.host, options.port);
252
+ const headers = [`CONNECT ${authority} HTTP/1.1`, `Host: ${authority}`, 'Proxy-Connection: Keep-Alive'];
253
+ if (proxyUrl.username || proxyUrl.password) {
254
+ const username = decodeUrlCredential(proxyUrl.username);
255
+ const password = decodeUrlCredential(proxyUrl.password);
256
+ headers.push(
257
+ `Proxy-Authorization: Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`,
258
+ );
259
+ }
260
+ socket.write(`${headers.join('\r\n')}\r\n\r\n`);
261
+ };
262
+ const onData = (chunk: Buffer): void => {
263
+ response = Buffer.concat([response, chunk]);
264
+ const headerEnd = response.indexOf('\r\n\r\n');
265
+ if (headerEnd < 0) {
266
+ if (response.length > maxConnectResponseHeaderBytes) {
267
+ fail(codedError('proxy CONNECT response headers exceeded 65536 bytes', 'EPROXY'));
268
+ }
269
+ return;
270
+ }
271
+ if (headerEnd + 4 > maxConnectResponseHeaderBytes) {
272
+ fail(codedError('proxy CONNECT response headers exceeded 65536 bytes', 'EPROXY'));
273
+ return;
274
+ }
275
+
276
+ const statusLineEnd = response.indexOf('\r\n');
277
+ const statusLine = response
278
+ .subarray(0, statusLineEnd < 0 ? headerEnd : statusLineEnd)
279
+ .toString('latin1');
280
+ const statusMatch = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(statusLine);
281
+ if (!statusMatch) {
282
+ fail(codedError(`invalid proxy CONNECT response: ${statusLine}`, 'EPROXY'));
283
+ return;
284
+ }
285
+ const statusCode = Number(statusMatch[1]);
286
+ if (statusCode < 200 || statusCode >= 300) {
287
+ fail(
288
+ codedError(
289
+ `proxy CONNECT failed with status ${statusCode}`,
290
+ statusCode === 407 ? 'EPROXYAUTH' : 'EPROXYCONNECT',
291
+ ),
292
+ );
293
+ return;
294
+ }
295
+
296
+ settled = true;
297
+ socket.pause();
298
+ cleanup();
299
+ const remainder = response.subarray(headerEnd + 4);
300
+ if (remainder.length > 0) socket.unshift(remainder);
301
+ resolve({
302
+ socket,
303
+ connectMs: Date.now() - startedAt,
304
+ remoteAddress: options.host,
305
+ });
306
+ };
307
+
308
+ socket.once(connectedEvent, onProxyConnected);
309
+ socket.on('data', onData);
310
+ socket.once('end', onUnexpectedClose);
311
+ socket.once('close', onUnexpectedClose);
312
+ socket.once('error', fail);
313
+ if (options.signal?.aborted) {
314
+ onAbort();
315
+ } else {
316
+ options.signal?.addEventListener('abort', onAbort, { once: true });
317
+ }
318
+ });
319
+ }
320
+
321
+ function formatAuthority(host: string, port: number): string {
322
+ return `${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`;
323
+ }
324
+
325
+ function unbracketHost(host: string): string {
326
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
327
+ }
328
+
329
+ function decodeUrlCredential(value: string): string {
330
+ try {
331
+ return decodeURIComponent(value);
332
+ } catch {
333
+ return value;
334
+ }
335
+ }
336
+
337
+ function codedError(message: string, code: string): NodeJS.ErrnoException {
338
+ return Object.assign(new Error(message), { code });
339
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Long-lived tail of a runtime session SSE stream (live app logs or the
3
+ * coalesced event log). The runtime replays its buffer on first connect and
4
+ * tags every entry with a monotonic SSE id; eventsource-client sends
5
+ * Last-Event-ID on reconnect, so the runtime resumes exactly where the
6
+ * stream broke and each entry is delivered exactly once.
7
+ *
8
+ * Stream health follows the exec client's model (and reuses its policy): the
9
+ * EventSource reconnects forever on its own, so a dead stream (deleted
10
+ * instance) would otherwise retry silently for good. Proof of life — a
11
+ * delivered entry, a keepalive comment, or a clean connection that stays
12
+ * open healthyConnectionMs — resets the give-up clock; once the stream stays
13
+ * broken past giveUpAfterMs, onError fires once and the stream closes.
14
+ */
15
+
16
+ import { createEventSource, type EventSourceMessage } from 'eventsource-client';
17
+ import { sseStreamPolicy } from '../exec-client';
18
+ import { nodeProxyTransport } from './proxy-transport';
19
+ import { sseFetch } from './sse-fetch';
20
+ import type { Fetch } from './builtin-types';
21
+
22
+ /** The session stream kept failing past the give-up window; it has been closed. */
23
+ export class SessionStreamLostError extends Error {}
24
+
25
+ export interface SessionStreamOptions<T extends { ts: number }> {
26
+ /** Full URL of the runtime SSE endpoint, without credentials. */
27
+ url: string;
28
+ /** Instance token; sent as a bearer Authorization header. */
29
+ token: string;
30
+ /** Called once per delivered entry, in stream order. */
31
+ onEntry: (entry: T) => void;
32
+ /** Called at most once, when the stream is given up on and closed. */
33
+ onError?: ((error: Error) => void) | undefined;
34
+ }
35
+
36
+ /**
37
+ * Connects to a session SSE endpoint and delivers parsed entries until the
38
+ * returned function is called (or the stream is given up on). Entries are
39
+ * one JSON object per SSE data frame; frames that do not parse to an object
40
+ * with a numeric `ts` are skipped.
41
+ */
42
+ export function streamSessionEntries<T extends { ts: number }>(options: SessionStreamOptions<T>): () => void {
43
+ let closed = false;
44
+ let connectedAt = 0;
45
+ let deadSince = 0;
46
+ let lastCycleAt = 0;
47
+ let lastCycleMono = 0;
48
+ let proofOfLifeThisCycle = false;
49
+ let cleanResponseThisCycle = false;
50
+ let lastStreamError: Error | undefined;
51
+
52
+ const fail = (error: Error) => {
53
+ if (closed) return;
54
+ closed = true;
55
+ eventSource.close();
56
+ options.onError?.(error);
57
+ };
58
+
59
+ // Stream health is judged at the fetch surface, where the response status
60
+ // is visible; the library fires onConnect for error responses too, so an
61
+ // error page held open must not read as a healthy connection. A rejected
62
+ // fetch never reaches onDisconnect (the hazard sseFetch exists for):
63
+ // capture it for the give-up message and let the clock decide, so one
64
+ // transient refusal does not kill a live stream.
65
+ const fetchWithStreamPolicy: Fetch = async (input, init) => {
66
+ const response = await nodeProxyTransport.fetch(input, init);
67
+ if (!response.ok) {
68
+ lastStreamError = new Error(`server answered HTTP ${response.status}`);
69
+ }
70
+ cleanResponseThisCycle = response.ok;
71
+ return response;
72
+ };
73
+
74
+ const eventSource = createEventSource({
75
+ url: options.url,
76
+ fetch: sseFetch(fetchWithStreamPolicy, (err) => {
77
+ lastStreamError = err instanceof Error ? err : new Error(String(err));
78
+ }),
79
+ headers: { Authorization: `Bearer ${options.token}` },
80
+ onConnect: () => {
81
+ connectedAt = Date.now();
82
+ },
83
+ // Keepalive comments count as proof of life (onMessage never sees them).
84
+ onComment: () => {
85
+ proofOfLifeThisCycle = true;
86
+ },
87
+ // Fires once per broken cycle, before the retry timer is armed, on both
88
+ // failure paths (request rejected, stream ended). This is where the
89
+ // give-up clock runs.
90
+ onScheduleReconnect: () => {
91
+ if (closed) return;
92
+ const now = Date.now();
93
+ const mono = performance.now();
94
+ const livedMs = connectedAt > 0 ? now - connectedAt : 0;
95
+ connectedAt = 0;
96
+ // Date.now() is wall clock: a laptop waking from sleep (or a clock
97
+ // step) would arrive with the whole window already "elapsed" and fail
98
+ // on its first attempt. Sleep is the wall clock advancing while the
99
+ // monotonic clock stands still; a large drift between the two restarts
100
+ // the streak.
101
+ if (deadSince > 0 && lastCycleAt > 0) {
102
+ const wallGapMs = now - lastCycleAt;
103
+ const monoGapMs = mono - lastCycleMono;
104
+ if (wallGapMs - monoGapMs > 30_000) {
105
+ deadSince = now;
106
+ }
107
+ }
108
+ lastCycleAt = now;
109
+ lastCycleMono = mono;
110
+ const healthy =
111
+ proofOfLifeThisCycle || (cleanResponseThisCycle && livedMs >= sseStreamPolicy.healthyConnectionMs);
112
+ proofOfLifeThisCycle = false;
113
+ cleanResponseThisCycle = false;
114
+ if (healthy) {
115
+ deadSince = 0;
116
+ lastStreamError = undefined;
117
+ return;
118
+ }
119
+ if (deadSince === 0) {
120
+ deadSince = now;
121
+ return;
122
+ }
123
+ if (now - deadSince >= sseStreamPolicy.giveUpAfterMs) {
124
+ const seconds = Math.round((now - deadSince) / 1000);
125
+ const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
126
+ fail(
127
+ new SessionStreamLostError(
128
+ `session stream to ${options.url} kept failing for ${seconds}s without delivering entries${cause}; ` +
129
+ 'the instance may no longer exist',
130
+ ),
131
+ );
132
+ }
133
+ },
134
+ onMessage: (message: EventSourceMessage) => {
135
+ deadSince = 0;
136
+ proofOfLifeThisCycle = true;
137
+ lastStreamError = undefined;
138
+ const data = typeof message.data === 'string' ? message.data : String(message.data ?? '');
139
+ let parsed: unknown;
140
+ try {
141
+ parsed = JSON.parse(data);
142
+ } catch {
143
+ return;
144
+ }
145
+ if (typeof (parsed as { ts?: unknown })?.ts !== 'number') {
146
+ return;
147
+ }
148
+ options.onEntry(parsed as T);
149
+ },
150
+ });
151
+
152
+ return () => {
153
+ if (closed) return;
154
+ closed = true;
155
+ eventSource.close();
156
+ };
157
+ }