@limrun/api 0.60.0 → 0.62.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 (40) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/destination-tunnel-dialer.d.mts +8 -0
  3. package/destination-tunnel-dialer.d.mts.map +1 -1
  4. package/destination-tunnel-dialer.d.ts +8 -0
  5. package/destination-tunnel-dialer.d.ts.map +1 -1
  6. package/destination-tunnel-dialer.js +6 -5
  7. package/destination-tunnel-dialer.js.map +1 -1
  8. package/destination-tunnel-dialer.mjs +5 -5
  9. package/destination-tunnel-dialer.mjs.map +1 -1
  10. package/internal/destination-tunnel-wire-reader.d.mts +2 -0
  11. package/internal/destination-tunnel-wire-reader.d.mts.map +1 -1
  12. package/internal/destination-tunnel-wire-reader.d.ts +2 -0
  13. package/internal/destination-tunnel-wire-reader.d.ts.map +1 -1
  14. package/internal/destination-tunnel-wire-reader.js +17 -0
  15. package/internal/destination-tunnel-wire-reader.js.map +1 -1
  16. package/internal/destination-tunnel-wire-reader.mjs +15 -0
  17. package/internal/destination-tunnel-wire-reader.mjs.map +1 -1
  18. package/package.json +1 -1
  19. package/resources/android-instances.d.mts +6 -6
  20. package/resources/android-instances.d.ts +6 -6
  21. package/resources/ios-instances.d.mts +6 -6
  22. package/resources/ios-instances.d.ts +6 -6
  23. package/src/destination-tunnel-dialer.ts +6 -5
  24. package/src/internal/destination-tunnel-wire-reader.ts +17 -0
  25. package/src/resources/android-instances.ts +6 -6
  26. package/src/resources/ios-instances.ts +6 -6
  27. package/src/tunnel-connector.ts +132 -42
  28. package/src/version.ts +1 -1
  29. package/tunnel-connector.d.mts +21 -10
  30. package/tunnel-connector.d.mts.map +1 -1
  31. package/tunnel-connector.d.ts +21 -10
  32. package/tunnel-connector.d.ts.map +1 -1
  33. package/tunnel-connector.js +96 -29
  34. package/tunnel-connector.js.map +1 -1
  35. package/tunnel-connector.mjs +98 -31
  36. package/tunnel-connector.mjs.map +1 -1
  37. package/version.d.mts +1 -1
  38. package/version.d.ts +1 -1
  39. package/version.js +1 -1
  40. package/version.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/api",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "description": "The official TypeScript library for the Limrun API",
5
5
  "author": "Limrun <contact@limrun.com>",
6
6
  "types": "./index.d.ts",
@@ -55,9 +55,9 @@ export declare namespace AndroidInstance {
55
55
  */
56
56
  hardTimeout?: string;
57
57
  /**
58
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
59
- * instance. Creation fails while that tunnel is offline, and the instance
60
- * becomes ready only after the tunnel attaches.
58
+ * Names a persistent tunnel, created in the console (Network) or the API, that
59
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
60
+ * holds it, and the instance becomes ready only after the tunnel attaches.
61
61
  */
62
62
  tunnel?: string;
63
63
  }
@@ -188,9 +188,9 @@ export declare namespace AndroidInstanceCreateParams {
188
188
  region?: string;
189
189
  sandbox?: Spec.Sandbox;
190
190
  /**
191
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
192
- * instance. Creation fails while that tunnel is offline, and the instance
193
- * becomes ready only after the tunnel attaches.
191
+ * Names a persistent tunnel, created in the console (Network) or the API, that
192
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
193
+ * holds it, and the instance becomes ready only after the tunnel attaches.
194
194
  */
195
195
  tunnel?: string;
196
196
  }
@@ -55,9 +55,9 @@ export declare namespace AndroidInstance {
55
55
  */
56
56
  hardTimeout?: string;
57
57
  /**
58
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
59
- * instance. Creation fails while that tunnel is offline, and the instance
60
- * becomes ready only after the tunnel attaches.
58
+ * Names a persistent tunnel, created in the console (Network) or the API, that
59
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
60
+ * holds it, and the instance becomes ready only after the tunnel attaches.
61
61
  */
62
62
  tunnel?: string;
63
63
  }
@@ -188,9 +188,9 @@ export declare namespace AndroidInstanceCreateParams {
188
188
  region?: string;
189
189
  sandbox?: Spec.Sandbox;
190
190
  /**
191
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
192
- * instance. Creation fails while that tunnel is offline, and the instance
193
- * becomes ready only after the tunnel attaches.
191
+ * Names a persistent tunnel, created in the console (Network) or the API, that
192
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
193
+ * holds it, and the instance becomes ready only after the tunnel attaches.
194
194
  */
195
195
  tunnel?: string;
196
196
  }
@@ -55,9 +55,9 @@ export declare namespace IosInstance {
55
55
  */
56
56
  hardTimeout?: string;
57
57
  /**
58
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
59
- * instance. Creation fails while that tunnel is offline, and the instance
60
- * becomes ready only after the tunnel attaches.
58
+ * Names a persistent tunnel, created in the console (Network) or the API, that
59
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
60
+ * holds it, and the instance becomes ready only after the tunnel attaches.
61
61
  */
62
62
  tunnel?: string;
63
63
  }
@@ -181,9 +181,9 @@ export declare namespace IosInstanceCreateParams {
181
181
  */
182
182
  region?: string;
183
183
  /**
184
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
185
- * instance. Creation fails while that tunnel is offline, and the instance
186
- * becomes ready only after the tunnel attaches.
184
+ * Names a persistent tunnel, created in the console (Network) or the API, that
185
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
186
+ * holds it, and the instance becomes ready only after the tunnel attaches.
187
187
  */
188
188
  tunnel?: string;
189
189
  }
@@ -55,9 +55,9 @@ export declare namespace IosInstance {
55
55
  */
56
56
  hardTimeout?: string;
57
57
  /**
58
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
59
- * instance. Creation fails while that tunnel is offline, and the instance
60
- * becomes ready only after the tunnel attaches.
58
+ * Names a persistent tunnel, created in the console (Network) or the API, that
59
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
60
+ * holds it, and the instance becomes ready only after the tunnel attaches.
61
61
  */
62
62
  tunnel?: string;
63
63
  }
@@ -181,9 +181,9 @@ export declare namespace IosInstanceCreateParams {
181
181
  */
182
182
  region?: string;
183
183
  /**
184
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
185
- * instance. Creation fails while that tunnel is offline, and the instance
186
- * becomes ready only after the tunnel attaches.
184
+ * Names a persistent tunnel, created in the console (Network) or the API, that
185
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
186
+ * holds it, and the instance becomes ready only after the tunnel attaches.
187
187
  */
188
188
  tunnel?: string;
189
189
  }
@@ -88,6 +88,11 @@ export type DestinationTunnelStartOptions = Pick<
88
88
  'selectors' | 'inspection' | 'onInspectionEvent' | 'onInspectionError' | 'window' | 'logLevel'
89
89
  >;
90
90
 
91
+ /** The inspection every instance client starts with: on, without bodies. */
92
+ export function destinationTunnelInspection(inspection: DestinationTunnelStartOptions['inspection']) {
93
+ return { enabled: true, captureBodies: false, ...(inspection ?? {}) };
94
+ }
95
+
91
96
  /**
92
97
  * Applies the defaults both instance clients share, so the platforms cannot
93
98
  * drift: inspection on, bodies off, and the client's log level.
@@ -98,11 +103,7 @@ export function destinationTunnelDialOptions(
98
103
  ): DestinationTcpTunnelOptions {
99
104
  return {
100
105
  selectors: options.selectors,
101
- inspection: {
102
- enabled: true,
103
- captureBodies: false,
104
- ...(options.inspection ?? {}),
105
- },
106
+ inspection: destinationTunnelInspection(options.inspection),
106
107
  ...(options.onInspectionEvent ? { onInspectionEvent: options.onInspectionEvent } : {}),
107
108
  ...(options.onInspectionError ? { onInspectionError: options.onInspectionError } : {}),
108
109
  ...(options.window === undefined ? {} : { window: options.window }),
@@ -20,6 +20,15 @@ export function readArray(record: Record<string, unknown>, key: string): unknown
20
20
  return value;
21
21
  }
22
22
 
23
+ export function readStringArray(record: Record<string, unknown>, key: string): string[] {
24
+ return readArray(record, key).map((value) => {
25
+ if (typeof value !== 'string') {
26
+ throw new DestinationTunnelProtocolError(`${key} must hold strings`);
27
+ }
28
+ return value;
29
+ });
30
+ }
31
+
23
32
  export function readString(record: Record<string, unknown>, key: string): string {
24
33
  const value = record[key];
25
34
  if (typeof value !== 'string') {
@@ -52,6 +61,14 @@ export function readInteger(record: Record<string, unknown>, key: string): numbe
52
61
  return value as number;
53
62
  }
54
63
 
64
+ export function readPositiveInteger(record: Record<string, unknown>, key: string): number {
65
+ const value = readInteger(record, key);
66
+ if (value < 1) {
67
+ throw new DestinationTunnelProtocolError(`${key} must be positive`);
68
+ }
69
+ return value;
70
+ }
71
+
55
72
  export function readFiniteNumber(record: Record<string, unknown>, key: string): number {
56
73
  const value = record[key];
57
74
  if (typeof value !== 'number' || !Number.isFinite(value)) {
@@ -90,9 +90,9 @@ export namespace AndroidInstance {
90
90
  hardTimeout?: string;
91
91
 
92
92
  /**
93
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
94
- * instance. Creation fails while that tunnel is offline, and the instance
95
- * becomes ready only after the tunnel attaches.
93
+ * Names a persistent tunnel, created in the console (Network) or the API, that
94
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
95
+ * holds it, and the instance becomes ready only after the tunnel attaches.
96
96
  */
97
97
  tunnel?: string;
98
98
  }
@@ -250,9 +250,9 @@ export namespace AndroidInstanceCreateParams {
250
250
  sandbox?: Spec.Sandbox;
251
251
 
252
252
  /**
253
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
254
- * instance. Creation fails while that tunnel is offline, and the instance
255
- * becomes ready only after the tunnel attaches.
253
+ * Names a persistent tunnel, created in the console (Network) or the API, that
254
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
255
+ * holds it, and the instance becomes ready only after the tunnel attaches.
256
256
  */
257
257
  tunnel?: string;
258
258
  }
@@ -90,9 +90,9 @@ export namespace IosInstance {
90
90
  hardTimeout?: string;
91
91
 
92
92
  /**
93
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
94
- * instance. Creation fails while that tunnel is offline, and the instance
95
- * becomes ready only after the tunnel attaches.
93
+ * Names a persistent tunnel, created in the console (Network) or the API, that
94
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
95
+ * holds it, and the instance becomes ready only after the tunnel attaches.
96
96
  */
97
97
  tunnel?: string;
98
98
  }
@@ -239,9 +239,9 @@ export namespace IosInstanceCreateParams {
239
239
  region?: string;
240
240
 
241
241
  /**
242
- * Names a persistent tunnel (`lim tunnel run --name`) that serves this
243
- * instance. Creation fails while that tunnel is offline, and the instance
244
- * becomes ready only after the tunnel attaches.
242
+ * Names a persistent tunnel, created in the console (Network) or the API, that
243
+ * serves this instance. Creation fails while no connector (`lim tunnel run`)
244
+ * holds it, and the instance becomes ready only after the tunnel attaches.
245
245
  */
246
246
  tunnel?: string;
247
247
  }
@@ -9,7 +9,8 @@ import {
9
9
  } from './internal/destination-tunnel-management';
10
10
  import {
11
11
  DestinationTunnelProtocolError,
12
- readInteger,
12
+ readPositiveInteger,
13
+ readStringArray,
13
14
  readNonEmptyString,
14
15
  readOptionalString,
15
16
  readRecord,
@@ -19,6 +20,7 @@ import {
19
20
  import {
20
21
  DestinationTunnelSessionError,
21
22
  destinationTunnelDialOptions,
23
+ destinationTunnelInspection,
22
24
  startDestinationTcpTunnel,
23
25
  type DestinationTcpTunnel,
24
26
  } from './destination-tunnel-dialer';
@@ -31,7 +33,7 @@ import {
31
33
  import { upgradeStatus, type LogLevel } from './tunnel';
32
34
  import { VERSION } from './version';
33
35
 
34
- const CONTROL_PROTOCOL_VERSION = 1;
36
+ const CONTROL_PROTOCOL_VERSION = 2;
35
37
  const CONTROL_HANDSHAKE_TIMEOUT_MS = 15_000;
36
38
  const CONTROL_PING_INTERVAL_MS = 15_000;
37
39
  const CONTROL_DEAD_PEER_MS = 45_000;
@@ -47,15 +49,23 @@ const MAX_REJECTION_REASON_CHARS = 200;
47
49
  /** Every tunnel WebSocket counts as instance activity, so retries must end. */
48
50
  const ATTACH_GIVE_UP_MS = 5 * 60_000;
49
51
  const TAKEN_OVER_MESSAGE = 'another connector for this tunnel took the instance over';
52
+ /** A token this close to its expiry is not dialed with; the hub refreshes it at half its life. */
53
+ const TOKEN_EXPIRY_MARGIN_MS = 5_000;
54
+ /** How long before its token expires the connector starts warning, and how often. */
55
+ const TOKEN_WARNING_WINDOW_MS = 14 * 24 * 60 * 60_000;
56
+ const TOKEN_WARNING_EVERY_MS = 24 * 60 * 60_000;
50
57
 
51
58
  export interface TunnelConnectorOptions {
52
- apiKey: string;
59
+ /** The tunnel's token; no other credential connects. */
60
+ token: string;
53
61
  /** Limrun API base URL, such as https://api.limrun.com. */
54
62
  baseURL: string;
55
63
  organizationId: string;
56
- /** The name instances set in `spec.tunnel` to attach to this connector. */
57
- name: string;
58
- selectors: DestinationTunnelSelectors;
64
+ /**
65
+ * The ID of a tunnel created in the console or the API. Its name, which
66
+ * instances set in `spec.tunnel`, and its selectors come from the platform.
67
+ */
68
+ tunnelId: string;
59
69
  /** Inspection for every instance tunnel. Defaults to enabled without bodies. */
60
70
  inspection?: Partial<DestinationTunnelInspectionConfig>;
61
71
  /** Take the name over from the connector that holds it now. */
@@ -70,8 +80,10 @@ export interface TunnelConnectorOptions {
70
80
  }
71
81
 
72
82
  export type TunnelConnectorEvent =
73
- /** This connector holds the name; emitted once per session and control connection. */
74
- | { type: 'active'; sessionId: string }
83
+ /** This connector holds the tunnel; emitted once per session and control connection. */
84
+ | { type: 'active'; sessionId: string; name: string }
85
+ /** The connector's tunnel token expires within two weeks; emitted once a day until it does. */
86
+ | { type: 'tokenExpiring'; expiresAt: string }
75
87
  /** Another connector holds the name; emitted once per holder. */
76
88
  | { type: 'standby'; holder: { hostname: string; since?: string } }
77
89
  | { type: 'attached'; instanceId: string; tunnelId: string }
@@ -93,7 +105,6 @@ type ControlClientMessage =
93
105
  | {
94
106
  type: 'hello';
95
107
  version: number;
96
- selectors: DestinationTunnelSelectors;
97
108
  replace: boolean;
98
109
  sessionId?: string;
99
110
  attached: string[];
@@ -104,9 +115,17 @@ type ControlClientMessage =
104
115
  | { type: 'bye' };
105
116
 
106
117
  type ControlServerMessage =
107
- | { type: 'active'; sessionId: string; leaseSeconds: number }
118
+ | { type: 'active'; sessionId: string; leaseSeconds: number; name: string; tokenExpiresAt?: string }
108
119
  | { type: 'standby'; holder: { hostname: string; since?: string } }
109
- | { type: 'attach'; instanceId: string; url: string; token: string }
120
+ | {
121
+ type: 'attach';
122
+ instanceId: string;
123
+ url: string;
124
+ selectors: string[];
125
+ token: string;
126
+ expiresInSeconds: number;
127
+ }
128
+ | { type: 'token'; instanceId: string; token: string; expiresInSeconds: number }
110
129
  | { type: 'detach'; instanceId: string; reason: string }
111
130
  | { type: 'notice'; code: string; message: string; instanceId?: string }
112
131
  | { type: 'revoked'; reason: string };
@@ -114,7 +133,11 @@ type ControlServerMessage =
114
133
  interface InstanceAttachment {
115
134
  /** Instance base URL: the iOS API URL or the Android ADB WebSocket URL. */
116
135
  url: string;
136
+ /** Opens this instance's tunnel and nothing else; the hub keeps it fresh. */
117
137
  token: string;
138
+ tokenExpiresAt: number;
139
+ /** The tunnel's selectors when the instance first attached; later edits apply to later instances. */
140
+ selectors: DestinationTunnelSelectors;
118
141
  tunnel?: DestinationTcpTunnel | undefined;
119
142
  /** This connector's latest tunnel on the instance, live or dropped. */
120
143
  lastTunnelId?: string;
@@ -129,23 +152,17 @@ interface InstanceAttachment {
129
152
  }
130
153
 
131
154
  /**
132
- * Serve a persistent tunnel: hold the name through one control WebSocket to
133
- * the Limrun API and open a destination tunnel to every instance created
134
- * with `spec.tunnel` set to it. Instance tunnels stay up while the control
135
- * channel reconnects, so an API outage never becomes a tunnel outage.
155
+ * Serve a persistent tunnel: hold it through one control WebSocket to the
156
+ * Limrun API and open a destination tunnel to every instance created with
157
+ * `spec.tunnel` set to its name, with a short-lived token the API hands out
158
+ * for each. Instance tunnels stay up while the control channel reconnects,
159
+ * so an API outage never becomes a tunnel outage; one that drops re-dials
160
+ * once the API sends a fresh token.
136
161
  */
137
162
  export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
138
- const selectors = validateDestinationTunnelSelectors(options.selectors);
139
- const dialOptions = {
140
- ...destinationTunnelDialOptions(
141
- { selectors, ...(options.inspection ? { inspection: options.inspection } : {}) },
142
- options.logLevel ?? 'warn',
143
- ),
144
- name: options.name,
145
- };
146
163
  // Reject a bad inspection config now rather than on every attach.
147
- normalizeDestinationTunnelInspection(dialOptions.inspection ?? {});
148
- const controlURL = deriveTunnelConnectURL(options.baseURL, options.organizationId, options.name);
164
+ normalizeDestinationTunnelInspection(destinationTunnelInspection(options.inspection));
165
+ const controlURL = deriveTunnelConnectURL(options.baseURL, options.organizationId, options.tunnelId);
149
166
  const client = {
150
167
  hostname: options.hostname ?? os.hostname(),
151
168
  version: options.clientVersion ?? VERSION,
@@ -162,6 +179,9 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
162
179
  let replace = options.replace ?? false;
163
180
  // When the last confirmed lease runs out; zero while on standby.
164
181
  let leaseExpiresAt = 0;
182
+ // The tunnel's name, from the first active; instances set it in spec.tunnel.
183
+ let tunnelName: string | undefined;
184
+ let lastTokenWarning = 0;
165
185
  let announcedSession: string | undefined;
166
186
  let announcedHolder: string | undefined;
167
187
 
@@ -211,7 +231,24 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
211
231
  const fail = (detail: string): void => {
212
232
  if (!shutdown()) return;
213
233
  ws?.terminate();
214
- rejectClosed(new Error(`tunnel ${options.name}: ${detail}`));
234
+ rejectClosed(new Error(`tunnel ${tunnelName ?? options.tunnelId}: ${detail}`));
235
+ };
236
+
237
+ const dialOptions = (attachment: InstanceAttachment) => ({
238
+ ...destinationTunnelDialOptions(
239
+ { selectors: attachment.selectors, ...(options.inspection ? { inspection: options.inspection } : {}) },
240
+ options.logLevel ?? 'warn',
241
+ ),
242
+ ...(tunnelName === undefined ? {} : { name: tunnelName }),
243
+ });
244
+
245
+ const tokenUsable = (attachment: InstanceAttachment): boolean =>
246
+ Date.now() < attachment.tokenExpiresAt - TOKEN_EXPIRY_MARGIN_MS;
247
+
248
+ // Tells the platform and the caller that the connector gave up on an instance.
249
+ const reportGivenUp = (instanceId: string, code: string, message: string): void => {
250
+ send({ type: 'attachFailed', instanceId, code, message, terminal: true });
251
+ emit({ type: 'attachFailed', instanceId, code, message });
215
252
  };
216
253
 
217
254
  const giveUp = (
@@ -221,8 +258,7 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
221
258
  message: string,
222
259
  ): void => {
223
260
  forget(instanceId, attachment);
224
- send({ type: 'attachFailed', instanceId, code, message, terminal: true });
225
- emit({ type: 'attachFailed', instanceId, code, message });
261
+ reportGivenUp(instanceId, code, message);
226
262
  };
227
263
 
228
264
  const scheduleAttachRetry = (instanceId: string, attachment: InstanceAttachment, reason: string): void => {
@@ -254,7 +290,8 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
254
290
  try {
255
291
  const active = (await getDestinationTunnelStatus(attachment.url, attachment.token)).active;
256
292
  return (
257
- active?.name === options.name &&
293
+ active !== undefined &&
294
+ active.name === tunnelName &&
258
295
  active.state !== 'stopping' &&
259
296
  active.tunnelId !== attachment.lastTunnelId
260
297
  );
@@ -288,7 +325,7 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
288
325
  retryAfterFailure(instanceId, attachment, 'already_active', 'the previous tunnel is still stopping');
289
326
  return;
290
327
  }
291
- if (active.name !== options.name) {
328
+ if (active.name !== tunnelName) {
292
329
  giveUp(instanceId, attachment, 'instance_busy', 'the instance already has another tunnel');
293
330
  return;
294
331
  }
@@ -322,6 +359,12 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
322
359
  retryAfterFailure(instanceId, attachment, 'not_holder', 'this connector does not hold the tunnel');
323
360
  return;
324
361
  }
362
+ // A token past its life is refused; the API sends a fresh one at half
363
+ // life, so this only waits while the control channel is down.
364
+ if (!tokenUsable(attachment)) {
365
+ retryAfterFailure(instanceId, attachment, 'token_expired', 'waiting for a fresh attach token');
366
+ return;
367
+ }
325
368
  let tunnel: DestinationTcpTunnel;
326
369
  try {
327
370
  if (attachment.lastTunnelId !== undefined && (await takenOver(attachment))) {
@@ -334,7 +377,7 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
334
377
  tunnel = await startDestinationTcpTunnel(
335
378
  deriveDestinationTunnelURL(attachment.url),
336
379
  attachment.token,
337
- dialOptions,
380
+ dialOptions(attachment),
338
381
  );
339
382
  } catch (error) {
340
383
  if (attachment.done) return;
@@ -387,17 +430,28 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
387
430
  // close() already let every instance go; a late attach must not dial.
388
431
  if (stopped) return;
389
432
  switch (message.type) {
390
- case 'active':
433
+ case 'active': {
391
434
  replace = false;
392
435
  leaseExpiresAt = Date.now() + message.leaseSeconds * 1000;
393
436
  controlBackoffMs = CONTROL_INITIAL_BACKOFF_MS;
394
437
  sessionId = message.sessionId;
438
+ tunnelName = message.name;
395
439
  announcedHolder = undefined;
396
440
  if (announcedSession !== message.sessionId) {
397
441
  announcedSession = message.sessionId;
398
- emit({ type: 'active', sessionId: message.sessionId });
442
+ emit({ type: 'active', sessionId: message.sessionId, name: message.name });
443
+ }
444
+ const now = Date.now();
445
+ if (
446
+ message.tokenExpiresAt !== undefined &&
447
+ Date.parse(message.tokenExpiresAt) - now < TOKEN_WARNING_WINDOW_MS &&
448
+ now - lastTokenWarning >= TOKEN_WARNING_EVERY_MS
449
+ ) {
450
+ lastTokenWarning = now;
451
+ emit({ type: 'tokenExpiring', expiresAt: message.tokenExpiresAt });
399
452
  }
400
453
  return;
454
+ }
401
455
  case 'standby': {
402
456
  leaseExpiresAt = 0;
403
457
  controlBackoffMs = CONTROL_INITIAL_BACKOFF_MS;
@@ -410,20 +464,46 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
410
464
  }
411
465
  return;
412
466
  }
467
+ case 'token': {
468
+ // A fresh token at half its life, or after a resume. An instance this
469
+ // connector gave up on stays given up.
470
+ const attachment = instances.get(message.instanceId);
471
+ if (attachment) {
472
+ attachment.token = message.token;
473
+ attachment.tokenExpiresAt = Date.now() + message.expiresInSeconds * 1000;
474
+ }
475
+ return;
476
+ }
413
477
  case 'attach': {
478
+ // Expiry is relative, so this host's clock never matters.
479
+ const tokenExpiresAt = Date.now() + message.expiresInSeconds * 1000;
414
480
  const existing = instances.get(message.instanceId);
415
481
  if (existing) {
416
- // Resume replays attaches; a live tunnel only needs confirming.
482
+ // A new holder session re-attaches every instance, including ones
483
+ // this connector still serves; a live tunnel only needs confirming.
484
+ // The instance keeps the selectors it first got.
417
485
  existing.url = message.url;
418
486
  existing.token = message.token;
487
+ existing.tokenExpiresAt = tokenExpiresAt;
419
488
  if (existing.tunnel) {
420
489
  send({ type: 'attached', instanceId: message.instanceId, tunnelId: existing.tunnel.tunnelId });
421
490
  }
422
491
  return;
423
492
  }
493
+ // The instance itself refuses what its platform cannot route, such
494
+ // as Android routes below port 1024.
495
+ let selectors: DestinationTunnelSelectors;
496
+ try {
497
+ selectors = validateDestinationTunnelSelectors(message.selectors);
498
+ } catch (error) {
499
+ reportGivenUp(message.instanceId, 'invalid_selectors', errorMessage(error));
500
+ return;
501
+ }
424
502
  const attachment: InstanceAttachment = {
425
503
  url: message.url,
426
504
  token: message.token,
505
+ tokenExpiresAt,
506
+ selectors,
427
507
  mayReclaim: true,
428
508
  failingSince: Date.now(),
429
509
  backoffMs: ATTACH_INITIAL_BACKOFF_MS,
@@ -485,7 +565,7 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
485
565
  if (stopped) return;
486
566
  const proxyAgent = nodeProxyTransport.getWebSocketAgent(controlURL);
487
567
  const socket = new WebSocket(controlURL, {
488
- headers: { Authorization: `Bearer ${options.apiKey}` },
568
+ headers: { Authorization: `Bearer ${options.token}` },
489
569
  ...(proxyAgent ? { agent: proxyAgent } : {}),
490
570
  perMessageDeflate: false,
491
571
  // A stalled upgrade, such as after a laptop wakes, would otherwise
@@ -518,7 +598,6 @@ export function runTunnel(options: TunnelConnectorOptions): TunnelConnector {
518
598
  send({
519
599
  type: 'hello',
520
600
  version: CONTROL_PROTOCOL_VERSION,
521
- selectors,
522
601
  replace,
523
602
  ...(sessionId === undefined ? {} : { sessionId }),
524
603
  attached: Array.from(instances)
@@ -614,11 +693,13 @@ function decodeControlServerMessage(value: unknown): ControlServerMessage | unde
614
693
  const type = readString(message, 'type');
615
694
  switch (type) {
616
695
  case 'active': {
617
- const leaseSeconds = readInteger(message, 'leaseSeconds');
618
- if (leaseSeconds < 1) {
619
- throw new DestinationTunnelProtocolError('leaseSeconds must be positive');
620
- }
621
- return { type, sessionId: readNonEmptyString(message, 'sessionId'), leaseSeconds };
696
+ return {
697
+ type,
698
+ sessionId: readNonEmptyString(message, 'sessionId'),
699
+ leaseSeconds: readPositiveInteger(message, 'leaseSeconds'),
700
+ name: readNonEmptyString(message, 'name'),
701
+ ...readOptionalString(message, 'tokenExpiresAt'),
702
+ };
622
703
  }
623
704
  case 'standby': {
624
705
  const holder = readRecord(message['holder'], 'holder');
@@ -632,7 +713,16 @@ function decodeControlServerMessage(value: unknown): ControlServerMessage | unde
632
713
  type,
633
714
  instanceId: readNonEmptyString(message, 'instanceId'),
634
715
  url: readNonEmptyString(message, 'url'),
716
+ selectors: readStringArray(message, 'selectors'),
717
+ token: readNonEmptyString(message, 'token'),
718
+ expiresInSeconds: readPositiveInteger(message, 'expiresInSeconds'),
719
+ };
720
+ case 'token':
721
+ return {
722
+ type,
723
+ instanceId: readNonEmptyString(message, 'instanceId'),
635
724
  token: readNonEmptyString(message, 'token'),
725
+ expiresInSeconds: readPositiveInteger(message, 'expiresInSeconds'),
636
726
  };
637
727
  case 'detach':
638
728
  return {
@@ -662,7 +752,7 @@ function revokedMessage(reason: string): string {
662
752
  case 'deleted':
663
753
  return 'the tunnel was deleted';
664
754
  case 'credential':
665
- return 'the API key was revoked';
755
+ return 'its tunnel token was rotated, revoked or has expired';
666
756
  default:
667
757
  return `the tunnel was revoked (${reason})`;
668
758
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.60.0'; // x-release-please-version
1
+ export const VERSION = '0.62.0'; // x-release-please-version
@@ -1,13 +1,16 @@
1
- import { type DestinationTunnelInspectionConfig, type DestinationTunnelSelectors } from "./destination-tunnel.mjs";
1
+ import { type DestinationTunnelInspectionConfig } from "./destination-tunnel.mjs";
2
2
  import { type LogLevel } from "./tunnel.mjs";
3
3
  export interface TunnelConnectorOptions {
4
- apiKey: string;
4
+ /** The tunnel's token; no other credential connects. */
5
+ token: string;
5
6
  /** Limrun API base URL, such as https://api.limrun.com. */
6
7
  baseURL: string;
7
8
  organizationId: string;
8
- /** The name instances set in `spec.tunnel` to attach to this connector. */
9
- name: string;
10
- selectors: DestinationTunnelSelectors;
9
+ /**
10
+ * The ID of a tunnel created in the console or the API. Its name, which
11
+ * instances set in `spec.tunnel`, and its selectors come from the platform.
12
+ */
13
+ tunnelId: string;
11
14
  /** Inspection for every instance tunnel. Defaults to enabled without bodies. */
12
15
  inspection?: Partial<DestinationTunnelInspectionConfig>;
13
16
  /** Take the name over from the connector that holds it now. */
@@ -21,10 +24,16 @@ export interface TunnelConnectorOptions {
21
24
  onEvent?: (event: TunnelConnectorEvent) => void;
22
25
  }
23
26
  export type TunnelConnectorEvent =
24
- /** This connector holds the name; emitted once per session and control connection. */
27
+ /** This connector holds the tunnel; emitted once per session and control connection. */
25
28
  {
26
29
  type: 'active';
27
30
  sessionId: string;
31
+ name: string;
32
+ }
33
+ /** The connector's tunnel token expires within two weeks; emitted once a day until it does. */
34
+ | {
35
+ type: 'tokenExpiring';
36
+ expiresAt: string;
28
37
  }
29
38
  /** Another connector holds the name; emitted once per holder. */
30
39
  | {
@@ -68,10 +77,12 @@ export interface TunnelConnector {
68
77
  close: () => Promise<void>;
69
78
  }
70
79
  /**
71
- * Serve a persistent tunnel: hold the name through one control WebSocket to
72
- * the Limrun API and open a destination tunnel to every instance created
73
- * with `spec.tunnel` set to it. Instance tunnels stay up while the control
74
- * channel reconnects, so an API outage never becomes a tunnel outage.
80
+ * Serve a persistent tunnel: hold it through one control WebSocket to the
81
+ * Limrun API and open a destination tunnel to every instance created with
82
+ * `spec.tunnel` set to its name, with a short-lived token the API hands out
83
+ * for each. Instance tunnels stay up while the control channel reconnects,
84
+ * so an API outage never becomes a tunnel outage; one that drops re-dials
85
+ * once the API sends a fresh token.
75
86
  */
76
87
  export declare function runTunnel(options: TunnelConnectorOptions): TunnelConnector;
77
88
  //# sourceMappingURL=tunnel-connector.d.mts.map