@limrun/api 0.29.0 → 0.31.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 (57) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/build-settings.d.mts +4 -0
  3. package/build-settings.d.mts.map +1 -0
  4. package/build-settings.d.ts +4 -0
  5. package/build-settings.d.ts.map +1 -0
  6. package/build-settings.js +61 -0
  7. package/build-settings.js.map +1 -0
  8. package/build-settings.mjs +56 -0
  9. package/build-settings.mjs.map +1 -0
  10. package/exec-client.d.mts +1 -0
  11. package/exec-client.d.mts.map +1 -1
  12. package/exec-client.d.ts +1 -0
  13. package/exec-client.d.ts.map +1 -1
  14. package/exec-client.js.map +1 -1
  15. package/exec-client.mjs.map +1 -1
  16. package/index.d.mts +2 -1
  17. package/index.d.mts.map +1 -1
  18. package/index.d.ts +2 -1
  19. package/index.d.ts.map +1 -1
  20. package/index.js +9 -1
  21. package/index.js.map +1 -1
  22. package/index.mjs +2 -0
  23. package/index.mjs.map +1 -1
  24. package/package.json +11 -1
  25. package/resources/index.d.mts +1 -1
  26. package/resources/index.d.mts.map +1 -1
  27. package/resources/index.d.ts +1 -1
  28. package/resources/index.d.ts.map +1 -1
  29. package/resources/index.js +3 -1
  30. package/resources/index.js.map +1 -1
  31. package/resources/index.mjs +1 -1
  32. package/resources/index.mjs.map +1 -1
  33. package/resources/xcode-instances-helpers.d.mts +67 -0
  34. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  35. package/resources/xcode-instances-helpers.d.ts +67 -0
  36. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  37. package/resources/xcode-instances-helpers.js +95 -1
  38. package/resources/xcode-instances-helpers.js.map +1 -1
  39. package/resources/xcode-instances-helpers.mjs +92 -0
  40. package/resources/xcode-instances-helpers.mjs.map +1 -1
  41. package/src/build-settings.ts +61 -0
  42. package/src/exec-client.ts +1 -0
  43. package/src/index.ts +8 -0
  44. package/src/resources/index.ts +2 -0
  45. package/src/resources/xcode-instances-helpers.ts +162 -0
  46. package/src/tunnel.ts +62 -3
  47. package/src/version.ts +1 -1
  48. package/tunnel.d.mts.map +1 -1
  49. package/tunnel.d.ts.map +1 -1
  50. package/tunnel.js +59 -2
  51. package/tunnel.js.map +1 -1
  52. package/tunnel.mjs +59 -2
  53. package/tunnel.mjs.map +1 -1
  54. package/version.d.mts +1 -1
  55. package/version.d.ts +1 -1
  56. package/version.js +1 -1
  57. package/version.mjs +1 -1
@@ -30,6 +30,7 @@ export type ExecRequest = {
30
30
  certificatePassword?: string;
31
31
  provisioningProfileBase64?: string;
32
32
  };
33
+ buildSettings?: Record<string, string>;
33
34
  signedUploadUrl?: string;
34
35
  additionalMetadata?: {
35
36
  signedDownloadUrl?: string;
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export { PagePromise } from './core/pagination';
9
9
  export * from './instance-client';
10
10
  export * as Ios from './ios-client';
11
11
  export { startHttpProxy, type HttpProxy, type StartHttpProxyOptions } from './http-proxy';
12
+ export { buildSettingKeyPattern, parseBuildSettingEntries, validateBuildSettings } from './build-settings';
12
13
  export {
13
14
  exec,
14
15
  type ExecRequest,
@@ -19,6 +20,13 @@ export {
19
20
  export {
20
21
  type XcodeCreateClientParams,
21
22
  type XcodeClient,
23
+ type RbeStatus,
24
+ type RbeStartOptions,
25
+ type RbeTunnelOptions,
26
+ type Tunnel,
27
+ RbeUnsupportedError,
28
+ deriveRbeTunnelUrl,
29
+ DEFAULT_RBE_TUNNEL_PORT,
22
30
  type XcodeProjectConfig,
23
31
  type XcodeBuildOptions,
24
32
  type ReactNativeBuildConfig,
@@ -32,6 +32,8 @@ export {
32
32
  export { Assets, AssetGetOrUploadParams, AssetGetOrUploadResponse } from './assets-helpers';
33
33
  export {
34
34
  XcodeInstances,
35
+ RbeUnsupportedError,
36
+ DEFAULT_RBE_TUNNEL_PORT,
35
37
  type XcodeCreateClientParams,
36
38
  type XcodeClient,
37
39
  type XcodeProjectConfig,
@@ -13,6 +13,11 @@ import {
13
13
  import { createIgnoreFn } from '../folder-sync-ignore';
14
14
  import { nodeProxyTransport } from '../internal/proxy-transport';
15
15
  import { directInstanceHttpError } from '../internal/direct-instance-errors';
16
+ import { LimrunError } from '../core/error';
17
+ import { validateBuildSettings } from '../build-settings';
18
+ import { startTcpTunnel, type Tunnel } from '../tunnel';
19
+
20
+ export type { Tunnel } from '../tunnel';
16
21
 
17
22
  export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
18
23
 
@@ -88,6 +93,7 @@ export type XcodeBuildOptions = {
88
93
  upload?: { assetName: string } | { signedUploadUrl: string };
89
94
  signing?: XcodeSigningConfig;
90
95
  reactNative?: ReactNativeBuildConfig;
96
+ buildSettings?: Record<string, string>;
91
97
  };
92
98
 
93
99
  export type SimulatorInstallState =
@@ -136,6 +142,43 @@ export type SimulatorAttachResult = {
136
142
  installError?: string;
137
143
  };
138
144
 
145
+ /**
146
+ * Status of the instance's embedded Bazel Remote Build Execution stack, as
147
+ * reported by limbuild's /rbe endpoints.
148
+ */
149
+ export type RbeStatus = {
150
+ state: 'stopped' | 'starting' | 'running' | 'failed';
151
+ /** Loopback port of the RBE gRPC frontend inside the instance, when running. */
152
+ frontendPort?: number;
153
+ /**
154
+ * The Xcode version key remote actions must declare via
155
+ * XCODE_VERSION_OVERRIDE (e.g. 26.4.0.17E192), when running. Clients
156
+ * generate their xcode_version_config from it.
157
+ */
158
+ xcodeVersion?: string;
159
+ error?: string;
160
+ };
161
+
162
+ /** Default local TCP port the RBE tunnel listens on. */
163
+ export const DEFAULT_RBE_TUNNEL_PORT = 8980;
164
+
165
+ export type RbeStartOptions = {
166
+ /** Max size of the content-addressable store (CAS), in bytes. */
167
+ casMaxBytes?: number;
168
+ /** Max size of the action cache (AC), in bytes. */
169
+ acMaxBytes?: number;
170
+ /** Number of concurrent build actions the embedded worker runs. */
171
+ workerConcurrency?: number;
172
+ };
173
+
174
+ export type RbeTunnelOptions = {
175
+ /** Local port to listen on. Defaults to DEFAULT_RBE_TUNNEL_PORT (8980). */
176
+ port?: number;
177
+ /** Local address to listen on. Defaults to 127.0.0.1. */
178
+ host?: string;
179
+ logLevel?: LogLevel;
180
+ };
181
+
139
182
  export type XcodeClient = {
140
183
  /**
141
184
  * Sync source code to the xcode instance. In watch mode, keeps syncing on changes.
@@ -165,6 +208,25 @@ export type XcodeClient = {
165
208
  * Return the currently attached simulator and latest installable build state.
166
209
  */
167
210
  getSimulator: () => Promise<SimulatorStatus>;
211
+
212
+ /**
213
+ * Start the instance's embedded Bazel Remote Build Execution stack.
214
+ * Idempotent: returns the running status when already up.
215
+ */
216
+ startRbe: (opts?: RbeStartOptions) => Promise<RbeStatus>;
217
+
218
+ /** Return the current RBE stack status. */
219
+ getRbe: () => Promise<RbeStatus>;
220
+
221
+ /** Stop the RBE stack. */
222
+ stopRbe: () => Promise<RbeStatus>;
223
+
224
+ /**
225
+ * Open a local TCP listener bridged to the instance's RBE gRPC frontend
226
+ * over a multiplexed websocket. Point bazel at it with
227
+ * --remote_executor=grpc://127.0.0.1:<port>.
228
+ */
229
+ startRbeTunnel: (opts?: RbeTunnelOptions) => Promise<Tunnel>;
168
230
  };
169
231
 
170
232
  export type XcodeCreateClientParams =
@@ -219,6 +281,57 @@ async function fetchSandboxInfo(apiUrl: string, token: string): Promise<{ homeDi
219
281
  };
220
282
  }
221
283
 
284
+ /**
285
+ * Derives the websocket URL of limbuild's /rbe/tunnel endpoint from the
286
+ * instance apiUrl, mirroring deriveReverseTunnelUrl in ios-client.ts.
287
+ */
288
+ export function deriveRbeTunnelUrl(apiUrl: string): string {
289
+ const url = new URL(apiUrl);
290
+ if (url.protocol === 'https:') {
291
+ url.protocol = 'wss:';
292
+ } else if (url.protocol === 'http:') {
293
+ url.protocol = 'ws:';
294
+ } else {
295
+ throw new Error(`Unsupported apiUrl protocol for rbe tunnel: ${url.protocol}`);
296
+ }
297
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/rbe/tunnel`;
298
+ url.search = '';
299
+ url.hash = '';
300
+ // The tunnel layer (startMultiplexedTcpTunnel) appends mode=multiplexed to
301
+ // the URL itself, so we don't set it here.
302
+ return url.toString();
303
+ }
304
+
305
+ /**
306
+ * Raised when an `/rbe` request returns 404. limbuild's `/rbe` routes only
307
+ * exist on builds with remote-execution support, so a 404 there means RBE is
308
+ * unavailable on this instance (an older limbuild, or the wrong environment) —
309
+ * NOT that the instance is missing. Kept distinct from NotFoundError so the CLI
310
+ * does not mistake it for a vanished instance and spin up replacements.
311
+ */
312
+ export class RbeUnsupportedError extends LimrunError {
313
+ constructor(operation: string) {
314
+ super(
315
+ `Remote build execution is not available on this Xcode instance (${operation} returned 404). ` +
316
+ 'Its limbuild may predate RBE support, or you may be targeting the wrong environment ' +
317
+ '(for example production instead of staging).',
318
+ );
319
+ this.name = 'RbeUnsupportedError';
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Reads an `/rbe` JSON response, mapping a 404 to RbeUnsupportedError (a route
325
+ * 404 means RBE is not supported here, never a missing instance).
326
+ */
327
+ async function readRbeResponse<T>(res: Response, operation: string): Promise<T> {
328
+ if (res.status === 404) {
329
+ await res.text().catch(() => undefined);
330
+ throw new RbeUnsupportedError(operation);
331
+ }
332
+ return readJsonResponse<T>(res, operation);
333
+ }
334
+
222
335
  async function readJsonResponse<T>(res: Response, operation: string): Promise<T> {
223
336
  const text = await res.text();
224
337
  if (!res.ok) {
@@ -326,11 +439,15 @@ export class XcodeInstances extends GeneratedXcodeInstances {
326
439
  if (options?.reactNative?.devServerURL && settings?.configuration === 'Release') {
327
440
  throw new Error('reactNative.devServerURL is only supported for Debug builds');
328
441
  }
442
+ if (options?.buildSettings) {
443
+ validateBuildSettings(options.buildSettings);
444
+ }
329
445
  const request: ExecRequest = {
330
446
  command: 'xcodebuild',
331
447
  ...(settings && { xcodebuild: settings }),
332
448
  ...(options?.reactNative && { reactNative: options.reactNative }),
333
449
  ...(options?.signing && { signing: options.signing }),
450
+ ...(options?.buildSettings && { buildSettings: options.buildSettings }),
334
451
  };
335
452
 
336
453
  if (options?.upload && 'assetName' in options.upload) {
@@ -395,6 +512,51 @@ export class XcodeInstances extends GeneratedXcodeInstances {
395
512
  });
396
513
  return readJsonResponse<SimulatorAttachResult>(res, 'POST /simulator');
397
514
  },
515
+
516
+ async startRbe(opts?: RbeStartOptions): Promise<RbeStatus> {
517
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/rbe`, {
518
+ method: 'POST',
519
+ headers: {
520
+ 'Content-Type': 'application/json',
521
+ Authorization: `Bearer ${token}`,
522
+ },
523
+ body: JSON.stringify(opts ?? {}),
524
+ });
525
+ return readRbeResponse<RbeStatus>(res, 'POST /rbe');
526
+ },
527
+
528
+ async getRbe(): Promise<RbeStatus> {
529
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/rbe`, {
530
+ method: 'GET',
531
+ headers: {
532
+ Authorization: `Bearer ${token}`,
533
+ },
534
+ });
535
+ return readRbeResponse<RbeStatus>(res, 'GET /rbe');
536
+ },
537
+
538
+ async stopRbe(): Promise<RbeStatus> {
539
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/rbe`, {
540
+ method: 'DELETE',
541
+ headers: {
542
+ Authorization: `Bearer ${token}`,
543
+ },
544
+ });
545
+ return readRbeResponse<RbeStatus>(res, 'DELETE /rbe');
546
+ },
547
+
548
+ async startRbeTunnel(opts?: RbeTunnelOptions): Promise<Tunnel> {
549
+ return startTcpTunnel(
550
+ deriveRbeTunnelUrl(apiUrl),
551
+ token,
552
+ opts?.host ?? '127.0.0.1',
553
+ opts?.port ?? DEFAULT_RBE_TUNNEL_PORT,
554
+ {
555
+ mode: 'multiplexed',
556
+ logLevel: opts?.logLevel ?? params.logLevel ?? 'info',
557
+ },
558
+ );
559
+ },
398
560
  };
399
561
  }
400
562
  }
package/src/tunnel.ts CHANGED
@@ -899,8 +899,19 @@ async function startMultiplexedTcpTunnel(
899
899
  let ws: WebSocket | undefined;
900
900
  let pingInterval: NodeJS.Timeout | undefined;
901
901
  let intentionalDisconnect = false;
902
+ // True once the initial connect has succeeded. Initial-connect failures
903
+ // reject the outer promise (no reconnect); only post-establishment drops
904
+ // reconnect, keeping the local listener open so long builds survive a blip.
905
+ let established = false;
902
906
  let connectionState: TunnelConnectionState = 'connecting';
903
907
 
908
+ const maxReconnectAttempts = options?.maxReconnectAttempts ?? 6;
909
+ const reconnectDelay = options?.reconnectDelay ?? 1000;
910
+ const maxReconnectDelay = options?.maxReconnectDelay ?? 30000;
911
+ let reconnectAttempts = 0;
912
+ let reconnectTimeout: NodeJS.Timeout | undefined;
913
+ let lastError: string | undefined;
914
+
904
915
  const stateChangeCallbacks: Set<TunnelConnectionStateCallback> = new Set();
905
916
 
906
917
  const updateConnectionState = (newState: TunnelConnectionState): void => {
@@ -943,6 +954,10 @@ async function startMultiplexedTcpTunnel(
943
954
 
944
955
  const close = () => {
945
956
  intentionalDisconnect = true;
957
+ if (reconnectTimeout) {
958
+ clearTimeout(reconnectTimeout);
959
+ reconnectTimeout = undefined;
960
+ }
946
961
  cleanupWebSocket();
947
962
  closeAllConnections();
948
963
  updateConnectionState('disconnected');
@@ -951,6 +966,37 @@ async function startMultiplexedTcpTunnel(
951
966
  }
952
967
  };
953
968
 
969
+ // Single reconnect scheduler. Only ws.on('close') drives it (after the
970
+ // tunnel is established), so there is no double-scheduling race; the
971
+ // `reconnectTimeout` guard is belt-and-suspenders. Mirrors the singleton
972
+ // tunnel's backoff, but tears down per-connection streams on each drop
973
+ // since a multiplexed connId cannot be resumed across a WS reconnect.
974
+ const scheduleReconnect = (): void => {
975
+ if (intentionalDisconnect || reconnectTimeout) {
976
+ return;
977
+ }
978
+ if (isNonRetryableError(lastError ?? '')) {
979
+ logger.error(`Closing multiplexed tunnel (non-retryable error): ${lastError}`);
980
+ close();
981
+ return;
982
+ }
983
+ if (reconnectAttempts >= maxReconnectAttempts) {
984
+ logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Closing tunnel.`);
985
+ close();
986
+ return;
987
+ }
988
+ const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
989
+ reconnectAttempts++;
990
+ updateConnectionState('reconnecting');
991
+ logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
992
+ reconnectTimeout = setTimeout(() => {
993
+ reconnectTimeout = undefined;
994
+ // On failure the new ws's own 'close' handler schedules the next
995
+ // attempt, so here we only swallow the rejection.
996
+ setupWebSocket().catch(() => {});
997
+ }, currentDelay);
998
+ };
999
+
954
1000
  // Send close signal for a connection (header-only packet)
955
1001
  const sendCloseSignal = (connId: number) => {
956
1002
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -992,6 +1038,7 @@ async function startMultiplexedTcpTunnel(
992
1038
 
993
1039
  ws.on('error', (err: any) => {
994
1040
  const errMessage = err.message || String(err);
1041
+ lastError = errMessage;
995
1042
  logger.error('WebSocket error:', errMessage, err.code || '');
996
1043
  rejectWs(err);
997
1044
  });
@@ -1001,17 +1048,28 @@ async function startMultiplexedTcpTunnel(
1001
1048
  clearInterval(pingInterval);
1002
1049
  pingInterval = undefined;
1003
1050
  }
1004
-
1005
- updateConnectionState('disconnected');
1006
1051
  logger.debug(`WebSocket disconnected (code=${code}, reason=${reason.toString()})`);
1007
1052
 
1008
- // Close all TCP connections when WebSocket closes
1053
+ // Per-connection streams cannot survive a WS drop (their remote peers
1054
+ // are gone), so tear them down; bazel's own gRPC retries re-establish
1055
+ // channels once the WS is back.
1009
1056
  closeAllConnections();
1057
+
1058
+ // Before establishment, the listening handler's catch rejects + closes
1059
+ // (no reconnect). After establishment, a non-intentional drop reconnects
1060
+ // (keeping the listener open) instead of killing the tunnel.
1061
+ if (!established || intentionalDisconnect) {
1062
+ updateConnectionState('disconnected');
1063
+ return;
1064
+ }
1065
+ scheduleReconnect();
1010
1066
  });
1011
1067
 
1012
1068
  ws.on('open', () => {
1013
1069
  const socket = ws as WebSocket;
1014
1070
  logger.debug('WebSocket connected');
1071
+ reconnectAttempts = 0;
1072
+ lastError = undefined;
1015
1073
  updateConnectionState('connected');
1016
1074
 
1017
1075
  pingInterval = setInterval(() => {
@@ -1090,6 +1148,7 @@ async function startMultiplexedTcpTunnel(
1090
1148
  // Start WebSocket connection and wait for it to be ready
1091
1149
  try {
1092
1150
  await setupWebSocket();
1151
+ established = true;
1093
1152
  logger.debug('WebSocket ready, tunnel fully initialized');
1094
1153
  resolve({
1095
1154
  address,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.29.0'; // x-release-please-version
1
+ export const VERSION = '0.31.0'; // x-release-please-version
package/tunnel.d.mts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.mts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAymBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
1
+ {"version":3,"file":"tunnel.d.mts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAoqBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
package/tunnel.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAymBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
1
+ {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAoqBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
package/tunnel.js CHANGED
@@ -690,7 +690,17 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
690
690
  let ws;
691
691
  let pingInterval;
692
692
  let intentionalDisconnect = false;
693
+ // True once the initial connect has succeeded. Initial-connect failures
694
+ // reject the outer promise (no reconnect); only post-establishment drops
695
+ // reconnect, keeping the local listener open so long builds survive a blip.
696
+ let established = false;
693
697
  let connectionState = 'connecting';
698
+ const maxReconnectAttempts = options?.maxReconnectAttempts ?? 6;
699
+ const reconnectDelay = options?.reconnectDelay ?? 1000;
700
+ const maxReconnectDelay = options?.maxReconnectDelay ?? 30000;
701
+ let reconnectAttempts = 0;
702
+ let reconnectTimeout;
703
+ let lastError;
694
704
  const stateChangeCallbacks = new Set();
695
705
  const updateConnectionState = (newState) => {
696
706
  if (connectionState !== newState) {
@@ -730,6 +740,10 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
730
740
  };
731
741
  const close = () => {
732
742
  intentionalDisconnect = true;
743
+ if (reconnectTimeout) {
744
+ clearTimeout(reconnectTimeout);
745
+ reconnectTimeout = undefined;
746
+ }
733
747
  cleanupWebSocket();
734
748
  closeAllConnections();
735
749
  updateConnectionState('disconnected');
@@ -737,6 +751,36 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
737
751
  server.close();
738
752
  }
739
753
  };
754
+ // Single reconnect scheduler. Only ws.on('close') drives it (after the
755
+ // tunnel is established), so there is no double-scheduling race; the
756
+ // `reconnectTimeout` guard is belt-and-suspenders. Mirrors the singleton
757
+ // tunnel's backoff, but tears down per-connection streams on each drop
758
+ // since a multiplexed connId cannot be resumed across a WS reconnect.
759
+ const scheduleReconnect = () => {
760
+ if (intentionalDisconnect || reconnectTimeout) {
761
+ return;
762
+ }
763
+ if ((0, exports.isNonRetryableError)(lastError ?? '')) {
764
+ logger.error(`Closing multiplexed tunnel (non-retryable error): ${lastError}`);
765
+ close();
766
+ return;
767
+ }
768
+ if (reconnectAttempts >= maxReconnectAttempts) {
769
+ logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Closing tunnel.`);
770
+ close();
771
+ return;
772
+ }
773
+ const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
774
+ reconnectAttempts++;
775
+ updateConnectionState('reconnecting');
776
+ logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
777
+ reconnectTimeout = setTimeout(() => {
778
+ reconnectTimeout = undefined;
779
+ // On failure the new ws's own 'close' handler schedules the next
780
+ // attempt, so here we only swallow the rejection.
781
+ setupWebSocket().catch(() => { });
782
+ }, currentDelay);
783
+ };
740
784
  // Send close signal for a connection (header-only packet)
741
785
  const sendCloseSignal = (connId) => {
742
786
  if (ws && ws.readyState === ws_1.WebSocket.OPEN) {
@@ -772,6 +816,7 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
772
816
  });
773
817
  ws.on('error', (err) => {
774
818
  const errMessage = err.message || String(err);
819
+ lastError = errMessage;
775
820
  logger.error('WebSocket error:', errMessage, err.code || '');
776
821
  rejectWs(err);
777
822
  });
@@ -780,14 +825,25 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
780
825
  clearInterval(pingInterval);
781
826
  pingInterval = undefined;
782
827
  }
783
- updateConnectionState('disconnected');
784
828
  logger.debug(`WebSocket disconnected (code=${code}, reason=${reason.toString()})`);
785
- // Close all TCP connections when WebSocket closes
829
+ // Per-connection streams cannot survive a WS drop (their remote peers
830
+ // are gone), so tear them down; bazel's own gRPC retries re-establish
831
+ // channels once the WS is back.
786
832
  closeAllConnections();
833
+ // Before establishment, the listening handler's catch rejects + closes
834
+ // (no reconnect). After establishment, a non-intentional drop reconnects
835
+ // (keeping the listener open) instead of killing the tunnel.
836
+ if (!established || intentionalDisconnect) {
837
+ updateConnectionState('disconnected');
838
+ return;
839
+ }
840
+ scheduleReconnect();
787
841
  });
788
842
  ws.on('open', () => {
789
843
  const socket = ws;
790
844
  logger.debug('WebSocket connected');
845
+ reconnectAttempts = 0;
846
+ lastError = undefined;
791
847
  updateConnectionState('connected');
792
848
  pingInterval = setInterval(() => {
793
849
  if (socket.readyState === ws_1.WebSocket.OPEN) {
@@ -852,6 +908,7 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
852
908
  // Start WebSocket connection and wait for it to be ready
853
909
  try {
854
910
  await setupWebSocket();
911
+ established = true;
855
912
  logger.debug('WebSocket ready, tunnel fully initialized');
856
913
  resolve({
857
914
  address,