@linzumi/cli 0.0.20-beta → 0.0.22-beta
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/README.md +65 -62
- package/bin/linzumi.js +10 -18
- package/dist/assets/linzumi-logo.svg +1 -0
- package/dist/index.js +9135 -0
- package/package.json +9 -4
- package/src/agentBootstrap.ts +0 -872
- package/src/authCache.ts +0 -157
- package/src/authResolution.ts +0 -77
- package/src/boundedCache.ts +0 -57
- package/src/channelSession.ts +0 -4301
- package/src/channelSessionSupport.ts +0 -308
- package/src/codexAppServer.ts +0 -380
- package/src/codexOutput.ts +0 -846
- package/src/codexRuntimeOptions.ts +0 -80
- package/src/dependencyStatus.ts +0 -198
- package/src/forwardTunnel.ts +0 -859
- package/src/forwardTunnelProtocol.ts +0 -324
- package/src/index.ts +0 -1080
- package/src/json.ts +0 -49
- package/src/kandanQueue.ts +0 -113
- package/src/kandanTls.ts +0 -86
- package/src/localCapabilities.ts +0 -143
- package/src/localCodexMessageState.ts +0 -135
- package/src/localCodexTurnState.ts +0 -108
- package/src/localConfig.ts +0 -99
- package/src/localEditor.ts +0 -1061
- package/src/localEditorRuntime.ts +0 -717
- package/src/localForwarding.ts +0 -523
- package/src/oauth.ts +0 -425
- package/src/pendingKandanMessageQueue.ts +0 -109
- package/src/phoenix.ts +0 -359
- package/src/portForwardApproval.ts +0 -181
- package/src/portForwardWatcher.ts +0 -404
- package/src/protocol.ts +0 -321
- package/src/runner.ts +0 -943
- package/src/runnerConsoleReporter.ts +0 -142
- package/src/runnerLogger.ts +0 -50
- package/src/streamDeltaCoalescing.ts +0 -129
- package/src/streamDeltaQueue.ts +0 -102
package/src/forwardTunnel.ts
DELETED
|
@@ -1,859 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
- Date: 2026-04-29
|
|
3
|
-
Spec: plans/2026-04-29-local-runner-streaming-forwarding-rca-plan.md
|
|
4
|
-
Relationship: Maintains the runner-to-Kandan binary data-plane tunnel and
|
|
5
|
-
bridges approved loopback HTTP/WebSocket streams without base64 buffering.
|
|
6
|
-
*/
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import http from "node:http";
|
|
9
|
-
import https from "node:https";
|
|
10
|
-
import { type JsonObject, type JsonValue, isJsonObject } from "./protocol";
|
|
11
|
-
import {
|
|
12
|
-
decodeForwardTunnelFrame,
|
|
13
|
-
encodeForwardTunnelFrame,
|
|
14
|
-
forwardTunnelChunkBytes,
|
|
15
|
-
type ForwardTunnelFrame,
|
|
16
|
-
type ForwardTunnelWebSocketOpcode,
|
|
17
|
-
} from "./forwardTunnelProtocol";
|
|
18
|
-
|
|
19
|
-
export type ForwardTunnelHandle = {
|
|
20
|
-
readonly generation: string;
|
|
21
|
-
readonly close: () => void;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
type ForwardTunnelOptions = {
|
|
25
|
-
readonly kandanUrl: string;
|
|
26
|
-
readonly token: string;
|
|
27
|
-
readonly runnerId: string;
|
|
28
|
-
readonly allowedPorts: () => readonly number[];
|
|
29
|
-
readonly log?: ((event: string, payload: JsonObject) => void) | undefined;
|
|
30
|
-
readonly socketFactory?: ((url: string) => WebSocket) | undefined;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
type HttpStream = {
|
|
34
|
-
readonly kind: "http";
|
|
35
|
-
readonly abortController: AbortController;
|
|
36
|
-
readonly requestBodyWriter:
|
|
37
|
-
| WritableStreamDefaultWriter<Uint8Array>
|
|
38
|
-
| undefined;
|
|
39
|
-
readonly rawRequest: http.ClientRequest | undefined;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
type WebSocketStream = {
|
|
43
|
-
readonly kind: "websocket";
|
|
44
|
-
readonly socket: WebSocket;
|
|
45
|
-
readonly pendingMessages: WebSocketPendingMessage[];
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
type OpenRequest = {
|
|
49
|
-
readonly port: number;
|
|
50
|
-
readonly method: string;
|
|
51
|
-
readonly path: string;
|
|
52
|
-
readonly queryString: string | undefined;
|
|
53
|
-
readonly headers: Headers;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
type LocalStream = HttpStream | WebSocketStream;
|
|
57
|
-
type WebSocketPendingMessage = {
|
|
58
|
-
readonly opcode: ForwardTunnelWebSocketOpcode;
|
|
59
|
-
readonly payload: Uint8Array;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export async function connectForwardTunnel(
|
|
63
|
-
options: ForwardTunnelOptions,
|
|
64
|
-
): Promise<ForwardTunnelHandle> {
|
|
65
|
-
const generation = randomUUID();
|
|
66
|
-
const streams = new Map<number, LocalStream>();
|
|
67
|
-
const state: {
|
|
68
|
-
websocket: WebSocket | undefined;
|
|
69
|
-
closed: boolean;
|
|
70
|
-
reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
71
|
-
} = {
|
|
72
|
-
websocket: undefined,
|
|
73
|
-
closed: false,
|
|
74
|
-
reconnectTimer: undefined,
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const send = (frame: ForwardTunnelFrame): void => {
|
|
78
|
-
const websocket = state.websocket;
|
|
79
|
-
if (websocket === undefined || websocket.readyState !== WebSocket.OPEN) {
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
websocket.send(encodeForwardTunnelFrame(frame));
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const closeLocalStreams = (): void => {
|
|
86
|
-
for (const [streamId, stream] of streams) {
|
|
87
|
-
closeLocalStream(stream);
|
|
88
|
-
streams.delete(streamId);
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const scheduleReconnect = (): void => {
|
|
93
|
-
if (state.closed || state.reconnectTimer !== undefined) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
state.reconnectTimer = setTimeout(() => {
|
|
98
|
-
state.reconnectTimer = undefined;
|
|
99
|
-
void openSocket();
|
|
100
|
-
}, 250);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const openSocket = async (): Promise<void> => {
|
|
104
|
-
if (state.closed) {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const websocket = (options.socketFactory ?? ((url) => new WebSocket(url)))(
|
|
109
|
-
forwardTunnelUrl(
|
|
110
|
-
options.kandanUrl,
|
|
111
|
-
options.runnerId,
|
|
112
|
-
options.token,
|
|
113
|
-
generation,
|
|
114
|
-
),
|
|
115
|
-
);
|
|
116
|
-
websocket.binaryType = "arraybuffer";
|
|
117
|
-
state.websocket = websocket;
|
|
118
|
-
websocket.addEventListener("message", (event) => {
|
|
119
|
-
void handleMessage(event.data, streams, options.allowedPorts, send);
|
|
120
|
-
});
|
|
121
|
-
websocket.addEventListener("close", () => {
|
|
122
|
-
if (state.websocket === websocket) {
|
|
123
|
-
closeLocalStreams();
|
|
124
|
-
scheduleReconnect();
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
websocket.addEventListener("error", () => {
|
|
128
|
-
if (state.websocket === websocket) {
|
|
129
|
-
options.log?.("kandan.forward_tunnel_error", { generation });
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
await waitForOpen(websocket);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
if (state.websocket === websocket) {
|
|
137
|
-
state.websocket = undefined;
|
|
138
|
-
options.log?.("kandan.forward_tunnel_open_failed", {
|
|
139
|
-
generation,
|
|
140
|
-
error: error instanceof Error ? error.message : "websocket_open_failed",
|
|
141
|
-
});
|
|
142
|
-
websocket.close();
|
|
143
|
-
scheduleReconnect();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
await openSocket();
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
generation,
|
|
152
|
-
close: () => {
|
|
153
|
-
state.closed = true;
|
|
154
|
-
if (state.reconnectTimer !== undefined) {
|
|
155
|
-
clearTimeout(state.reconnectTimer);
|
|
156
|
-
}
|
|
157
|
-
closeLocalStreams();
|
|
158
|
-
state.websocket?.close();
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function forwardTunnelUrl(
|
|
164
|
-
baseUrl: string,
|
|
165
|
-
runnerId: string,
|
|
166
|
-
token: string,
|
|
167
|
-
generation: string,
|
|
168
|
-
): string {
|
|
169
|
-
const parsed = new URL(baseUrl);
|
|
170
|
-
parsed.protocol =
|
|
171
|
-
parsed.protocol === "https:" || parsed.protocol === "wss:" ? "wss:" : "ws:";
|
|
172
|
-
parsed.pathname = `/api/v2/local-codex-runner/tunnel/${encodeURIComponent(runnerId)}`;
|
|
173
|
-
parsed.search = "";
|
|
174
|
-
parsed.searchParams.set("token", token);
|
|
175
|
-
parsed.searchParams.set("generation", generation);
|
|
176
|
-
return parsed.toString();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function handleMessage(
|
|
180
|
-
message: Blob | ArrayBuffer | Uint8Array,
|
|
181
|
-
streams: Map<number, LocalStream>,
|
|
182
|
-
allowedPorts: () => readonly number[],
|
|
183
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
184
|
-
): Promise<void> {
|
|
185
|
-
const decoded = await decodeForwardTunnelFrame(message);
|
|
186
|
-
|
|
187
|
-
if (!decoded.ok) {
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const frame = decoded.frame;
|
|
192
|
-
switch (frame.type) {
|
|
193
|
-
case "open_http":
|
|
194
|
-
openHttpStream(
|
|
195
|
-
frame.streamId,
|
|
196
|
-
frame.payload,
|
|
197
|
-
streams,
|
|
198
|
-
allowedPorts,
|
|
199
|
-
send,
|
|
200
|
-
);
|
|
201
|
-
return;
|
|
202
|
-
case "request_body_chunk":
|
|
203
|
-
await writeRequestBodyChunk(frame.streamId, frame.payload, streams);
|
|
204
|
-
return;
|
|
205
|
-
case "request_body_end":
|
|
206
|
-
await closeRequestBody(frame.streamId, streams);
|
|
207
|
-
return;
|
|
208
|
-
case "open_websocket":
|
|
209
|
-
openWebSocketStream(
|
|
210
|
-
frame.streamId,
|
|
211
|
-
frame.payload,
|
|
212
|
-
streams,
|
|
213
|
-
allowedPorts,
|
|
214
|
-
send,
|
|
215
|
-
"ws",
|
|
216
|
-
);
|
|
217
|
-
return;
|
|
218
|
-
case "websocket_message":
|
|
219
|
-
sendWebSocketMessage(
|
|
220
|
-
frame.streamId,
|
|
221
|
-
frame.opcode,
|
|
222
|
-
frame.payload,
|
|
223
|
-
streams,
|
|
224
|
-
send,
|
|
225
|
-
);
|
|
226
|
-
return;
|
|
227
|
-
case "websocket_close":
|
|
228
|
-
case "cancel":
|
|
229
|
-
closeStream(frame.streamId, streams);
|
|
230
|
-
return;
|
|
231
|
-
case "response_headers":
|
|
232
|
-
case "response_body_chunk":
|
|
233
|
-
case "response_end":
|
|
234
|
-
case "stream_error":
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function openHttpStream(
|
|
240
|
-
streamId: number,
|
|
241
|
-
payload: JsonObject,
|
|
242
|
-
streams: Map<number, LocalStream>,
|
|
243
|
-
allowedPorts: () => readonly number[],
|
|
244
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
245
|
-
): void {
|
|
246
|
-
const request = openRequest(payload);
|
|
247
|
-
|
|
248
|
-
if (request === undefined) {
|
|
249
|
-
sendStreamError(streamId, "invalid_forward_request", send);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (!allowedPorts().includes(request.port)) {
|
|
254
|
-
sendStreamError(streamId, "forward_port_not_allowed", send);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const abortController = new AbortController();
|
|
259
|
-
const bodyStream =
|
|
260
|
-
request.method === "GET" || request.method === "HEAD"
|
|
261
|
-
? undefined
|
|
262
|
-
: new TransformStream<Uint8Array>();
|
|
263
|
-
streams.set(streamId, {
|
|
264
|
-
kind: "http",
|
|
265
|
-
abortController,
|
|
266
|
-
requestBodyWriter: bodyStream?.writable.getWriter(),
|
|
267
|
-
rawRequest: undefined,
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
switch (bodyStream) {
|
|
271
|
-
case undefined:
|
|
272
|
-
void forwardRawHttpResponse(streamId, request, streams, send);
|
|
273
|
-
return;
|
|
274
|
-
|
|
275
|
-
default: {
|
|
276
|
-
const requestInit: RequestInit & { readonly duplex?: "half" } = {
|
|
277
|
-
method: request.method,
|
|
278
|
-
headers: request.headers,
|
|
279
|
-
signal: abortController.signal,
|
|
280
|
-
body: bodyStream.readable,
|
|
281
|
-
duplex: "half",
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
void fetchWithHttpsFallback(request, requestInit)
|
|
285
|
-
.then((response) =>
|
|
286
|
-
forwardHttpResponse(streamId, response, request.method, streams, send),
|
|
287
|
-
)
|
|
288
|
-
.catch((error) => {
|
|
289
|
-
if (!abortController.signal.aborted) {
|
|
290
|
-
sendStreamError(
|
|
291
|
-
streamId,
|
|
292
|
-
error instanceof Error &&
|
|
293
|
-
error.message === "forward_port_not_allowed"
|
|
294
|
-
? "forward_port_not_allowed"
|
|
295
|
-
: "forward_target_unavailable",
|
|
296
|
-
send,
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
streams.delete(streamId);
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function forwardHttpResponse(
|
|
306
|
-
streamId: number,
|
|
307
|
-
response: Response,
|
|
308
|
-
method: string,
|
|
309
|
-
streams: Map<number, LocalStream>,
|
|
310
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
311
|
-
): Promise<void> {
|
|
312
|
-
send({
|
|
313
|
-
type: "response_headers",
|
|
314
|
-
streamId,
|
|
315
|
-
payload: {
|
|
316
|
-
status: response.status,
|
|
317
|
-
headers: responseHeaders(response.headers),
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
if (method !== "HEAD" && response.body !== null) {
|
|
322
|
-
const reader = response.body.getReader();
|
|
323
|
-
while (true) {
|
|
324
|
-
const result = await reader.read();
|
|
325
|
-
if (result.done) {
|
|
326
|
-
break;
|
|
327
|
-
}
|
|
328
|
-
sendChunkedBody(streamId, result.value, send);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
send({ type: "response_end", streamId });
|
|
333
|
-
streams.delete(streamId);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function sendChunkedBody(
|
|
337
|
-
streamId: number,
|
|
338
|
-
body: Uint8Array,
|
|
339
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
340
|
-
): void {
|
|
341
|
-
for (
|
|
342
|
-
let offset = 0;
|
|
343
|
-
offset < body.byteLength;
|
|
344
|
-
offset += forwardTunnelChunkBytes
|
|
345
|
-
) {
|
|
346
|
-
send({
|
|
347
|
-
type: "response_body_chunk",
|
|
348
|
-
streamId,
|
|
349
|
-
payload: body.subarray(offset, offset + forwardTunnelChunkBytes),
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async function writeRequestBodyChunk(
|
|
355
|
-
streamId: number,
|
|
356
|
-
body: Uint8Array,
|
|
357
|
-
streams: Map<number, LocalStream>,
|
|
358
|
-
): Promise<void> {
|
|
359
|
-
const stream = streams.get(streamId);
|
|
360
|
-
if (stream?.kind === "http" && stream.requestBodyWriter !== undefined) {
|
|
361
|
-
await stream.requestBodyWriter.write(body);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
async function closeRequestBody(
|
|
366
|
-
streamId: number,
|
|
367
|
-
streams: Map<number, LocalStream>,
|
|
368
|
-
): Promise<void> {
|
|
369
|
-
const stream = streams.get(streamId);
|
|
370
|
-
if (stream?.kind === "http" && stream.requestBodyWriter !== undefined) {
|
|
371
|
-
await stream.requestBodyWriter.close();
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function openWebSocketStream(
|
|
376
|
-
streamId: number,
|
|
377
|
-
payload: JsonObject,
|
|
378
|
-
streams: Map<number, LocalStream>,
|
|
379
|
-
allowedPorts: () => readonly number[],
|
|
380
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
381
|
-
scheme: "ws" | "wss",
|
|
382
|
-
): void {
|
|
383
|
-
const request = openRequest(payload);
|
|
384
|
-
|
|
385
|
-
if (request === undefined) {
|
|
386
|
-
sendStreamError(streamId, "invalid_forward_request", send);
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (!allowedPorts().includes(request.port)) {
|
|
391
|
-
sendStreamError(streamId, "forward_port_not_allowed", send);
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
let opened = false;
|
|
396
|
-
const pendingMessages: WebSocketPendingMessage[] = [];
|
|
397
|
-
const url = localForwardWebSocketUrl(
|
|
398
|
-
scheme,
|
|
399
|
-
request.port,
|
|
400
|
-
request.path,
|
|
401
|
-
request.queryString,
|
|
402
|
-
);
|
|
403
|
-
const protocols = webSocketProtocols(request.headers);
|
|
404
|
-
const socket =
|
|
405
|
-
protocols === undefined ? new WebSocket(url) : new WebSocket(url, protocols);
|
|
406
|
-
streams.set(streamId, { kind: "websocket", socket, pendingMessages });
|
|
407
|
-
socket.addEventListener("open", () => {
|
|
408
|
-
opened = true;
|
|
409
|
-
send({
|
|
410
|
-
type: "response_headers",
|
|
411
|
-
streamId,
|
|
412
|
-
payload: { status: 101, headers: [] },
|
|
413
|
-
});
|
|
414
|
-
flushPendingWebSocketMessages(socket, pendingMessages);
|
|
415
|
-
});
|
|
416
|
-
socket.addEventListener("message", (event) => {
|
|
417
|
-
const body =
|
|
418
|
-
typeof event.data === "string"
|
|
419
|
-
? Buffer.from(event.data)
|
|
420
|
-
: Buffer.from(event.data as ArrayBuffer);
|
|
421
|
-
send({
|
|
422
|
-
type: "websocket_message",
|
|
423
|
-
streamId,
|
|
424
|
-
opcode: typeof event.data === "string" ? "text" : "binary",
|
|
425
|
-
payload: body,
|
|
426
|
-
});
|
|
427
|
-
});
|
|
428
|
-
socket.addEventListener("close", (event) => {
|
|
429
|
-
const stream = streams.get(streamId);
|
|
430
|
-
if (stream?.kind !== "websocket" || stream.socket !== socket) {
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
streams.delete(streamId);
|
|
435
|
-
send({
|
|
436
|
-
type: "websocket_close",
|
|
437
|
-
streamId,
|
|
438
|
-
payload: { code: event.code, reason: event.reason },
|
|
439
|
-
});
|
|
440
|
-
});
|
|
441
|
-
socket.addEventListener("error", () => {
|
|
442
|
-
const stream = streams.get(streamId);
|
|
443
|
-
if (stream?.kind !== "websocket" || stream.socket !== socket) {
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
streams.delete(streamId);
|
|
448
|
-
if (!opened && scheme === "ws") {
|
|
449
|
-
openWebSocketStream(
|
|
450
|
-
streamId,
|
|
451
|
-
payload,
|
|
452
|
-
streams,
|
|
453
|
-
allowedPorts,
|
|
454
|
-
send,
|
|
455
|
-
"wss",
|
|
456
|
-
);
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
sendStreamError(streamId, "websocket_error", send);
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function sendWebSocketMessage(
|
|
464
|
-
streamId: number,
|
|
465
|
-
opcode: ForwardTunnelWebSocketOpcode,
|
|
466
|
-
payload: Uint8Array,
|
|
467
|
-
streams: Map<number, LocalStream>,
|
|
468
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
469
|
-
): void {
|
|
470
|
-
const stream = streams.get(streamId);
|
|
471
|
-
if (
|
|
472
|
-
stream?.kind !== "websocket" ||
|
|
473
|
-
stream.socket.readyState > WebSocket.OPEN
|
|
474
|
-
) {
|
|
475
|
-
sendStreamError(streamId, "websocket_not_open", send);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (stream.socket.readyState === WebSocket.CONNECTING) {
|
|
480
|
-
queueWebSocketMessage(stream, opcode, payload, streamId, send);
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
sendLocalWebSocketMessage(stream.socket, opcode, payload);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function queueWebSocketMessage(
|
|
488
|
-
stream: WebSocketStream,
|
|
489
|
-
opcode: ForwardTunnelWebSocketOpcode,
|
|
490
|
-
payload: Uint8Array,
|
|
491
|
-
streamId: number,
|
|
492
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
493
|
-
): void {
|
|
494
|
-
const queuedBytes = stream.pendingMessages.reduce(
|
|
495
|
-
(total, message) => total + message.payload.byteLength,
|
|
496
|
-
0,
|
|
497
|
-
);
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
stream.pendingMessages.length >= 256 ||
|
|
501
|
-
queuedBytes + payload.byteLength > 4 * 1024 * 1024
|
|
502
|
-
) {
|
|
503
|
-
sendStreamError(streamId, "websocket_pending_queue_full", send);
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
stream.pendingMessages.push({ opcode, payload: Buffer.from(payload) });
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function flushPendingWebSocketMessages(
|
|
511
|
-
socket: WebSocket,
|
|
512
|
-
pendingMessages: WebSocketPendingMessage[],
|
|
513
|
-
): void {
|
|
514
|
-
for (const message of pendingMessages.splice(0)) {
|
|
515
|
-
sendLocalWebSocketMessage(socket, message.opcode, message.payload);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function sendLocalWebSocketMessage(
|
|
520
|
-
socket: WebSocket,
|
|
521
|
-
opcode: ForwardTunnelWebSocketOpcode,
|
|
522
|
-
payload: Uint8Array,
|
|
523
|
-
): void {
|
|
524
|
-
switch (opcode) {
|
|
525
|
-
case "text":
|
|
526
|
-
socket.send(Buffer.from(payload).toString());
|
|
527
|
-
return;
|
|
528
|
-
case "binary":
|
|
529
|
-
case "ping":
|
|
530
|
-
case "pong":
|
|
531
|
-
socket.send(payload);
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
function closeStream(
|
|
537
|
-
streamId: number,
|
|
538
|
-
streams: Map<number, LocalStream>,
|
|
539
|
-
): void {
|
|
540
|
-
const stream = streams.get(streamId);
|
|
541
|
-
streams.delete(streamId);
|
|
542
|
-
if (stream !== undefined) {
|
|
543
|
-
closeLocalStream(stream);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function closeLocalStream(stream: LocalStream): void {
|
|
548
|
-
switch (stream.kind) {
|
|
549
|
-
case "http":
|
|
550
|
-
stream.abortController.abort();
|
|
551
|
-
stream.rawRequest?.destroy();
|
|
552
|
-
void stream.requestBodyWriter?.abort().catch(() => undefined);
|
|
553
|
-
return;
|
|
554
|
-
case "websocket":
|
|
555
|
-
stream.socket.close();
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function sendStreamError(
|
|
561
|
-
streamId: number,
|
|
562
|
-
error: string,
|
|
563
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
564
|
-
): void {
|
|
565
|
-
send({ type: "stream_error", streamId, payload: { error } });
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function openRequest(payload: JsonObject): OpenRequest | undefined {
|
|
569
|
-
const port = payload.port;
|
|
570
|
-
const method = payload.method;
|
|
571
|
-
const path = payload.path;
|
|
572
|
-
const queryString = payload.queryString;
|
|
573
|
-
|
|
574
|
-
if (
|
|
575
|
-
typeof port !== "number" ||
|
|
576
|
-
!Number.isInteger(port) ||
|
|
577
|
-
port < 1 ||
|
|
578
|
-
port > 65_535 ||
|
|
579
|
-
typeof method !== "string" ||
|
|
580
|
-
typeof path !== "string" ||
|
|
581
|
-
(queryString !== undefined && typeof queryString !== "string")
|
|
582
|
-
) {
|
|
583
|
-
return undefined;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
return {
|
|
587
|
-
port,
|
|
588
|
-
method,
|
|
589
|
-
path,
|
|
590
|
-
queryString,
|
|
591
|
-
headers: requestHeaders(payload.headers),
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
async function fetchWithHttpsFallback(
|
|
596
|
-
request: OpenRequest,
|
|
597
|
-
requestInit: RequestInit,
|
|
598
|
-
): Promise<Response> {
|
|
599
|
-
try {
|
|
600
|
-
return await fetch(
|
|
601
|
-
localForwardUrl("http", request.port, request.path, request.queryString),
|
|
602
|
-
requestInit,
|
|
603
|
-
);
|
|
604
|
-
} catch (httpError) {
|
|
605
|
-
if (requestInit.signal?.aborted) {
|
|
606
|
-
throw httpError;
|
|
607
|
-
}
|
|
608
|
-
return await fetch(
|
|
609
|
-
localForwardUrl("https", request.port, request.path, request.queryString),
|
|
610
|
-
requestInit,
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function localForwardUrl(
|
|
616
|
-
scheme: "http" | "https",
|
|
617
|
-
port: number,
|
|
618
|
-
path: string,
|
|
619
|
-
queryString: string | undefined,
|
|
620
|
-
): string {
|
|
621
|
-
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
622
|
-
const url = new URL(`${scheme}://127.0.0.1:${port}${normalizedPath}`);
|
|
623
|
-
|
|
624
|
-
if (queryString !== undefined && queryString.trim() !== "") {
|
|
625
|
-
url.search = queryString;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
return url.toString();
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
export function localForwardWebSocketUrl(
|
|
632
|
-
scheme: "ws" | "wss",
|
|
633
|
-
port: number,
|
|
634
|
-
path: string,
|
|
635
|
-
queryString: string | undefined,
|
|
636
|
-
): string {
|
|
637
|
-
const httpScheme = scheme === "ws" ? "http" : "https";
|
|
638
|
-
const url = new URL(localForwardUrl(httpScheme, port, path, queryString));
|
|
639
|
-
url.protocol = `${scheme}:`;
|
|
640
|
-
return url.toString();
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function requestHeaders(headers: JsonValue | undefined): Headers {
|
|
644
|
-
const result = new Headers();
|
|
645
|
-
|
|
646
|
-
if (!Array.isArray(headers)) {
|
|
647
|
-
return result;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
for (const header of headers) {
|
|
651
|
-
if (
|
|
652
|
-
isWireHeader(header) &&
|
|
653
|
-
!blockedRequestHeaderNames.has(header.name.toLowerCase())
|
|
654
|
-
) {
|
|
655
|
-
result.set(header.name, header.value);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
return result;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function webSocketProtocols(headers: Headers): string[] | undefined {
|
|
663
|
-
const protocolHeader = headers.get("sec-websocket-protocol");
|
|
664
|
-
if (protocolHeader === null) {
|
|
665
|
-
return undefined;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const protocols = protocolHeader
|
|
669
|
-
.split(",")
|
|
670
|
-
.map((protocol) => protocol.trim())
|
|
671
|
-
.filter((protocol) => protocol !== "");
|
|
672
|
-
|
|
673
|
-
return protocols.length === 0 ? undefined : protocols;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
function responseHeaders(headers: Headers): JsonObject[] {
|
|
677
|
-
return Array.from(headers.entries())
|
|
678
|
-
.filter(([name]) => !blockedFetchResponseHeaderNames.has(name.toLowerCase()))
|
|
679
|
-
.slice(0, 80)
|
|
680
|
-
.map(([name, value]) => ({ name, value }));
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function rawResponseHeaders(headers: http.IncomingHttpHeaders): JsonObject[] {
|
|
684
|
-
const entries = Object.entries(headers).flatMap(([name, value]) => {
|
|
685
|
-
if (blockedRawResponseHeaderNames.has(name.toLowerCase())) {
|
|
686
|
-
return [];
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
switch (typeof value) {
|
|
690
|
-
case "string":
|
|
691
|
-
return [{ name, value }];
|
|
692
|
-
case "undefined":
|
|
693
|
-
return [];
|
|
694
|
-
default:
|
|
695
|
-
return value.map((headerValue) => ({ name, value: headerValue }));
|
|
696
|
-
}
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
return entries.slice(0, 80);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
async function forwardRawHttpResponse(
|
|
703
|
-
streamId: number,
|
|
704
|
-
request: OpenRequest,
|
|
705
|
-
streams: Map<number, LocalStream>,
|
|
706
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
707
|
-
): Promise<void> {
|
|
708
|
-
try {
|
|
709
|
-
await rawHttpRequest("http", streamId, request, streams, send);
|
|
710
|
-
} catch (httpError) {
|
|
711
|
-
const stream = streams.get(streamId);
|
|
712
|
-
if (stream?.kind === "http" && stream.abortController.signal.aborted) {
|
|
713
|
-
streams.delete(streamId);
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
try {
|
|
718
|
-
await rawHttpRequest("https", streamId, request, streams, send);
|
|
719
|
-
} catch (_httpsError) {
|
|
720
|
-
sendStreamError(
|
|
721
|
-
streamId,
|
|
722
|
-
httpError instanceof Error && httpError.message === "aborted"
|
|
723
|
-
? "forward_target_unavailable"
|
|
724
|
-
: "forward_target_unavailable",
|
|
725
|
-
send,
|
|
726
|
-
);
|
|
727
|
-
streams.delete(streamId);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function rawHttpRequest(
|
|
733
|
-
scheme: "http" | "https",
|
|
734
|
-
streamId: number,
|
|
735
|
-
request: OpenRequest,
|
|
736
|
-
streams: Map<number, LocalStream>,
|
|
737
|
-
send: (frame: ForwardTunnelFrame) => void,
|
|
738
|
-
): Promise<void> {
|
|
739
|
-
return new Promise((resolve, reject) => {
|
|
740
|
-
const localStream = streams.get(streamId);
|
|
741
|
-
if (localStream?.kind !== "http") {
|
|
742
|
-
reject(new Error("aborted"));
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const url = localForwardUrl(
|
|
747
|
-
scheme,
|
|
748
|
-
request.port,
|
|
749
|
-
request.path,
|
|
750
|
-
request.queryString,
|
|
751
|
-
);
|
|
752
|
-
const client = scheme === "http" ? http : https;
|
|
753
|
-
const rawRequest = client.request(url, {
|
|
754
|
-
method: request.method,
|
|
755
|
-
headers: Object.fromEntries(request.headers.entries()),
|
|
756
|
-
signal: localStream.abortController.signal,
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
streams.set(streamId, { ...localStream, rawRequest });
|
|
760
|
-
|
|
761
|
-
rawRequest.on("response", (response) => {
|
|
762
|
-
send({
|
|
763
|
-
type: "response_headers",
|
|
764
|
-
streamId,
|
|
765
|
-
payload: {
|
|
766
|
-
status: response.statusCode ?? 502,
|
|
767
|
-
headers: rawResponseHeaders(response.headers),
|
|
768
|
-
},
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
response.on("data", (chunk: Buffer) => {
|
|
772
|
-
sendChunkedBody(streamId, chunk, send);
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
response.on("end", () => {
|
|
776
|
-
send({ type: "response_end", streamId });
|
|
777
|
-
streams.delete(streamId);
|
|
778
|
-
resolve();
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
response.on("error", reject);
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
rawRequest.on("error", reject);
|
|
785
|
-
rawRequest.end();
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function isWireHeader(
|
|
790
|
-
value: JsonValue,
|
|
791
|
-
): value is { readonly name: string; readonly value: string } {
|
|
792
|
-
return (
|
|
793
|
-
isJsonObject(value) &&
|
|
794
|
-
typeof value.name === "string" &&
|
|
795
|
-
typeof value.value === "string"
|
|
796
|
-
);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function waitForOpen(websocket: WebSocket): Promise<void> {
|
|
800
|
-
return new Promise((resolve, reject) => {
|
|
801
|
-
websocket.addEventListener("open", () => resolve(), { once: true });
|
|
802
|
-
websocket.addEventListener(
|
|
803
|
-
"error",
|
|
804
|
-
() => reject(new Error("websocket open failed")),
|
|
805
|
-
{
|
|
806
|
-
once: true,
|
|
807
|
-
},
|
|
808
|
-
);
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
const blockedRequestHeaderNames = new Set([
|
|
813
|
-
"authorization",
|
|
814
|
-
"cache-control",
|
|
815
|
-
"connection",
|
|
816
|
-
"content-encoding",
|
|
817
|
-
"content-length",
|
|
818
|
-
"cookie",
|
|
819
|
-
"etag",
|
|
820
|
-
"host",
|
|
821
|
-
"if-modified-since",
|
|
822
|
-
"if-none-match",
|
|
823
|
-
"keep-alive",
|
|
824
|
-
"last-modified",
|
|
825
|
-
"proxy-authenticate",
|
|
826
|
-
"proxy-authorization",
|
|
827
|
-
"set-cookie",
|
|
828
|
-
"te",
|
|
829
|
-
"trailer",
|
|
830
|
-
"transfer-encoding",
|
|
831
|
-
"upgrade",
|
|
832
|
-
]);
|
|
833
|
-
|
|
834
|
-
const blockedFetchResponseHeaderNames = new Set([
|
|
835
|
-
"connection",
|
|
836
|
-
"content-encoding",
|
|
837
|
-
"content-length",
|
|
838
|
-
"keep-alive",
|
|
839
|
-
"proxy-authenticate",
|
|
840
|
-
"proxy-authorization",
|
|
841
|
-
"set-cookie",
|
|
842
|
-
"te",
|
|
843
|
-
"trailer",
|
|
844
|
-
"transfer-encoding",
|
|
845
|
-
"upgrade",
|
|
846
|
-
]);
|
|
847
|
-
|
|
848
|
-
const blockedRawResponseHeaderNames = new Set([
|
|
849
|
-
"connection",
|
|
850
|
-
"content-length",
|
|
851
|
-
"keep-alive",
|
|
852
|
-
"proxy-authenticate",
|
|
853
|
-
"proxy-authorization",
|
|
854
|
-
"set-cookie",
|
|
855
|
-
"te",
|
|
856
|
-
"trailer",
|
|
857
|
-
"transfer-encoding",
|
|
858
|
-
"upgrade",
|
|
859
|
-
]);
|