@limrun/api 0.47.2 → 0.48.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 (55) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/destination-tunnel-dialer.d.mts +22 -0
  3. package/destination-tunnel-dialer.d.mts.map +1 -0
  4. package/destination-tunnel-dialer.d.ts +22 -0
  5. package/destination-tunnel-dialer.d.ts.map +1 -0
  6. package/destination-tunnel-dialer.js +491 -0
  7. package/destination-tunnel-dialer.js.map +1 -0
  8. package/destination-tunnel-dialer.mjs +486 -0
  9. package/destination-tunnel-dialer.mjs.map +1 -0
  10. package/destination-tunnel.d.mts +77 -0
  11. package/destination-tunnel.d.mts.map +1 -0
  12. package/destination-tunnel.d.ts +77 -0
  13. package/destination-tunnel.d.ts.map +1 -0
  14. package/destination-tunnel.js +249 -0
  15. package/destination-tunnel.js.map +1 -0
  16. package/destination-tunnel.mjs +236 -0
  17. package/destination-tunnel.mjs.map +1 -0
  18. package/internal/destination-tunnel-management.d.mts +23 -0
  19. package/internal/destination-tunnel-management.d.mts.map +1 -0
  20. package/internal/destination-tunnel-management.d.ts +23 -0
  21. package/internal/destination-tunnel-management.d.ts.map +1 -0
  22. package/internal/destination-tunnel-management.js +122 -0
  23. package/internal/destination-tunnel-management.js.map +1 -0
  24. package/internal/destination-tunnel-management.mjs +117 -0
  25. package/internal/destination-tunnel-management.mjs.map +1 -0
  26. package/internal/destination-tunnel-url.d.mts +4 -0
  27. package/internal/destination-tunnel-url.d.mts.map +1 -0
  28. package/internal/destination-tunnel-url.d.ts +4 -0
  29. package/internal/destination-tunnel-url.d.ts.map +1 -0
  30. package/internal/destination-tunnel-url.js +28 -0
  31. package/internal/destination-tunnel-url.js.map +1 -0
  32. package/internal/destination-tunnel-url.mjs +23 -0
  33. package/internal/destination-tunnel-url.mjs.map +1 -0
  34. package/ios-client.d.mts +22 -0
  35. package/ios-client.d.mts.map +1 -1
  36. package/ios-client.d.ts +22 -0
  37. package/ios-client.d.ts.map +1 -1
  38. package/ios-client.js +18 -0
  39. package/ios-client.js.map +1 -1
  40. package/ios-client.mjs +18 -0
  41. package/ios-client.mjs.map +1 -1
  42. package/package.json +21 -1
  43. package/resources/android-instances.d.mts +1 -1
  44. package/resources/android-instances.d.ts +1 -1
  45. package/src/destination-tunnel-dialer.ts +554 -0
  46. package/src/destination-tunnel.ts +354 -0
  47. package/src/internal/destination-tunnel-management.ts +154 -0
  48. package/src/internal/destination-tunnel-url.ts +25 -0
  49. package/src/ios-client.ts +48 -0
  50. package/src/resources/android-instances.ts +1 -1
  51. package/src/version.ts +1 -1
  52. package/version.d.mts +1 -1
  53. package/version.d.ts +1 -1
  54. package/version.js +1 -1
  55. package/version.mjs +1 -1
@@ -0,0 +1,354 @@
1
+ import net from 'net';
2
+
3
+ export const DESTINATION_TUNNEL_VERSION = 1;
4
+ export const DESTINATION_TUNNEL_MAX_ROUTES = 10;
5
+ export const DESTINATION_TUNNEL_CONN_ID_HEADER_BYTES = 4;
6
+
7
+ export interface DestinationTunnelRoute {
8
+ host: string;
9
+ port: number;
10
+ }
11
+
12
+ export type DestinationTunnelOpenFailureReason =
13
+ | 'dns_not_found'
14
+ | 'dns_temporary_failure'
15
+ | 'connection_refused'
16
+ | 'connection_reset'
17
+ | 'connection_timed_out'
18
+ | 'unreachable'
19
+ | 'permission_denied'
20
+ | 'resource_exhausted'
21
+ | 'route_not_allowed'
22
+ | 'cancelled'
23
+ | 'internal'
24
+ | (string & {});
25
+
26
+ export type DestinationTunnelResetReason =
27
+ | 'cancelled'
28
+ | 'connection_error'
29
+ | 'protocol_error'
30
+ | 'resource_exhausted'
31
+ | 'internal'
32
+ | (string & {});
33
+
34
+ export type DestinationTunnelSessionErrorCode =
35
+ | 'unsupported_version'
36
+ | 'invalid_message'
37
+ | 'invalid_route'
38
+ | 'route_capacity'
39
+ | 'already_active'
40
+ | 'unavailable'
41
+ | 'internal'
42
+ | (string & {});
43
+
44
+ export type DestinationTunnelClientMessage =
45
+ | { type: 'start'; version: number; routes: DestinationTunnelRoute[] }
46
+ | { type: 'openOk'; connId: number }
47
+ | {
48
+ type: 'openFail';
49
+ connId: number;
50
+ reason: DestinationTunnelOpenFailureReason;
51
+ osCode?: string;
52
+ }
53
+ | { type: 'fin'; connId: number }
54
+ | { type: 'reset'; connId: number; reason: DestinationTunnelResetReason; osCode?: string };
55
+
56
+ export type DestinationTunnelServerMessage =
57
+ | { type: 'ready'; version: number; tunnelId: string }
58
+ | {
59
+ type: 'open';
60
+ connId: number;
61
+ routeId: string;
62
+ host: string;
63
+ port: number;
64
+ proto: 'tcp';
65
+ }
66
+ | { type: 'fin'; connId: number }
67
+ | { type: 'reset'; connId: number; reason: DestinationTunnelResetReason; osCode?: string }
68
+ | { type: 'error'; code: DestinationTunnelSessionErrorCode };
69
+
70
+ export type DestinationTunnelRouteErrorCode =
71
+ | 'empty'
72
+ | 'too_many'
73
+ | 'invalid_host'
74
+ | 'invalid_port'
75
+ | 'duplicate';
76
+
77
+ export class DestinationTunnelRouteError extends Error {
78
+ constructor(
79
+ readonly code: DestinationTunnelRouteErrorCode,
80
+ message: string,
81
+ ) {
82
+ super(message);
83
+ this.name = 'DestinationTunnelRouteError';
84
+ }
85
+ }
86
+
87
+ export class DestinationTunnelProtocolError extends Error {
88
+ constructor(message: string) {
89
+ super(message);
90
+ this.name = 'DestinationTunnelProtocolError';
91
+ }
92
+ }
93
+
94
+ export function encodeDestinationTunnelDataFrame(connId: number, payload: Buffer): Buffer {
95
+ validateConnectionId(connId);
96
+ if (payload.length === 0) {
97
+ throw new DestinationTunnelProtocolError('tunnel data frame must include a payload');
98
+ }
99
+ const frame = Buffer.allocUnsafe(DESTINATION_TUNNEL_CONN_ID_HEADER_BYTES + payload.length);
100
+ frame.writeUInt32BE(connId, 0);
101
+ payload.copy(frame, DESTINATION_TUNNEL_CONN_ID_HEADER_BYTES);
102
+ return frame;
103
+ }
104
+
105
+ export function decodeDestinationTunnelDataFrame(frame: Buffer): {
106
+ connId: number;
107
+ payload: Buffer;
108
+ } {
109
+ if (frame.length <= DESTINATION_TUNNEL_CONN_ID_HEADER_BYTES) {
110
+ throw new DestinationTunnelProtocolError('tunnel data frame must include a payload');
111
+ }
112
+ return {
113
+ connId: frame.readUInt32BE(0),
114
+ payload: frame.subarray(DESTINATION_TUNNEL_CONN_ID_HEADER_BYTES),
115
+ };
116
+ }
117
+
118
+ export function validateDestinationTunnelRoutes(
119
+ routes: readonly DestinationTunnelRoute[],
120
+ ): DestinationTunnelRoute[] {
121
+ if (routes.length === 0) {
122
+ throw new DestinationTunnelRouteError('empty', 'at least one tunnel route is required');
123
+ }
124
+ if (routes.length > DESTINATION_TUNNEL_MAX_ROUTES) {
125
+ throw new DestinationTunnelRouteError(
126
+ 'too_many',
127
+ `at most ${DESTINATION_TUNNEL_MAX_ROUTES} tunnel routes are allowed`,
128
+ );
129
+ }
130
+
131
+ const canonicalRoutes: DestinationTunnelRoute[] = [];
132
+ const seen = new Set<string>();
133
+ for (const route of routes) {
134
+ const canonical = canonicalizeDestinationTunnelRoute(route);
135
+ const key = `${canonical.host}\0${canonical.port}`;
136
+ if (seen.has(key)) {
137
+ throw new DestinationTunnelRouteError(
138
+ 'duplicate',
139
+ `duplicate tunnel route ${canonical.host}:${canonical.port}`,
140
+ );
141
+ }
142
+ seen.add(key);
143
+ canonicalRoutes.push(canonical);
144
+ }
145
+ return canonicalRoutes;
146
+ }
147
+
148
+ export function assertDestinationTunnelOpenAllowed(
149
+ message: Extract<DestinationTunnelServerMessage, { type: 'open' }>,
150
+ routes: readonly DestinationTunnelRoute[],
151
+ ): void {
152
+ const index = parseRouteId(message.routeId);
153
+ const route = routes[index];
154
+ if (!route || message.host !== route.host || message.port !== route.port || message.proto !== 'tcp') {
155
+ throw new DestinationTunnelProtocolError(
156
+ `server requested undeclared route ${message.routeId} ${message.host}:${message.port}/${message.proto}`,
157
+ );
158
+ }
159
+ }
160
+
161
+ export function assertDestinationTunnelReady(
162
+ message: Extract<DestinationTunnelServerMessage, { type: 'ready' }>,
163
+ ): void {
164
+ if (message.version !== DESTINATION_TUNNEL_VERSION) {
165
+ throw new DestinationTunnelProtocolError(`unsupported tunnel version ${message.version}`);
166
+ }
167
+ }
168
+
169
+ export function encodeDestinationTunnelClientMessage(message: DestinationTunnelClientMessage): string {
170
+ const record = readRecord(message, 'tunnel control message');
171
+ const type = readString(record, 'type');
172
+
173
+ switch (type) {
174
+ case 'start': {
175
+ const version = readInteger(record, 'version');
176
+ if (version !== DESTINATION_TUNNEL_VERSION) {
177
+ throw new DestinationTunnelProtocolError(`unsupported tunnel version ${version}`);
178
+ }
179
+ const routes = validateDestinationTunnelRoutes(readArray(record, 'routes').map(readRoute));
180
+ return JSON.stringify({ type, version, routes });
181
+ }
182
+ case 'openOk':
183
+ case 'fin':
184
+ return JSON.stringify({ type, connId: readConnectionId(record) });
185
+ case 'openFail':
186
+ case 'reset':
187
+ return JSON.stringify({
188
+ type,
189
+ connId: readConnectionId(record),
190
+ reason: readString(record, 'reason'),
191
+ ...readOptionalString(record, 'osCode'),
192
+ });
193
+ default:
194
+ throw new DestinationTunnelProtocolError(`unknown tunnel control message type ${type}`);
195
+ }
196
+ }
197
+
198
+ export function decodeDestinationTunnelServerMessage(value: unknown): DestinationTunnelServerMessage {
199
+ const message = readRecord(value, 'tunnel control message');
200
+ const type = readString(message, 'type');
201
+
202
+ switch (type) {
203
+ case 'ready':
204
+ return {
205
+ type,
206
+ version: readInteger(message, 'version'),
207
+ tunnelId: readString(message, 'tunnelId'),
208
+ };
209
+ case 'open':
210
+ return {
211
+ type,
212
+ connId: readConnectionId(message),
213
+ routeId: readString(message, 'routeId'),
214
+ host: readString(message, 'host'),
215
+ port: readPort(message, 'port'),
216
+ proto: readTCP(message),
217
+ };
218
+ case 'fin':
219
+ return { type, connId: readConnectionId(message) };
220
+ case 'reset':
221
+ return {
222
+ type,
223
+ connId: readConnectionId(message),
224
+ reason: readString(message, 'reason'),
225
+ ...readOptionalString(message, 'osCode'),
226
+ };
227
+ case 'error':
228
+ return { type, code: readString(message, 'code') };
229
+ default:
230
+ throw new DestinationTunnelProtocolError(`unknown tunnel control message type ${type}`);
231
+ }
232
+ }
233
+
234
+ function canonicalizeDestinationTunnelRoute(route: DestinationTunnelRoute): DestinationTunnelRoute {
235
+ if (!Number.isInteger(route.port) || route.port < 1 || route.port > 65_535 || route.port === 53) {
236
+ throw new DestinationTunnelRouteError('invalid_port', `invalid tunnel route port ${route.port}`);
237
+ }
238
+
239
+ const asciiHost = Buffer.byteLength(route.host, 'utf8') === route.host.length;
240
+ if (asciiHost && route.host.toLowerCase() === 'localhost') {
241
+ return { host: 'localhost', port: route.port };
242
+ }
243
+
244
+ const ipVersion = net.isIP(route.host);
245
+ if (ipVersion === 4) {
246
+ return { host: route.host, port: route.port };
247
+ }
248
+ if (ipVersion === 6) {
249
+ const hostname = new URL(`http://[${route.host}]/`).hostname;
250
+ const canonical = hostname.slice(1, -1);
251
+ if (canonical !== '::1' && /^::(?:[0-9a-f]{1,4}:)?[0-9a-f]{1,4}$/.test(canonical)) {
252
+ throw new DestinationTunnelRouteError('invalid_host', `invalid tunnel route host ${route.host}`);
253
+ }
254
+ return { host: canonicalizeIPv6(canonical), port: route.port };
255
+ }
256
+
257
+ throw new DestinationTunnelRouteError('invalid_host', `invalid tunnel route host ${route.host}`);
258
+ }
259
+
260
+ function canonicalizeIPv6(host: string): string {
261
+ const mappedIPv4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
262
+ if (!mappedIPv4?.[1] || !mappedIPv4[2]) {
263
+ return host;
264
+ }
265
+ const high = Number.parseInt(mappedIPv4[1], 16);
266
+ const low = Number.parseInt(mappedIPv4[2], 16);
267
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
268
+ }
269
+
270
+ function parseRouteId(routeId: string): number {
271
+ const match = /^route-([1-9]\d*)$/.exec(routeId);
272
+ if (!match?.[1]) {
273
+ return -1;
274
+ }
275
+ return Number(match[1]) - 1;
276
+ }
277
+
278
+ function readRoute(value: unknown): DestinationTunnelRoute {
279
+ const route = readRecord(value, 'tunnel route');
280
+ return {
281
+ host: readString(route, 'host'),
282
+ port: readPort(route, 'port'),
283
+ };
284
+ }
285
+
286
+ function readRecord(value: unknown, name: string): Record<string, unknown> {
287
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
288
+ throw new DestinationTunnelProtocolError(`${name} must be an object`);
289
+ }
290
+ return value as Record<string, unknown>;
291
+ }
292
+
293
+ function readArray(record: Record<string, unknown>, key: string): unknown[] {
294
+ const value = record[key];
295
+ if (!Array.isArray(value)) {
296
+ throw new DestinationTunnelProtocolError(`${key} must be an array`);
297
+ }
298
+ return value;
299
+ }
300
+
301
+ function readString(record: Record<string, unknown>, key: string): string {
302
+ const value = record[key];
303
+ if (typeof value !== 'string') {
304
+ throw new DestinationTunnelProtocolError(`${key} must be a string`);
305
+ }
306
+ return value;
307
+ }
308
+
309
+ function readOptionalString(record: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
310
+ const value = record[key];
311
+ if (value === undefined) {
312
+ return {};
313
+ }
314
+ if (typeof value !== 'string') {
315
+ throw new DestinationTunnelProtocolError(`${key} must be a string`);
316
+ }
317
+ return { [key]: value };
318
+ }
319
+
320
+ function readInteger(record: Record<string, unknown>, key: string): number {
321
+ const value = record[key];
322
+ if (!Number.isInteger(value)) {
323
+ throw new DestinationTunnelProtocolError(`${key} must be an integer`);
324
+ }
325
+ return value as number;
326
+ }
327
+
328
+ function readPort(record: Record<string, unknown>, key: string): number {
329
+ const value = readInteger(record, key);
330
+ if (value < 1 || value > 65_535) {
331
+ throw new DestinationTunnelProtocolError(`${key} must be between 1 and 65535`);
332
+ }
333
+ return value;
334
+ }
335
+
336
+ function readConnectionId(record: Record<string, unknown>): number {
337
+ const value = readInteger(record, 'connId');
338
+ validateConnectionId(value);
339
+ return value;
340
+ }
341
+
342
+ function validateConnectionId(value: number): void {
343
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) {
344
+ throw new DestinationTunnelProtocolError('connId must be an unsigned 32-bit integer');
345
+ }
346
+ }
347
+
348
+ function readTCP(record: Record<string, unknown>): 'tcp' {
349
+ const value = readString(record, 'proto');
350
+ if (value !== 'tcp') {
351
+ throw new DestinationTunnelProtocolError(`unsupported tunnel transport ${value}`);
352
+ }
353
+ return value;
354
+ }
@@ -0,0 +1,154 @@
1
+ import { nodeProxyTransport } from './proxy-transport';
2
+ import { deriveDestinationTunnelStatusURL, deriveDestinationTunnelStopURL } from './destination-tunnel-url';
3
+ import { validateDestinationTunnelRoutes, type DestinationTunnelRoute } from '../destination-tunnel';
4
+
5
+ export interface DestinationTunnelStatus {
6
+ active?: {
7
+ tunnelId: string;
8
+ state: 'starting' | 'ready' | 'stopping';
9
+ routes: DestinationTunnelRoute[];
10
+ };
11
+ lastFailure?: {
12
+ tunnelId: string;
13
+ code: string;
14
+ };
15
+ lastDialFailure?: {
16
+ tunnelId: string;
17
+ connectionId: number;
18
+ routeId: string;
19
+ reason: string;
20
+ osCode?: string;
21
+ };
22
+ }
23
+
24
+ export async function getDestinationTunnelStatus(
25
+ apiUrl: string,
26
+ token: string,
27
+ ): Promise<DestinationTunnelStatus> {
28
+ const response = await nodeProxyTransport.fetch(deriveDestinationTunnelStatusURL(apiUrl).toString(), {
29
+ headers: { Authorization: `Bearer ${token}` },
30
+ });
31
+ if (!response.ok) {
32
+ throw new Error(`getTunnelStatus failed: ${response.status} ${await response.text()}`);
33
+ }
34
+ return decodeDestinationTunnelStatus(await response.json());
35
+ }
36
+
37
+ export async function stopDestinationTunnel(apiUrl: string, token: string, tunnelId: string): Promise<void> {
38
+ if (!tunnelId.trim()) {
39
+ throw new Error('tunnelId must not be empty');
40
+ }
41
+ const response = await nodeProxyTransport.fetch(
42
+ deriveDestinationTunnelStopURL(apiUrl, tunnelId).toString(),
43
+ {
44
+ method: 'DELETE',
45
+ headers: { Authorization: `Bearer ${token}` },
46
+ },
47
+ );
48
+ if (!response.ok) {
49
+ throw new Error(`stopTunnel failed: ${response.status} ${await response.text()}`);
50
+ }
51
+ }
52
+
53
+ export function decodeDestinationTunnelStatus(value: unknown): DestinationTunnelStatus {
54
+ const status = readRecord(value, 'tunnel status');
55
+ const decodedStatus: DestinationTunnelStatus = {};
56
+ if (status['active'] !== undefined) {
57
+ decodedStatus.active = readActiveTunnel(status['active']);
58
+ }
59
+ if (status['lastFailure'] !== undefined) {
60
+ decodedStatus.lastFailure = readTunnelFailure(status['lastFailure']);
61
+ }
62
+ if (status['lastDialFailure'] !== undefined) {
63
+ decodedStatus.lastDialFailure = readTunnelDialFailure(status['lastDialFailure']);
64
+ }
65
+ return decodedStatus;
66
+ }
67
+
68
+ function readActiveTunnel(value: unknown): NonNullable<DestinationTunnelStatus['active']> {
69
+ const active = readRecord(value, 'active tunnel');
70
+ const state = readString(active, 'state');
71
+ if (state !== 'starting' && state !== 'ready' && state !== 'stopping') {
72
+ throw new Error(`invalid tunnel state ${state}`);
73
+ }
74
+ return {
75
+ tunnelId: readNonEmptyString(active, 'tunnelId'),
76
+ state,
77
+ routes: validateDestinationTunnelRoutes(readArray(active, 'routes').map(readTunnelRoute)),
78
+ };
79
+ }
80
+
81
+ function readTunnelRoute(value: unknown): DestinationTunnelRoute {
82
+ const route = readRecord(value, 'tunnel route');
83
+ const port = route['port'];
84
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) {
85
+ throw new Error('tunnel route port must be an integer from 1 to 65535');
86
+ }
87
+ return {
88
+ host: readNonEmptyString(route, 'host'),
89
+ port,
90
+ };
91
+ }
92
+
93
+ function readTunnelFailure(value: unknown): NonNullable<DestinationTunnelStatus['lastFailure']> {
94
+ const failure = readRecord(value, 'tunnel failure');
95
+ return {
96
+ tunnelId: readNonEmptyString(failure, 'tunnelId'),
97
+ code: readNonEmptyString(failure, 'code'),
98
+ };
99
+ }
100
+
101
+ function readTunnelDialFailure(value: unknown): NonNullable<DestinationTunnelStatus['lastDialFailure']> {
102
+ const failure = readRecord(value, 'tunnel dial failure');
103
+ const connectionId = failure['connectionId'];
104
+ if (
105
+ typeof connectionId !== 'number' ||
106
+ !Number.isInteger(connectionId) ||
107
+ connectionId < 0 ||
108
+ connectionId > 0xffff_ffff
109
+ ) {
110
+ throw new Error('tunnel dial failure connectionId must be an unsigned 32-bit integer');
111
+ }
112
+ const osCode = failure['osCode'];
113
+ if (osCode !== undefined && typeof osCode !== 'string') {
114
+ throw new Error('tunnel dial failure osCode must be a string');
115
+ }
116
+ return {
117
+ tunnelId: readNonEmptyString(failure, 'tunnelId'),
118
+ connectionId,
119
+ routeId: readNonEmptyString(failure, 'routeId'),
120
+ reason: readNonEmptyString(failure, 'reason'),
121
+ ...(osCode === undefined ? {} : { osCode }),
122
+ };
123
+ }
124
+
125
+ function readRecord(value: unknown, name: string): Record<string, unknown> {
126
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
127
+ throw new Error(`${name} must be an object`);
128
+ }
129
+ return value as Record<string, unknown>;
130
+ }
131
+
132
+ function readArray(record: Record<string, unknown>, key: string): unknown[] {
133
+ const value = record[key];
134
+ if (!Array.isArray(value)) {
135
+ throw new Error(`${key} must be an array`);
136
+ }
137
+ return value;
138
+ }
139
+
140
+ function readString(record: Record<string, unknown>, key: string): string {
141
+ const value = record[key];
142
+ if (typeof value !== 'string') {
143
+ throw new Error(`${key} must be a string`);
144
+ }
145
+ return value;
146
+ }
147
+
148
+ function readNonEmptyString(record: Record<string, unknown>, key: string): string {
149
+ const value = readString(record, key);
150
+ if (value.length === 0) {
151
+ throw new Error(`${key} must not be empty`);
152
+ }
153
+ return value;
154
+ }
@@ -0,0 +1,25 @@
1
+ function deriveDestinationTunnelEndpointURL(apiUrl: string, suffix?: string): URL {
2
+ const url = new URL(apiUrl);
3
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
4
+ throw new Error(`Unsupported apiUrl protocol for tunnel: ${url.protocol}`);
5
+ }
6
+ const tunnelPath = `${url.pathname.replace(/\/+$/, '')}/tunnel`;
7
+ url.pathname = suffix === undefined ? tunnelPath : `${tunnelPath}/${suffix}`;
8
+ url.search = '';
9
+ url.hash = '';
10
+ return url;
11
+ }
12
+
13
+ export function deriveDestinationTunnelURL(apiUrl: string): string {
14
+ const url = deriveDestinationTunnelEndpointURL(apiUrl);
15
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
16
+ return url.toString();
17
+ }
18
+
19
+ export function deriveDestinationTunnelStatusURL(apiUrl: string): URL {
20
+ return deriveDestinationTunnelEndpointURL(apiUrl, 'status');
21
+ }
22
+
23
+ export function deriveDestinationTunnelStopURL(apiUrl: string, tunnelId: string): URL {
24
+ return deriveDestinationTunnelEndpointURL(apiUrl, encodeURIComponent(tunnelId));
25
+ }
package/src/ios-client.ts CHANGED
@@ -5,12 +5,20 @@ import fs from 'fs';
5
5
  import { WebSocket, Data } from 'ws';
6
6
  import { EventEmitter } from 'events';
7
7
  import { assertPort, isNonRetryableError, startReverseTcpTunnel, type ReverseTunnel } from './tunnel';
8
+ import { startDestinationTcpTunnel, type DestinationTcpTunnel } from './destination-tunnel-dialer';
9
+ import type { DestinationTunnelRoute } from './destination-tunnel';
8
10
  import { type SyncFolderResult, type FolderSyncOptions, syncFolder } from './folder-sync';
9
11
  import { createIgnoreFn } from './folder-sync-ignore';
10
12
  import { prepareAppBundlePath, watchAppArchive } from './app-archive';
11
13
  import { downloadFileToLocalPath } from './internal/download-file';
12
14
  import { sleep } from './internal/utils/sleep';
13
15
  import { nodeProxyTransport } from './internal/proxy-transport';
16
+ import {
17
+ getDestinationTunnelStatus,
18
+ stopDestinationTunnel,
19
+ type DestinationTunnelStatus,
20
+ } from './internal/destination-tunnel-management';
21
+ import { deriveDestinationTunnelURL } from './internal/destination-tunnel-url';
14
22
  import {
15
23
  startHttpProxy as startLocalHttpProxy,
16
24
  startForwardHttpProxy as startLocalForwardHttpProxy,
@@ -55,6 +63,14 @@ export function deriveReverseTunnelUrl(apiUrl: string, remotePort: number): stri
55
63
  }
56
64
 
57
65
  export type { ReverseTunnel } from './tunnel';
66
+ export type Tunnel = DestinationTcpTunnel;
67
+ export type TunnelOptions = {
68
+ /** Exact localhost or literal-IP TCP destinations that the server may ask this client to dial. */
69
+ routes: DestinationTunnelRoute[];
70
+ /** Controls tunnel logging verbosity. Defaults to the instance client's log level. */
71
+ logLevel?: LogLevel;
72
+ };
73
+ export type TunnelStatus = DestinationTunnelStatus;
58
74
 
59
75
  /**
60
76
  * Events emitted by a simctl execution
@@ -855,6 +871,20 @@ export type InstanceClient = {
855
871
  */
856
872
  startReverseTunnel: (options: ReverseTunnelOptions) => Promise<ReverseTunnel>;
857
873
 
874
+ /**
875
+ * Transparently route declared simulator TCP destinations through this machine.
876
+ *
877
+ * The caller owns the returned tunnel and must close it. Disconnecting this
878
+ * instance client does not close the tunnel.
879
+ */
880
+ startTunnel: (options: TunnelOptions) => Promise<Tunnel>;
881
+
882
+ /** Get the active destination tunnel and most recent terminal failure. */
883
+ getTunnelStatus: () => Promise<TunnelStatus>;
884
+
885
+ /** Stop the active tunnel only when its ID matches `tunnelId`. */
886
+ stopTunnel: (tunnelId: string) => Promise<void>;
887
+
858
888
  /**
859
889
  * Start a local HTTP proxy to a port exposed by the iOS instance.
860
890
  *
@@ -2035,6 +2065,9 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
2035
2065
  discoverStoreKitConfig,
2036
2066
  softReset,
2037
2067
  startReverseTunnel,
2068
+ startTunnel,
2069
+ getTunnelStatus,
2070
+ stopTunnel,
2038
2071
  startHttpProxy,
2039
2072
  startForwardHttpProxy,
2040
2073
  disconnect,
@@ -2823,6 +2856,21 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
2823
2856
  });
2824
2857
  };
2825
2858
 
2859
+ const startTunnel = async (tunnelOptions: TunnelOptions): Promise<Tunnel> => {
2860
+ return startDestinationTcpTunnel(deriveDestinationTunnelURL(options.apiUrl), options.token, {
2861
+ routes: tunnelOptions.routes,
2862
+ logLevel: tunnelOptions.logLevel ?? logLevel,
2863
+ });
2864
+ };
2865
+
2866
+ const getTunnelStatus = async (): Promise<TunnelStatus> => {
2867
+ return getDestinationTunnelStatus(options.apiUrl, options.token);
2868
+ };
2869
+
2870
+ const stopTunnel = async (tunnelId: string): Promise<void> => {
2871
+ await stopDestinationTunnel(options.apiUrl, options.token, tunnelId);
2872
+ };
2873
+
2826
2874
  const startHttpProxy = async (proxyOptions: HttpProxyOptions): Promise<number> => {
2827
2875
  assertPort(proxyOptions.localPort, 'localPort', 1, 65535);
2828
2876
 
@@ -228,7 +228,7 @@ export namespace AndroidInstanceCreateParams {
228
228
  clientIp?: string;
229
229
 
230
230
  /**
231
- * The major version of Android, e.g. "13", "14" or "15".
231
+ * The major version of Android, e.g. "14" or "15".
232
232
  */
233
233
  osVersion?: string;
234
234
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.47.2'; // x-release-please-version
1
+ export const VERSION = '0.48.0'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.47.2";
1
+ export declare const VERSION = "0.48.0";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.47.2";
1
+ export declare const VERSION = "0.48.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '0.47.2'; // x-release-please-version
4
+ exports.VERSION = '0.48.0'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.47.2'; // x-release-please-version
1
+ export const VERSION = '0.48.0'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map