@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.
- package/CHANGELOG.md +13 -0
- package/destination-tunnel-dialer.d.mts +22 -0
- package/destination-tunnel-dialer.d.mts.map +1 -0
- package/destination-tunnel-dialer.d.ts +22 -0
- package/destination-tunnel-dialer.d.ts.map +1 -0
- package/destination-tunnel-dialer.js +491 -0
- package/destination-tunnel-dialer.js.map +1 -0
- package/destination-tunnel-dialer.mjs +486 -0
- package/destination-tunnel-dialer.mjs.map +1 -0
- package/destination-tunnel.d.mts +77 -0
- package/destination-tunnel.d.mts.map +1 -0
- package/destination-tunnel.d.ts +77 -0
- package/destination-tunnel.d.ts.map +1 -0
- package/destination-tunnel.js +249 -0
- package/destination-tunnel.js.map +1 -0
- package/destination-tunnel.mjs +236 -0
- package/destination-tunnel.mjs.map +1 -0
- package/internal/destination-tunnel-management.d.mts +23 -0
- package/internal/destination-tunnel-management.d.mts.map +1 -0
- package/internal/destination-tunnel-management.d.ts +23 -0
- package/internal/destination-tunnel-management.d.ts.map +1 -0
- package/internal/destination-tunnel-management.js +122 -0
- package/internal/destination-tunnel-management.js.map +1 -0
- package/internal/destination-tunnel-management.mjs +117 -0
- package/internal/destination-tunnel-management.mjs.map +1 -0
- package/internal/destination-tunnel-url.d.mts +4 -0
- package/internal/destination-tunnel-url.d.mts.map +1 -0
- package/internal/destination-tunnel-url.d.ts +4 -0
- package/internal/destination-tunnel-url.d.ts.map +1 -0
- package/internal/destination-tunnel-url.js +28 -0
- package/internal/destination-tunnel-url.js.map +1 -0
- package/internal/destination-tunnel-url.mjs +23 -0
- package/internal/destination-tunnel-url.mjs.map +1 -0
- package/ios-client.d.mts +22 -0
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +22 -0
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +18 -0
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +18 -0
- package/ios-client.mjs.map +1 -1
- package/package.json +21 -1
- package/resources/android-instances.d.mts +1 -1
- package/resources/android-instances.d.ts +1 -1
- package/src/destination-tunnel-dialer.ts +554 -0
- package/src/destination-tunnel.ts +354 -0
- package/src/internal/destination-tunnel-management.ts +154 -0
- package/src/internal/destination-tunnel-url.ts +25 -0
- package/src/ios-client.ts +48 -0
- package/src/resources/android-instances.ts +1 -1
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- 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
|
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
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.
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.48.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/version.js
CHANGED
package/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.48.0'; // x-release-please-version
|
|
2
2
|
//# sourceMappingURL=version.mjs.map
|