@limrun/api 0.28.4 → 0.28.5
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 +21 -0
- package/ios-client.d.mts +48 -1
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +48 -1
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +61 -4
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +60 -4
- package/ios-client.mjs.map +1 -1
- package/package.json +1 -1
- package/src/ios-client.ts +125 -8
- package/src/tunnel.ts +445 -0
- package/src/version.ts +1 -1
- package/tunnel.d.mts +50 -0
- package/tunnel.d.mts.map +1 -1
- package/tunnel.d.ts +50 -0
- package/tunnel.d.ts.map +1 -1
- package/tunnel.js +362 -0
- package/tunnel.js.map +1 -1
- package/tunnel.mjs +360 -0
- package/tunnel.mjs.map +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
package/src/tunnel.ts
CHANGED
|
@@ -42,6 +42,16 @@ export interface Tunnel {
|
|
|
42
42
|
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface ReverseTunnel {
|
|
46
|
+
remoteAddress: {
|
|
47
|
+
address: string;
|
|
48
|
+
port: number;
|
|
49
|
+
};
|
|
50
|
+
close: () => void;
|
|
51
|
+
getConnectionState: () => TunnelConnectionState;
|
|
52
|
+
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
export interface TcpTunnelOptions {
|
|
46
56
|
/**
|
|
47
57
|
* Maximum number of reconnection attempts
|
|
@@ -73,6 +83,38 @@ export interface TcpTunnelOptions {
|
|
|
73
83
|
mode?: TunnelMode;
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
export interface ReverseTcpTunnelOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Hostname or IP address of the user-local service.
|
|
89
|
+
* @default '127.0.0.1'
|
|
90
|
+
*/
|
|
91
|
+
localHost?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Port of the user-local service.
|
|
94
|
+
*/
|
|
95
|
+
localPort: number;
|
|
96
|
+
/**
|
|
97
|
+
* Controls logging verbosity
|
|
98
|
+
* @default 'info'
|
|
99
|
+
*/
|
|
100
|
+
logLevel?: LogLevel;
|
|
101
|
+
/**
|
|
102
|
+
* Maximum concurrent remote TCP connections.
|
|
103
|
+
* @default 64
|
|
104
|
+
*/
|
|
105
|
+
maxConnections?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Maximum bytes buffered per connection while the local TCP connect is pending.
|
|
108
|
+
* @default 16777216
|
|
109
|
+
*/
|
|
110
|
+
maxPendingBytesPerConnection?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Local TCP connect timeout in milliseconds.
|
|
113
|
+
* @default 10000
|
|
114
|
+
*/
|
|
115
|
+
connectTimeoutMs?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
76
118
|
/**
|
|
77
119
|
* Starts a persistent TCP → WebSocket proxy.
|
|
78
120
|
*
|
|
@@ -105,6 +147,403 @@ export async function startTcpTunnel(
|
|
|
105
147
|
return startSingletonTcpTunnel(remoteURL, token, hostname, port, options);
|
|
106
148
|
}
|
|
107
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Starts a reverse TCP tunnel for client-first protocols.
|
|
152
|
+
*
|
|
153
|
+
* The remote endpoint accepts TCP connections near the simulator and sends them
|
|
154
|
+
* through `remoteURL` as multiplexed WebSocket frames. For each remote connID,
|
|
155
|
+
* this client opens a TCP connection to `localHost:localPort` after the first
|
|
156
|
+
* payload arrives, then pipes bytes in both directions.
|
|
157
|
+
*/
|
|
158
|
+
export async function startReverseTcpTunnel(
|
|
159
|
+
remoteURL: string,
|
|
160
|
+
token: string,
|
|
161
|
+
options: ReverseTcpTunnelOptions,
|
|
162
|
+
): Promise<ReverseTunnel> {
|
|
163
|
+
assertPort(options.localPort, 'localPort', 1, 65535);
|
|
164
|
+
|
|
165
|
+
const localHost = options.localHost ?? '127.0.0.1';
|
|
166
|
+
const localPort = options.localPort;
|
|
167
|
+
const logLevel = options.logLevel ?? 'info';
|
|
168
|
+
const maxConnections = options.maxConnections ?? 64;
|
|
169
|
+
const maxPendingBytesPerConnection = options.maxPendingBytesPerConnection ?? 16 * 1024 * 1024;
|
|
170
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? 10_000;
|
|
171
|
+
|
|
172
|
+
const logger = {
|
|
173
|
+
debug: (...args: any[]) => {
|
|
174
|
+
if (logLevel === 'debug') console.log('[ReverseTunnel]', ...args);
|
|
175
|
+
},
|
|
176
|
+
info: (...args: any[]) => {
|
|
177
|
+
if (logLevel === 'info' || logLevel === 'debug') console.log('[ReverseTunnel]', ...args);
|
|
178
|
+
},
|
|
179
|
+
warn: (...args: any[]) => {
|
|
180
|
+
if (logLevel === 'warn' || logLevel === 'info' || logLevel === 'debug')
|
|
181
|
+
console.warn('[ReverseTunnel]', ...args);
|
|
182
|
+
},
|
|
183
|
+
error: (...args: any[]) => {
|
|
184
|
+
if (logLevel !== 'none') console.error('[ReverseTunnel]', ...args);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const connections: Map<number, net.Socket> = new Map();
|
|
190
|
+
const connecting: Set<number> = new Set();
|
|
191
|
+
const pendingPerConn: Map<number, Buffer[]> = new Map();
|
|
192
|
+
const pendingBytesPerConn: Map<number, number> = new Map();
|
|
193
|
+
const connectTimers: Map<number, NodeJS.Timeout> = new Map();
|
|
194
|
+
const recentlyClosedConnIds: Map<number, NodeJS.Timeout> = new Map();
|
|
195
|
+
const stateChangeCallbacks: Set<TunnelConnectionStateCallback> = new Set();
|
|
196
|
+
|
|
197
|
+
let ws: WebSocket | undefined;
|
|
198
|
+
let pingInterval: NodeJS.Timeout | undefined;
|
|
199
|
+
let intentionalDisconnect = false;
|
|
200
|
+
let connectionState: TunnelConnectionState = 'connecting';
|
|
201
|
+
let hasResolved = false;
|
|
202
|
+
let remoteAddress: ReverseTunnel['remoteAddress'] | undefined;
|
|
203
|
+
|
|
204
|
+
const updateConnectionState = (newState: TunnelConnectionState): void => {
|
|
205
|
+
if (connectionState !== newState) {
|
|
206
|
+
connectionState = newState;
|
|
207
|
+
logger.debug(`Connection state changed to: ${newState}`);
|
|
208
|
+
stateChangeCallbacks.forEach((callback) => {
|
|
209
|
+
try {
|
|
210
|
+
callback(newState);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
logger.error('Error in connection state callback:', err);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const sendCloseSignal = (connId: number): void => {
|
|
219
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
220
|
+
ws.send(encodeConnectionHeader(connId));
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const markRecentlyClosed = (connId: number): void => {
|
|
225
|
+
const existingTimer = recentlyClosedConnIds.get(connId);
|
|
226
|
+
if (existingTimer) {
|
|
227
|
+
clearTimeout(existingTimer);
|
|
228
|
+
}
|
|
229
|
+
const timer = setTimeout(() => {
|
|
230
|
+
recentlyClosedConnIds.delete(connId);
|
|
231
|
+
}, 30_000);
|
|
232
|
+
timer.unref();
|
|
233
|
+
recentlyClosedConnIds.set(connId, timer);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const removeConnection = (connId: number, sendClose: boolean, graceful = false): void => {
|
|
237
|
+
const socket = connections.get(connId);
|
|
238
|
+
const connectTimer = connectTimers.get(connId);
|
|
239
|
+
connections.delete(connId);
|
|
240
|
+
connecting.delete(connId);
|
|
241
|
+
pendingPerConn.delete(connId);
|
|
242
|
+
pendingBytesPerConn.delete(connId);
|
|
243
|
+
connectTimers.delete(connId);
|
|
244
|
+
markRecentlyClosed(connId);
|
|
245
|
+
if (connectTimer) {
|
|
246
|
+
clearTimeout(connectTimer);
|
|
247
|
+
}
|
|
248
|
+
if (sendClose) {
|
|
249
|
+
sendCloseSignal(connId);
|
|
250
|
+
}
|
|
251
|
+
if (socket && !socket.destroyed) {
|
|
252
|
+
if (graceful) {
|
|
253
|
+
socket.end();
|
|
254
|
+
setTimeout(() => {
|
|
255
|
+
if (!socket.destroyed) {
|
|
256
|
+
socket.destroy();
|
|
257
|
+
}
|
|
258
|
+
}, 1_000).unref();
|
|
259
|
+
} else {
|
|
260
|
+
socket.destroy();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const closeAllConnections = (): void => {
|
|
266
|
+
for (const connId of Array.from(connections.keys())) {
|
|
267
|
+
removeConnection(connId, false);
|
|
268
|
+
}
|
|
269
|
+
connections.clear();
|
|
270
|
+
connecting.clear();
|
|
271
|
+
pendingPerConn.clear();
|
|
272
|
+
pendingBytesPerConn.clear();
|
|
273
|
+
for (const timer of recentlyClosedConnIds.values()) {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
}
|
|
276
|
+
recentlyClosedConnIds.clear();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const cleanupWebSocket = (): void => {
|
|
280
|
+
if (pingInterval) {
|
|
281
|
+
clearInterval(pingInterval);
|
|
282
|
+
pingInterval = undefined;
|
|
283
|
+
}
|
|
284
|
+
if (ws) {
|
|
285
|
+
ws.removeAllListeners();
|
|
286
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
287
|
+
ws.close(1000, 'close');
|
|
288
|
+
}
|
|
289
|
+
ws = undefined;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const close = (): void => {
|
|
294
|
+
intentionalDisconnect = true;
|
|
295
|
+
cleanupWebSocket();
|
|
296
|
+
closeAllConnections();
|
|
297
|
+
updateConnectionState('disconnected');
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const getConnectionState = (): TunnelConnectionState => {
|
|
301
|
+
return connectionState;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const onConnectionStateChange = (callback: TunnelConnectionStateCallback): (() => void) => {
|
|
305
|
+
stateChangeCallbacks.add(callback);
|
|
306
|
+
return () => {
|
|
307
|
+
stateChangeCallbacks.delete(callback);
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const resolveReady = (address: ReverseTunnel['remoteAddress']): void => {
|
|
312
|
+
remoteAddress = address;
|
|
313
|
+
hasResolved = true;
|
|
314
|
+
resolve({
|
|
315
|
+
remoteAddress,
|
|
316
|
+
close,
|
|
317
|
+
getConnectionState,
|
|
318
|
+
onConnectionStateChange,
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const rejectStartup = (err: Error): void => {
|
|
323
|
+
if (!hasResolved) {
|
|
324
|
+
cleanupWebSocket();
|
|
325
|
+
closeAllConnections();
|
|
326
|
+
updateConnectionState('disconnected');
|
|
327
|
+
reject(err);
|
|
328
|
+
} else {
|
|
329
|
+
logger.error(err.message);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const flushPending = (connId: number, socket: net.Socket): void => {
|
|
334
|
+
const connectTimer = connectTimers.get(connId);
|
|
335
|
+
if (connectTimer) {
|
|
336
|
+
clearTimeout(connectTimer);
|
|
337
|
+
connectTimers.delete(connId);
|
|
338
|
+
}
|
|
339
|
+
const pending = pendingPerConn.get(connId) ?? [];
|
|
340
|
+
pendingPerConn.delete(connId);
|
|
341
|
+
pendingBytesPerConn.delete(connId);
|
|
342
|
+
connecting.delete(connId);
|
|
343
|
+
for (const payload of pending) {
|
|
344
|
+
socket.write(payload);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const connectLocal = (connId: number, firstPayload: Buffer): void => {
|
|
349
|
+
// The current wire protocol has data and close frames, but no explicit
|
|
350
|
+
// "remote TCP accepted" frame. Connect lazily on first payload, which fits
|
|
351
|
+
// HTTP/WebSocket dev servers. Server-first protocols need a protocol
|
|
352
|
+
// extension before this can behave like a transparent TCP forward.
|
|
353
|
+
if (recentlyClosedConnIds.has(connId)) {
|
|
354
|
+
logger.debug(`Ignoring payload for closed conn=${connId}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (connections.size >= maxConnections) {
|
|
358
|
+
logger.warn(`Rejecting reverse tunnel conn=${connId}: max connections reached`);
|
|
359
|
+
sendCloseSignal(connId);
|
|
360
|
+
markRecentlyClosed(connId);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (firstPayload.length > maxPendingBytesPerConnection) {
|
|
364
|
+
logger.warn(`Rejecting reverse tunnel conn=${connId}: initial payload exceeds pending byte limit`);
|
|
365
|
+
sendCloseSignal(connId);
|
|
366
|
+
markRecentlyClosed(connId);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
pendingPerConn.set(connId, [firstPayload]);
|
|
371
|
+
pendingBytesPerConn.set(connId, firstPayload.length);
|
|
372
|
+
connecting.add(connId);
|
|
373
|
+
|
|
374
|
+
const socket = net.createConnection({ host: localHost, port: localPort });
|
|
375
|
+
connections.set(connId, socket);
|
|
376
|
+
const connectTimer = setTimeout(() => {
|
|
377
|
+
logger.error(`Local TCP connect timed out conn=${connId} after ${connectTimeoutMs}ms`);
|
|
378
|
+
if (connections.has(connId)) {
|
|
379
|
+
removeConnection(connId, true);
|
|
380
|
+
}
|
|
381
|
+
}, connectTimeoutMs);
|
|
382
|
+
connectTimer.unref();
|
|
383
|
+
connectTimers.set(connId, connectTimer);
|
|
384
|
+
|
|
385
|
+
socket.on('connect', () => {
|
|
386
|
+
logger.debug(`Connected conn=${connId} to ${localHost}:${localPort}`);
|
|
387
|
+
flushPending(connId, socket);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
socket.on('data', (chunk: Buffer) => {
|
|
391
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
392
|
+
ws.send(Buffer.concat([encodeConnectionHeader(connId), chunk]), (err) => {
|
|
393
|
+
if (err) {
|
|
394
|
+
logger.error(`Failed to send conn=${connId} data: ${err.message}`);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
socket.on('close', () => {
|
|
401
|
+
logger.debug(`Local TCP connection closed conn=${connId}`);
|
|
402
|
+
if (connections.has(connId)) {
|
|
403
|
+
removeConnection(connId, true);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
socket.on('error', (err: Error) => {
|
|
408
|
+
logger.error(`Local TCP connection error conn=${connId}: ${err.message}`);
|
|
409
|
+
if (connections.has(connId)) {
|
|
410
|
+
removeConnection(connId, true);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const handleBinaryFrame = (buffer: Buffer): void => {
|
|
416
|
+
if (buffer.length < 4) {
|
|
417
|
+
logger.error('Received binary frame shorter than 4 bytes; dropping');
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const connId = decodeConnectionHeader(buffer);
|
|
422
|
+
const payload = buffer.subarray(4);
|
|
423
|
+
|
|
424
|
+
if (payload.length === 0) {
|
|
425
|
+
logger.debug(`Received remote close for conn=${connId}`);
|
|
426
|
+
removeConnection(connId, false, true);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (recentlyClosedConnIds.has(connId)) {
|
|
430
|
+
logger.debug(`Ignoring payload for closed conn=${connId}`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const socket = connections.get(connId);
|
|
435
|
+
if (!socket) {
|
|
436
|
+
connectLocal(connId, payload);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (connecting.has(connId)) {
|
|
441
|
+
const pending = pendingPerConn.get(connId) ?? [];
|
|
442
|
+
const pendingBytes = (pendingBytesPerConn.get(connId) ?? 0) + payload.length;
|
|
443
|
+
if (pendingBytes > maxPendingBytesPerConnection) {
|
|
444
|
+
logger.warn(`Closing reverse tunnel conn=${connId}: pending byte limit exceeded`);
|
|
445
|
+
removeConnection(connId, true);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
pending.push(payload);
|
|
449
|
+
pendingPerConn.set(connId, pending);
|
|
450
|
+
pendingBytesPerConn.set(connId, pendingBytes);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
socket.write(payload);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const handleControlMessage = (data: any): void => {
|
|
458
|
+
let parsed: any;
|
|
459
|
+
try {
|
|
460
|
+
parsed = JSON.parse(data.toString());
|
|
461
|
+
} catch {
|
|
462
|
+
rejectStartup(new Error(`Malformed reverse tunnel control message: ${data.toString()}`));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (parsed.type === 'ready') {
|
|
467
|
+
if (typeof parsed.remoteHost !== 'string' || typeof parsed.remotePort !== 'number') {
|
|
468
|
+
rejectStartup(new Error(`Malformed reverse tunnel ready message: ${data.toString()}`));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
logger.info(`Reverse tunnel ready: ${parsed.remoteHost}:${parsed.remotePort}`);
|
|
472
|
+
updateConnectionState('connected');
|
|
473
|
+
resolveReady({ address: parsed.remoteHost, port: parsed.remotePort });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (parsed.type === 'error') {
|
|
478
|
+
rejectStartup(new Error(parsed.message || 'Reverse tunnel failed to start'));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
rejectStartup(new Error(`Unexpected reverse tunnel control message type: ${parsed.type}`));
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const url = new URL(remoteURL);
|
|
486
|
+
const proxyAgent = nodeProxyTransport.getWebSocketAgent(url.toString());
|
|
487
|
+
ws = new WebSocket(url.toString(), {
|
|
488
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
489
|
+
...(proxyAgent ? { agent: proxyAgent } : {}),
|
|
490
|
+
perMessageDeflate: false,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
ws.on('error', (err: Error) => {
|
|
494
|
+
logger.error('WebSocket error:', err.message);
|
|
495
|
+
rejectStartup(err);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
ws.on('close', (code: number, reason: Buffer) => {
|
|
499
|
+
if (pingInterval) {
|
|
500
|
+
clearInterval(pingInterval);
|
|
501
|
+
pingInterval = undefined;
|
|
502
|
+
}
|
|
503
|
+
logger.debug(`WebSocket disconnected (code=${code}, reason=${reason.toString()})`);
|
|
504
|
+
closeAllConnections();
|
|
505
|
+
updateConnectionState('disconnected');
|
|
506
|
+
if (!intentionalDisconnect && !hasResolved) {
|
|
507
|
+
rejectStartup(
|
|
508
|
+
new Error(`Reverse tunnel WebSocket closed before ready: ${code} ${reason.toString()}`),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
ws.on('open', () => {
|
|
514
|
+
const socket = ws as WebSocket;
|
|
515
|
+
logger.debug(`Connected WebSocket to ${url.toString()}`);
|
|
516
|
+
pingInterval = setInterval(() => {
|
|
517
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
518
|
+
(socket as any).ping();
|
|
519
|
+
}
|
|
520
|
+
}, 30_000);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
ws.on('message', (data: any, isBinary: boolean) => {
|
|
524
|
+
if (!hasResolved) {
|
|
525
|
+
if (isBinary) {
|
|
526
|
+
rejectStartup(new Error('Received reverse tunnel binary frame before ready control message'));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
handleControlMessage(data);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!isBinary) {
|
|
534
|
+
logger.warn(`Ignoring unexpected reverse tunnel text message: ${data.toString()}`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
handleBinaryFrame(
|
|
539
|
+
Buffer.isBuffer(data) ? data
|
|
540
|
+
: Array.isArray(data) ? Buffer.concat(data)
|
|
541
|
+
: Buffer.from(data),
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
108
547
|
/**
|
|
109
548
|
* Singleton mode: Single TCP connection forwarded to WebSocket
|
|
110
549
|
*/
|
|
@@ -729,6 +1168,12 @@ export const isNonRetryableError = (errMessage: string): boolean => {
|
|
|
729
1168
|
return false;
|
|
730
1169
|
};
|
|
731
1170
|
|
|
1171
|
+
export function assertPort(port: number, name: string, min: number, max: number): void {
|
|
1172
|
+
if (!Number.isInteger(port) || port < min || port > max) {
|
|
1173
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
732
1177
|
/**
|
|
733
1178
|
* Encode a 32-bit connection ID as a 4-byte big-endian header
|
|
734
1179
|
*/
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.28.
|
|
1
|
+
export const VERSION = '0.28.5'; // x-release-please-version
|
package/tunnel.d.mts
CHANGED
|
@@ -32,6 +32,15 @@ export interface Tunnel {
|
|
|
32
32
|
*/
|
|
33
33
|
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
34
34
|
}
|
|
35
|
+
export interface ReverseTunnel {
|
|
36
|
+
remoteAddress: {
|
|
37
|
+
address: string;
|
|
38
|
+
port: number;
|
|
39
|
+
};
|
|
40
|
+
close: () => void;
|
|
41
|
+
getConnectionState: () => TunnelConnectionState;
|
|
42
|
+
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
43
|
+
}
|
|
35
44
|
export interface TcpTunnelOptions {
|
|
36
45
|
/**
|
|
37
46
|
* Maximum number of reconnection attempts
|
|
@@ -62,6 +71,37 @@ export interface TcpTunnelOptions {
|
|
|
62
71
|
*/
|
|
63
72
|
mode?: TunnelMode;
|
|
64
73
|
}
|
|
74
|
+
export interface ReverseTcpTunnelOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Hostname or IP address of the user-local service.
|
|
77
|
+
* @default '127.0.0.1'
|
|
78
|
+
*/
|
|
79
|
+
localHost?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Port of the user-local service.
|
|
82
|
+
*/
|
|
83
|
+
localPort: number;
|
|
84
|
+
/**
|
|
85
|
+
* Controls logging verbosity
|
|
86
|
+
* @default 'info'
|
|
87
|
+
*/
|
|
88
|
+
logLevel?: LogLevel;
|
|
89
|
+
/**
|
|
90
|
+
* Maximum concurrent remote TCP connections.
|
|
91
|
+
* @default 64
|
|
92
|
+
*/
|
|
93
|
+
maxConnections?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Maximum bytes buffered per connection while the local TCP connect is pending.
|
|
96
|
+
* @default 16777216
|
|
97
|
+
*/
|
|
98
|
+
maxPendingBytesPerConnection?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Local TCP connect timeout in milliseconds.
|
|
101
|
+
* @default 10000
|
|
102
|
+
*/
|
|
103
|
+
connectTimeoutMs?: number;
|
|
104
|
+
}
|
|
65
105
|
/**
|
|
66
106
|
* Starts a persistent TCP → WebSocket proxy.
|
|
67
107
|
*
|
|
@@ -79,7 +119,17 @@ export interface TcpTunnelOptions {
|
|
|
79
119
|
* @param options Optional reconnection configuration
|
|
80
120
|
*/
|
|
81
121
|
export declare function startTcpTunnel(remoteURL: string, token: string, hostname: string, port: number, options?: TcpTunnelOptions): Promise<Tunnel>;
|
|
122
|
+
/**
|
|
123
|
+
* Starts a reverse TCP tunnel for client-first protocols.
|
|
124
|
+
*
|
|
125
|
+
* The remote endpoint accepts TCP connections near the simulator and sends them
|
|
126
|
+
* through `remoteURL` as multiplexed WebSocket frames. For each remote connID,
|
|
127
|
+
* this client opens a TCP connection to `localHost:localPort` after the first
|
|
128
|
+
* payload arrives, then pipes bytes in both directions.
|
|
129
|
+
*/
|
|
130
|
+
export declare function startReverseTcpTunnel(remoteURL: string, token: string, options: ReverseTcpTunnelOptions): Promise<ReverseTunnel>;
|
|
82
131
|
export declare const isNonRetryableError: (errMessage: string) => boolean;
|
|
132
|
+
export declare function assertPort(port: number, name: string, min: number, max: number): void;
|
|
83
133
|
/**
|
|
84
134
|
* Encode a 32-bit connection ID as a 4-byte big-endian header
|
|
85
135
|
*/
|
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,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;;;;;;;;;;;;;;;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;AAymBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF;;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;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"}
|
package/tunnel.d.ts
CHANGED
|
@@ -32,6 +32,15 @@ export interface Tunnel {
|
|
|
32
32
|
*/
|
|
33
33
|
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
34
34
|
}
|
|
35
|
+
export interface ReverseTunnel {
|
|
36
|
+
remoteAddress: {
|
|
37
|
+
address: string;
|
|
38
|
+
port: number;
|
|
39
|
+
};
|
|
40
|
+
close: () => void;
|
|
41
|
+
getConnectionState: () => TunnelConnectionState;
|
|
42
|
+
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
43
|
+
}
|
|
35
44
|
export interface TcpTunnelOptions {
|
|
36
45
|
/**
|
|
37
46
|
* Maximum number of reconnection attempts
|
|
@@ -62,6 +71,37 @@ export interface TcpTunnelOptions {
|
|
|
62
71
|
*/
|
|
63
72
|
mode?: TunnelMode;
|
|
64
73
|
}
|
|
74
|
+
export interface ReverseTcpTunnelOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Hostname or IP address of the user-local service.
|
|
77
|
+
* @default '127.0.0.1'
|
|
78
|
+
*/
|
|
79
|
+
localHost?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Port of the user-local service.
|
|
82
|
+
*/
|
|
83
|
+
localPort: number;
|
|
84
|
+
/**
|
|
85
|
+
* Controls logging verbosity
|
|
86
|
+
* @default 'info'
|
|
87
|
+
*/
|
|
88
|
+
logLevel?: LogLevel;
|
|
89
|
+
/**
|
|
90
|
+
* Maximum concurrent remote TCP connections.
|
|
91
|
+
* @default 64
|
|
92
|
+
*/
|
|
93
|
+
maxConnections?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Maximum bytes buffered per connection while the local TCP connect is pending.
|
|
96
|
+
* @default 16777216
|
|
97
|
+
*/
|
|
98
|
+
maxPendingBytesPerConnection?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Local TCP connect timeout in milliseconds.
|
|
101
|
+
* @default 10000
|
|
102
|
+
*/
|
|
103
|
+
connectTimeoutMs?: number;
|
|
104
|
+
}
|
|
65
105
|
/**
|
|
66
106
|
* Starts a persistent TCP → WebSocket proxy.
|
|
67
107
|
*
|
|
@@ -79,7 +119,17 @@ export interface TcpTunnelOptions {
|
|
|
79
119
|
* @param options Optional reconnection configuration
|
|
80
120
|
*/
|
|
81
121
|
export declare function startTcpTunnel(remoteURL: string, token: string, hostname: string, port: number, options?: TcpTunnelOptions): Promise<Tunnel>;
|
|
122
|
+
/**
|
|
123
|
+
* Starts a reverse TCP tunnel for client-first protocols.
|
|
124
|
+
*
|
|
125
|
+
* The remote endpoint accepts TCP connections near the simulator and sends them
|
|
126
|
+
* through `remoteURL` as multiplexed WebSocket frames. For each remote connID,
|
|
127
|
+
* this client opens a TCP connection to `localHost:localPort` after the first
|
|
128
|
+
* payload arrives, then pipes bytes in both directions.
|
|
129
|
+
*/
|
|
130
|
+
export declare function startReverseTcpTunnel(remoteURL: string, token: string, options: ReverseTcpTunnelOptions): Promise<ReverseTunnel>;
|
|
82
131
|
export declare const isNonRetryableError: (errMessage: string) => boolean;
|
|
132
|
+
export declare function assertPort(port: number, name: string, min: number, max: number): void;
|
|
83
133
|
/**
|
|
84
134
|
* Encode a 32-bit connection ID as a 4-byte big-endian header
|
|
85
135
|
*/
|
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,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;;;;;;;;;;;;;;;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;AAymBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF;;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;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"}
|