@limrun/api 0.48.3 → 0.49.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 +8 -0
- package/client.d.mts +6 -3
- package/client.d.mts.map +1 -1
- package/client.d.ts +6 -3
- package/client.d.ts.map +1 -1
- package/client.js +4 -4
- package/client.js.map +1 -1
- package/client.mjs +2 -2
- package/client.mjs.map +1 -1
- package/destination-tunnel-dialer.d.mts +30 -2
- package/destination-tunnel-dialer.d.mts.map +1 -1
- package/destination-tunnel-dialer.d.ts +30 -2
- package/destination-tunnel-dialer.d.ts.map +1 -1
- package/destination-tunnel-dialer.js +349 -65
- package/destination-tunnel-dialer.js.map +1 -1
- package/destination-tunnel-dialer.mjs +345 -64
- package/destination-tunnel-dialer.mjs.map +1 -1
- package/destination-tunnel-har.d.mts +23 -0
- package/destination-tunnel-har.d.mts.map +1 -0
- package/destination-tunnel-har.d.ts +23 -0
- package/destination-tunnel-har.d.ts.map +1 -0
- package/destination-tunnel-har.js +164 -0
- package/destination-tunnel-har.js.map +1 -0
- package/destination-tunnel-har.mjs +160 -0
- package/destination-tunnel-har.mjs.map +1 -0
- package/destination-tunnel-inspection.d.mts +71 -0
- package/destination-tunnel-inspection.d.mts.map +1 -0
- package/destination-tunnel-inspection.d.ts +71 -0
- package/destination-tunnel-inspection.d.ts.map +1 -0
- package/destination-tunnel-inspection.js +267 -0
- package/destination-tunnel-inspection.js.map +1 -0
- package/destination-tunnel-inspection.mjs +261 -0
- package/destination-tunnel-inspection.mjs.map +1 -0
- package/destination-tunnel.d.mts +119 -14
- package/destination-tunnel.d.mts.map +1 -1
- package/destination-tunnel.d.ts +119 -14
- package/destination-tunnel.d.ts.map +1 -1
- package/destination-tunnel.js +407 -98
- package/destination-tunnel.js.map +1 -1
- package/destination-tunnel.mjs +374 -73
- package/destination-tunnel.mjs.map +1 -1
- package/exec-client.d.mts +1 -0
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +1 -0
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js.map +1 -1
- package/exec-client.mjs.map +1 -1
- package/index.d.mts +6 -0
- package/index.d.mts.map +1 -1
- package/index.d.ts +6 -0
- package/index.d.ts.map +1 -1
- package/index.js +23 -2
- package/index.js.map +1 -1
- package/index.mjs +5 -0
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +105 -1
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +105 -1
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +83 -1
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +83 -1
- package/instance-client.mjs.map +1 -1
- package/internal/destination-tunnel-management.d.mts +4 -3
- package/internal/destination-tunnel-management.d.mts.map +1 -1
- package/internal/destination-tunnel-management.d.ts +4 -3
- package/internal/destination-tunnel-management.d.ts.map +1 -1
- package/internal/destination-tunnel-management.js +64 -46
- package/internal/destination-tunnel-management.js.map +1 -1
- package/internal/destination-tunnel-management.mjs +55 -37
- package/internal/destination-tunnel-management.mjs.map +1 -1
- package/internal/destination-tunnel-url.d.mts +2 -0
- package/internal/destination-tunnel-url.d.mts.map +1 -1
- package/internal/destination-tunnel-url.d.ts +2 -0
- package/internal/destination-tunnel-url.d.ts.map +1 -1
- package/internal/destination-tunnel-url.js +45 -5
- package/internal/destination-tunnel-url.js.map +1 -1
- package/internal/destination-tunnel-url.mjs +44 -5
- package/internal/destination-tunnel-url.mjs.map +1 -1
- package/internal/destination-tunnel-wire-reader.d.mts +23 -0
- package/internal/destination-tunnel-wire-reader.d.mts.map +1 -0
- package/internal/destination-tunnel-wire-reader.d.ts +23 -0
- package/internal/destination-tunnel-wire-reader.d.ts.map +1 -0
- package/internal/destination-tunnel-wire-reader.js +110 -0
- package/internal/destination-tunnel-wire-reader.js.map +1 -0
- package/internal/destination-tunnel-wire-reader.mjs +93 -0
- package/internal/destination-tunnel-wire-reader.mjs.map +1 -0
- package/internal/persist-option.d.mts +14 -0
- package/internal/persist-option.d.mts.map +1 -0
- package/internal/persist-option.d.ts +14 -0
- package/internal/persist-option.d.ts.map +1 -0
- package/internal/persist-option.js +18 -0
- package/internal/persist-option.js.map +1 -0
- package/internal/persist-option.mjs +15 -0
- package/internal/persist-option.mjs.map +1 -0
- package/internal/proxy-transport.d.mts +27 -0
- package/internal/proxy-transport.d.mts.map +1 -1
- package/internal/proxy-transport.d.ts +27 -0
- package/internal/proxy-transport.d.ts.map +1 -1
- package/internal/proxy-transport.js +206 -0
- package/internal/proxy-transport.js.map +1 -1
- package/internal/proxy-transport.mjs +205 -0
- package/internal/proxy-transport.mjs.map +1 -1
- package/internal/session-stream.d.mts +39 -0
- package/internal/session-stream.d.mts.map +1 -0
- package/internal/session-stream.d.ts +39 -0
- package/internal/session-stream.d.ts.map +1 -0
- package/internal/session-stream.js +144 -0
- package/internal/session-stream.js.map +1 -0
- package/internal/session-stream.mjs +139 -0
- package/internal/session-stream.mjs.map +1 -0
- package/internal/tslib.js +4 -4
- package/ios-client.d.mts +61 -4
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +61 -4
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +48 -2
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +48 -2
- package/ios-client.mjs.map +1 -1
- package/package.json +22 -1
- package/resources/android-instances-helpers.d.mts +25 -0
- package/resources/android-instances-helpers.d.mts.map +1 -0
- package/resources/android-instances-helpers.d.ts +25 -0
- package/resources/android-instances-helpers.d.ts.map +1 -0
- package/resources/android-instances-helpers.js +37 -0
- package/resources/android-instances-helpers.js.map +1 -0
- package/resources/android-instances-helpers.mjs +33 -0
- package/resources/android-instances-helpers.mjs.map +1 -0
- package/resources/index.d.mts +5 -2
- package/resources/index.d.mts.map +1 -1
- package/resources/index.d.ts +5 -2
- package/resources/index.d.ts.map +1 -1
- package/resources/index.js +5 -5
- package/resources/index.js.map +1 -1
- package/resources/index.mjs +2 -2
- package/resources/index.mjs.map +1 -1
- package/resources/ios-instances-helpers.d.mts +25 -0
- package/resources/ios-instances-helpers.d.mts.map +1 -0
- package/resources/ios-instances-helpers.d.ts +25 -0
- package/resources/ios-instances-helpers.d.ts.map +1 -0
- package/resources/ios-instances-helpers.js +37 -0
- package/resources/ios-instances-helpers.js.map +1 -0
- package/resources/ios-instances-helpers.mjs +33 -0
- package/resources/ios-instances-helpers.mjs.map +1 -0
- package/resources/session-artifacts.d.mts +70 -0
- package/resources/session-artifacts.d.mts.map +1 -0
- package/resources/session-artifacts.d.ts +70 -0
- package/resources/session-artifacts.d.ts.map +1 -0
- package/resources/session-artifacts.js +8 -0
- package/resources/session-artifacts.js.map +1 -0
- package/resources/session-artifacts.mjs +7 -0
- package/resources/session-artifacts.mjs.map +1 -0
- package/resources/xcode-instances-helpers.d.mts +9 -0
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +9 -0
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/client.ts +4 -2
- package/src/destination-tunnel-dialer.ts +403 -64
- package/src/destination-tunnel-har.ts +200 -0
- package/src/destination-tunnel-inspection.ts +367 -0
- package/src/destination-tunnel.ts +510 -88
- package/src/exec-client.ts +1 -0
- package/src/index.ts +52 -0
- package/src/instance-client.ts +271 -5
- package/src/internal/destination-tunnel-management.ts +67 -43
- package/src/internal/destination-tunnel-url.ts +61 -5
- package/src/internal/destination-tunnel-wire-reader.ts +109 -0
- package/src/internal/persist-option.ts +26 -0
- package/src/internal/proxy-transport.ts +248 -0
- package/src/internal/session-stream.ts +157 -0
- package/src/ios-client.ts +127 -8
- package/src/resources/android-instances-helpers.ts +45 -0
- package/src/resources/index.ts +9 -2
- package/src/resources/ios-instances-helpers.ts +45 -0
- package/src/resources/session-artifacts.ts +95 -0
- package/src/resources/xcode-instances-helpers.ts +9 -0
- 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,200 @@
|
|
|
1
|
+
import type { Entry as HAREntry, Request as HARRequest, Response as HARResponse } from 'har-format';
|
|
2
|
+
import { DESTINATION_TUNNEL_DEFAULT_MAX_BODY_BYTES } from './destination-tunnel';
|
|
3
|
+
import type {
|
|
4
|
+
DestinationTunnelInspectionComplete,
|
|
5
|
+
DestinationTunnelInspectionEvent,
|
|
6
|
+
DestinationTunnelInspectionExtension,
|
|
7
|
+
} from './destination-tunnel-inspection';
|
|
8
|
+
|
|
9
|
+
interface PendingCapture {
|
|
10
|
+
requestBody: Uint8Array[];
|
|
11
|
+
requestBytes: number;
|
|
12
|
+
requestTruncated: boolean;
|
|
13
|
+
responseBody: Uint8Array[];
|
|
14
|
+
responseBytes: number;
|
|
15
|
+
responseTruncated: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DestinationTunnelHARExtension = DestinationTunnelInspectionExtension & {
|
|
19
|
+
requestBodyEncoding?: 'base64';
|
|
20
|
+
requestBodyTruncated: boolean;
|
|
21
|
+
responseBodyTruncated: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DestinationTunnelHAREntry = HAREntry & {
|
|
25
|
+
_limrun: DestinationTunnelHARExtension;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Correlates filesystem-neutral inspection body chunks with completion
|
|
30
|
+
* metadata and returns self-contained HAR entries.
|
|
31
|
+
*/
|
|
32
|
+
export class DestinationTunnelHARAssembler {
|
|
33
|
+
private readonly pending = new Map<string, PendingCapture>();
|
|
34
|
+
|
|
35
|
+
constructor(readonly bodyLimit: number = DESTINATION_TUNNEL_DEFAULT_MAX_BODY_BYTES) {
|
|
36
|
+
if (!Number.isInteger(bodyLimit) || bodyLimit < 1) {
|
|
37
|
+
throw new Error('HAR body limit must be a positive integer');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
add(event: DestinationTunnelInspectionEvent): DestinationTunnelHAREntry | undefined {
|
|
42
|
+
if (event.type === 'body') {
|
|
43
|
+
const state = this.stateFor(event.requestId);
|
|
44
|
+
const response = event.direction === 'response';
|
|
45
|
+
const bytes = response ? state.responseBytes : state.requestBytes;
|
|
46
|
+
const take = Math.min(event.body.byteLength, Math.max(0, this.bodyLimit - bytes));
|
|
47
|
+
if (take > 0) {
|
|
48
|
+
(response ? state.responseBody : state.requestBody).push(event.body.slice(0, take));
|
|
49
|
+
}
|
|
50
|
+
if (response) {
|
|
51
|
+
state.responseBytes += take;
|
|
52
|
+
state.responseTruncated ||= take < event.body.byteLength;
|
|
53
|
+
} else {
|
|
54
|
+
state.requestBytes += take;
|
|
55
|
+
state.requestTruncated ||= take < event.body.byteLength;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
if (event.type !== 'complete') return undefined;
|
|
60
|
+
|
|
61
|
+
const state = this.stateFor(event.requestId);
|
|
62
|
+
try {
|
|
63
|
+
return makeDestinationTunnelHAREntry(event.data, state);
|
|
64
|
+
} finally {
|
|
65
|
+
this.pending.delete(event.requestId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
reset(): void {
|
|
70
|
+
this.pending.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private stateFor(requestId: string): PendingCapture {
|
|
74
|
+
let state = this.pending.get(requestId);
|
|
75
|
+
if (!state) {
|
|
76
|
+
state = {
|
|
77
|
+
requestBody: [],
|
|
78
|
+
requestBytes: 0,
|
|
79
|
+
requestTruncated: false,
|
|
80
|
+
responseBody: [],
|
|
81
|
+
responseBytes: 0,
|
|
82
|
+
responseTruncated: false,
|
|
83
|
+
};
|
|
84
|
+
this.pending.set(requestId, state);
|
|
85
|
+
}
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeDestinationTunnelHAREntry(
|
|
91
|
+
complete: DestinationTunnelInspectionComplete,
|
|
92
|
+
capture: PendingCapture,
|
|
93
|
+
): DestinationTunnelHAREntry {
|
|
94
|
+
const requestBody = concatBytes(capture.requestBody, capture.requestBytes);
|
|
95
|
+
const responseBody = concatBytes(capture.responseBody, capture.responseBytes);
|
|
96
|
+
const { request, encoding: requestBodyEncoding } = addRequestBody(complete.request, requestBody);
|
|
97
|
+
const response = addResponseBody(complete.response, responseBody);
|
|
98
|
+
return {
|
|
99
|
+
...(complete.pageref === undefined ? {} : { pageref: complete.pageref }),
|
|
100
|
+
startedDateTime: complete.startedDateTime,
|
|
101
|
+
time: complete.time,
|
|
102
|
+
request,
|
|
103
|
+
response,
|
|
104
|
+
cache: complete.cache,
|
|
105
|
+
timings: complete.timings,
|
|
106
|
+
...(complete.serverIPAddress === undefined ? {} : { serverIPAddress: complete.serverIPAddress }),
|
|
107
|
+
...(complete.connection === undefined ? {} : { connection: complete.connection }),
|
|
108
|
+
...(complete.comment === undefined ? {} : { comment: complete.comment }),
|
|
109
|
+
_limrun: {
|
|
110
|
+
...complete._limrun,
|
|
111
|
+
...(requestBodyEncoding ? { requestBodyEncoding } : {}),
|
|
112
|
+
requestBodyTruncated: complete._limrun.requestBodyTruncated === true || capture.requestTruncated,
|
|
113
|
+
responseBodyTruncated: complete._limrun.responseBodyTruncated === true || capture.responseTruncated,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function addRequestBody(request: HARRequest, body: Uint8Array): { request: HARRequest; encoding?: 'base64' } {
|
|
119
|
+
if (body.byteLength === 0) return { request };
|
|
120
|
+
const mimeType = request.postData?.mimeType ?? headerValue(request.headers, 'content-type');
|
|
121
|
+
const encoded = encodedBody(body, mimeType);
|
|
122
|
+
return {
|
|
123
|
+
request: {
|
|
124
|
+
...request,
|
|
125
|
+
postData: {
|
|
126
|
+
mimeType: mimeType ?? '',
|
|
127
|
+
text: encoded.text,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
...(encoded.encoding ? { encoding: encoded.encoding } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function addResponseBody(response: HARResponse, body: Uint8Array): HARResponse {
|
|
135
|
+
if (body.byteLength === 0) return response;
|
|
136
|
+
const content = { ...response.content };
|
|
137
|
+
delete content.text;
|
|
138
|
+
delete content.encoding;
|
|
139
|
+
return {
|
|
140
|
+
...response,
|
|
141
|
+
content: {
|
|
142
|
+
...content,
|
|
143
|
+
...encodedBody(body, response.content.mimeType),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function encodedBody(
|
|
149
|
+
body: Uint8Array,
|
|
150
|
+
contentType: string | undefined,
|
|
151
|
+
): { text: string; encoding?: 'base64' } {
|
|
152
|
+
if (isTextualContentType(contentType)) {
|
|
153
|
+
return { text: new TextDecoder().decode(body) };
|
|
154
|
+
}
|
|
155
|
+
return { text: bytesToBase64(body), encoding: 'base64' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isTextualContentType(contentType: string | undefined): boolean {
|
|
159
|
+
const mimeType = contentType?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
|
160
|
+
return (
|
|
161
|
+
mimeType.startsWith('text/') ||
|
|
162
|
+
mimeType.endsWith('+json') ||
|
|
163
|
+
mimeType.endsWith('+xml') ||
|
|
164
|
+
mimeType === 'application/json' ||
|
|
165
|
+
mimeType === 'application/xml' ||
|
|
166
|
+
mimeType === 'application/javascript' ||
|
|
167
|
+
mimeType === 'application/x-javascript' ||
|
|
168
|
+
mimeType === 'application/x-www-form-urlencoded' ||
|
|
169
|
+
mimeType === 'application/graphql'
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function headerValue(headers: Array<{ name: string; value: string }>, name: string): string | undefined {
|
|
174
|
+
return headers.find((header) => header.name.toLowerCase() === name)?.value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function concatBytes(chunks: Uint8Array[], length: number): Uint8Array {
|
|
178
|
+
const result = new Uint8Array(length);
|
|
179
|
+
let offset = 0;
|
|
180
|
+
for (const chunk of chunks) {
|
|
181
|
+
result.set(chunk, offset);
|
|
182
|
+
offset += chunk.byteLength;
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
188
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
189
|
+
let result = '';
|
|
190
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
191
|
+
const first = bytes[index]!;
|
|
192
|
+
const second = bytes[index + 1];
|
|
193
|
+
const third = bytes[index + 2];
|
|
194
|
+
result += alphabet[first >> 2];
|
|
195
|
+
result += alphabet[((first & 3) << 4) | ((second ?? 0) >> 4)];
|
|
196
|
+
result += second === undefined ? '=' : alphabet[((second & 15) << 2) | ((third ?? 0) >> 6)];
|
|
197
|
+
result += third === undefined ? '=' : alphabet[third & 63];
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { createEventSource, type EventSourceClient } from 'eventsource-client';
|
|
2
|
+
import type { Entry, Header } from 'har-format';
|
|
3
|
+
import { nodeProxyTransport } from './internal/proxy-transport';
|
|
4
|
+
import { deriveDestinationTunnelInspectionURL } from './internal/destination-tunnel-url';
|
|
5
|
+
import {
|
|
6
|
+
DestinationTunnelProtocolError,
|
|
7
|
+
readArray,
|
|
8
|
+
readFiniteNumber,
|
|
9
|
+
readOptionalBoolean,
|
|
10
|
+
readOptionalString,
|
|
11
|
+
readRecord,
|
|
12
|
+
readSafeNonNegativeInteger,
|
|
13
|
+
readString,
|
|
14
|
+
} from './internal/destination-tunnel-wire-reader';
|
|
15
|
+
|
|
16
|
+
export { deriveDestinationTunnelInspectionURL } from './internal/destination-tunnel-url';
|
|
17
|
+
|
|
18
|
+
export interface DestinationTunnelInspectionExtension {
|
|
19
|
+
tunnelId: string;
|
|
20
|
+
selectorId: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
requestBodyTruncated?: boolean;
|
|
23
|
+
responseBodyTruncated?: boolean;
|
|
24
|
+
responseTrailers?: Header[];
|
|
25
|
+
responseCookieSameSite?: Array<{ index: number; value: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DestinationTunnelInspectionComplete = Entry & {
|
|
29
|
+
_limrun: DestinationTunnelInspectionExtension;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface DestinationTunnelInspectionGap {
|
|
33
|
+
fromSequence: number;
|
|
34
|
+
toSequence: number;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type DestinationTunnelInspectionMetadataEvent =
|
|
39
|
+
| {
|
|
40
|
+
type: 'complete';
|
|
41
|
+
sequence: number;
|
|
42
|
+
requestId: string;
|
|
43
|
+
data: DestinationTunnelInspectionComplete;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
type: 'gap';
|
|
47
|
+
sequence: number;
|
|
48
|
+
data: DestinationTunnelInspectionGap;
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
type: 'inspection_error';
|
|
52
|
+
sequence: number;
|
|
53
|
+
requestId?: string;
|
|
54
|
+
data: { message: string; [key: string]: unknown };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export interface DestinationTunnelInspectionBodyEvent {
|
|
58
|
+
type: 'body';
|
|
59
|
+
sequence: number;
|
|
60
|
+
requestId: string;
|
|
61
|
+
direction: 'request' | 'response';
|
|
62
|
+
body: Uint8Array;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type DestinationTunnelInspectionEvent =
|
|
66
|
+
| DestinationTunnelInspectionMetadataEvent
|
|
67
|
+
| DestinationTunnelInspectionBodyEvent;
|
|
68
|
+
|
|
69
|
+
export type DestinationTunnelInspectionEventCallback = (event: DestinationTunnelInspectionEvent) => void;
|
|
70
|
+
export type DestinationTunnelInspectionErrorCallback = (error: Error) => void;
|
|
71
|
+
|
|
72
|
+
export interface DestinationTunnelInspectionStreamOptions {
|
|
73
|
+
onEvent?: DestinationTunnelInspectionEventCallback;
|
|
74
|
+
onError?: DestinationTunnelInspectionErrorCallback;
|
|
75
|
+
reconnectInitialDelayMs?: number;
|
|
76
|
+
reconnectMaxDelayMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DestinationTunnelInspectionStream {
|
|
80
|
+
close: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Follow one tunnel's inspection SSE feed independently from its data
|
|
85
|
+
* WebSocket. Authentication is carried in `?token=` so the same endpoint can
|
|
86
|
+
* be consumed by browser-native EventSource.
|
|
87
|
+
*/
|
|
88
|
+
export function startDestinationTunnelInspectionStream(
|
|
89
|
+
remoteURL: string,
|
|
90
|
+
tunnelId: string,
|
|
91
|
+
token: string,
|
|
92
|
+
options: DestinationTunnelInspectionStreamOptions = {},
|
|
93
|
+
): DestinationTunnelInspectionStream {
|
|
94
|
+
let active = true;
|
|
95
|
+
let lastSequence = 0;
|
|
96
|
+
|
|
97
|
+
const reportError = (error: Error): void => {
|
|
98
|
+
invokeSafely(options.onError, error);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const emitGap = (fromSequence: number, toSequence: number): void => {
|
|
102
|
+
invokeSafely(
|
|
103
|
+
options.onEvent,
|
|
104
|
+
{
|
|
105
|
+
type: 'gap',
|
|
106
|
+
sequence: toSequence,
|
|
107
|
+
data: {
|
|
108
|
+
fromSequence,
|
|
109
|
+
toSequence,
|
|
110
|
+
message: `Inspection stream gap: missing sequences ${fromSequence}-${toSequence}`,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
reportError,
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const acceptSequence = (
|
|
118
|
+
sequence: number,
|
|
119
|
+
explicitGap?: { fromSequence: number; toSequence: number },
|
|
120
|
+
): void => {
|
|
121
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
122
|
+
throw new DestinationTunnelProtocolError('inspection sequence must be a safe non-negative integer');
|
|
123
|
+
}
|
|
124
|
+
if (sequence <= lastSequence) {
|
|
125
|
+
throw new DestinationTunnelProtocolError(
|
|
126
|
+
`inspection sequence ${sequence} is not newer than ${lastSequence}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (explicitGap) {
|
|
130
|
+
if (
|
|
131
|
+
explicitGap.fromSequence !== lastSequence + 1 ||
|
|
132
|
+
explicitGap.toSequence !== sequence ||
|
|
133
|
+
explicitGap.fromSequence > explicitGap.toSequence
|
|
134
|
+
) {
|
|
135
|
+
throw new DestinationTunnelProtocolError('invalid inspection gap range');
|
|
136
|
+
}
|
|
137
|
+
emitGap(explicitGap.fromSequence, explicitGap.toSequence);
|
|
138
|
+
} else if (sequence > lastSequence + 1) {
|
|
139
|
+
emitGap(lastSequence + 1, sequence - 1);
|
|
140
|
+
}
|
|
141
|
+
lastSequence = sequence;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const url = deriveDestinationTunnelInspectionURL(remoteURL, tunnelId, lastSequence, token);
|
|
145
|
+
const source: EventSourceClient = createEventSource({
|
|
146
|
+
url,
|
|
147
|
+
fetch: nodeProxyTransport.fetch.bind(nodeProxyTransport),
|
|
148
|
+
onMessage: (message) => {
|
|
149
|
+
if (!active) return;
|
|
150
|
+
try {
|
|
151
|
+
const event = decodeDestinationTunnelInspectionSSEEvent(message.id, message.data);
|
|
152
|
+
if (event.type === 'gap') {
|
|
153
|
+
acceptSequence(event.sequence, event.data);
|
|
154
|
+
} else {
|
|
155
|
+
acceptSequence(event.sequence);
|
|
156
|
+
invokeSafely(options.onEvent, event, reportError);
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
reportError(error instanceof Error ? error : new Error(String(error)));
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
onDisconnect: () => {
|
|
163
|
+
if (active) reportError(new Error('Inspection SSE disconnected; reconnecting'));
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const close = (): void => {
|
|
168
|
+
if (!active) return;
|
|
169
|
+
active = false;
|
|
170
|
+
source.close();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return { close };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Decode one browser/EventSource message while keeping the public inspection
|
|
178
|
+
* event API independent of SSE transport details.
|
|
179
|
+
*/
|
|
180
|
+
export function decodeDestinationTunnelInspectionSSEEvent(
|
|
181
|
+
lastEventId: string | undefined,
|
|
182
|
+
text: string,
|
|
183
|
+
): DestinationTunnelInspectionEvent {
|
|
184
|
+
if (lastEventId === undefined || !/^(0|[1-9]\d*)$/.test(lastEventId)) {
|
|
185
|
+
throw new DestinationTunnelProtocolError('inspection SSE event ID must be an unsigned integer');
|
|
186
|
+
}
|
|
187
|
+
const sequence = Number(lastEventId);
|
|
188
|
+
if (!Number.isSafeInteger(sequence)) {
|
|
189
|
+
throw new DestinationTunnelProtocolError('inspection sequence must be a safe non-negative integer');
|
|
190
|
+
}
|
|
191
|
+
let parsed: unknown;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(text);
|
|
194
|
+
} catch {
|
|
195
|
+
throw new DestinationTunnelProtocolError('inspection SSE data must be valid JSON');
|
|
196
|
+
}
|
|
197
|
+
const record = readRecord(parsed, 'inspection event');
|
|
198
|
+
if (record['sequence'] !== undefined) {
|
|
199
|
+
const envelopeSequence = readSafeNonNegativeInteger(record['sequence'], 'inspection sequence');
|
|
200
|
+
if (envelopeSequence !== sequence) {
|
|
201
|
+
throw new DestinationTunnelProtocolError('inspection SSE event ID does not match envelope sequence');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (readString(record, 'type') !== 'body') {
|
|
205
|
+
return decodeDestinationTunnelInspectionMetadataRecord(record, sequence);
|
|
206
|
+
}
|
|
207
|
+
const requestId = readRequiredRequestId(record);
|
|
208
|
+
const data = readRecord(record['data'], 'inspection body data');
|
|
209
|
+
const direction = readString(data, 'direction');
|
|
210
|
+
if (direction !== 'request' && direction !== 'response') {
|
|
211
|
+
throw new DestinationTunnelProtocolError('invalid inspection body direction');
|
|
212
|
+
}
|
|
213
|
+
if (readString(data, 'encoding') !== 'base64') {
|
|
214
|
+
throw new DestinationTunnelProtocolError('inspection body encoding must be base64');
|
|
215
|
+
}
|
|
216
|
+
const body = decodeCanonicalBase64(readString(data, 'chunk'));
|
|
217
|
+
return {
|
|
218
|
+
type: 'body',
|
|
219
|
+
sequence,
|
|
220
|
+
requestId,
|
|
221
|
+
direction,
|
|
222
|
+
body,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function decodeDestinationTunnelInspectionMetadataRecord(
|
|
227
|
+
record: Record<string, unknown>,
|
|
228
|
+
sequence: number,
|
|
229
|
+
): DestinationTunnelInspectionMetadataEvent {
|
|
230
|
+
const type = readString(record, 'type');
|
|
231
|
+
const data = readRecord(record['data'], 'inspection metadata data');
|
|
232
|
+
switch (type) {
|
|
233
|
+
case 'complete':
|
|
234
|
+
return {
|
|
235
|
+
type,
|
|
236
|
+
sequence,
|
|
237
|
+
requestId: readRequiredRequestId(record),
|
|
238
|
+
data: readComplete(data),
|
|
239
|
+
};
|
|
240
|
+
case 'gap': {
|
|
241
|
+
const fromSequence = readSafeNonNegativeInteger(data['fromSequence'], 'inspection sequence');
|
|
242
|
+
const toSequence = readSafeNonNegativeInteger(data['toSequence'], 'inspection sequence');
|
|
243
|
+
return {
|
|
244
|
+
type,
|
|
245
|
+
sequence,
|
|
246
|
+
data: {
|
|
247
|
+
fromSequence,
|
|
248
|
+
toSequence,
|
|
249
|
+
message: `Inspection stream gap: missing sequences ${fromSequence}-${toSequence}`,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
case 'inspection_error':
|
|
254
|
+
return {
|
|
255
|
+
type,
|
|
256
|
+
sequence,
|
|
257
|
+
...(record['requestId'] === undefined ? {} : { requestId: readRequiredRequestId(record) }),
|
|
258
|
+
data: { ...data, message: readString(data, 'message') },
|
|
259
|
+
};
|
|
260
|
+
default:
|
|
261
|
+
throw new DestinationTunnelProtocolError(`unknown inspection metadata type ${type}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readComplete(record: Record<string, unknown>): DestinationTunnelInspectionComplete {
|
|
266
|
+
readDateTime(record, 'startedDateTime');
|
|
267
|
+
readFiniteNumber(record, 'time');
|
|
268
|
+
readRequest(readRecord(record['request'], 'inspection request'));
|
|
269
|
+
readResponse(readRecord(record['response'], 'inspection response'));
|
|
270
|
+
readExtension(readRecord(record['_limrun'], 'inspection extension'));
|
|
271
|
+
return record as unknown as DestinationTunnelInspectionComplete;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readExtension(record: Record<string, unknown>): void {
|
|
275
|
+
readString(record, 'tunnelId');
|
|
276
|
+
readString(record, 'selectorId');
|
|
277
|
+
readOptionalString(record, 'error');
|
|
278
|
+
readOptionalBoolean(record, 'requestBodyTruncated');
|
|
279
|
+
readOptionalBoolean(record, 'responseBodyTruncated');
|
|
280
|
+
if (record['responseTrailers'] !== undefined) {
|
|
281
|
+
readNameValueArray(record['responseTrailers'], 'response trailers');
|
|
282
|
+
}
|
|
283
|
+
if (record['responseCookieSameSite'] !== undefined) {
|
|
284
|
+
for (const item of readArray(record, 'responseCookieSameSite')) {
|
|
285
|
+
const sameSite = readRecord(item, 'response cookie same-site metadata');
|
|
286
|
+
readSafeNonNegativeInteger(sameSite['index'], 'response cookie same-site index');
|
|
287
|
+
readString(sameSite, 'value');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function readRequest(record: Record<string, unknown>): void {
|
|
293
|
+
readString(record, 'method');
|
|
294
|
+
readString(record, 'url');
|
|
295
|
+
readString(record, 'httpVersion');
|
|
296
|
+
readNameValueArray(record['headers'], 'request headers');
|
|
297
|
+
readNameValueArray(record['queryString'], 'request query string');
|
|
298
|
+
readNameValueArray(record['cookies'], 'request cookies');
|
|
299
|
+
readFiniteNumber(record, 'headersSize');
|
|
300
|
+
readFiniteNumber(record, 'bodySize');
|
|
301
|
+
if (record['postData'] !== undefined) {
|
|
302
|
+
readRecord(record['postData'], 'request post data');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function readResponse(record: Record<string, unknown>): void {
|
|
307
|
+
readFiniteNumber(record, 'status');
|
|
308
|
+
readString(record, 'statusText');
|
|
309
|
+
readString(record, 'httpVersion');
|
|
310
|
+
readNameValueArray(record['headers'], 'response headers');
|
|
311
|
+
readNameValueArray(record['cookies'], 'response cookies');
|
|
312
|
+
const content = readRecord(record['content'], 'response content');
|
|
313
|
+
readFiniteNumber(content, 'size');
|
|
314
|
+
readString(content, 'mimeType');
|
|
315
|
+
readString(record, 'redirectURL');
|
|
316
|
+
readFiniteNumber(record, 'headersSize');
|
|
317
|
+
readFiniteNumber(record, 'bodySize');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function readNameValueArray(value: unknown, name: string): void {
|
|
321
|
+
if (!Array.isArray(value)) {
|
|
322
|
+
throw new DestinationTunnelProtocolError(`${name} must be an array`);
|
|
323
|
+
}
|
|
324
|
+
for (const item of value) {
|
|
325
|
+
const record = readRecord(item, name);
|
|
326
|
+
readString(record, 'name');
|
|
327
|
+
readString(record, 'value');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readRequiredRequestId(record: Record<string, unknown>): string {
|
|
332
|
+
const requestId = readString(record, 'requestId');
|
|
333
|
+
if (!requestId) throw new DestinationTunnelProtocolError('inspection request ID must not be empty');
|
|
334
|
+
return requestId;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function readDateTime(record: Record<string, unknown>, key: string): string {
|
|
338
|
+
const value = readString(record, key);
|
|
339
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
340
|
+
throw new DestinationTunnelProtocolError(`${key} must be an ISO date-time`);
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function invokeSafely<T>(
|
|
346
|
+
callback: ((value: T) => void) | undefined,
|
|
347
|
+
value: T,
|
|
348
|
+
onFailure?: (error: Error) => void,
|
|
349
|
+
): void {
|
|
350
|
+
if (!callback) return;
|
|
351
|
+
try {
|
|
352
|
+
callback(value);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
onFailure?.(error instanceof Error ? error : new Error(String(error)));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function decodeCanonicalBase64(value: string): Uint8Array {
|
|
359
|
+
try {
|
|
360
|
+
if (value.length % 4 !== 0) throw new Error('invalid base64 length');
|
|
361
|
+
const binary = atob(value);
|
|
362
|
+
if (btoa(binary) !== value) throw new Error('non-canonical base64');
|
|
363
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
364
|
+
} catch {
|
|
365
|
+
throw new DestinationTunnelProtocolError('inspection body chunk must be valid base64');
|
|
366
|
+
}
|
|
367
|
+
}
|