@opencode/plugin-browser 0.0.0-reserved → 2.0.1
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 +127 -4
- package/dist/connection.d.ts +234 -0
- package/dist/connection.js +133 -0
- package/dist/files.d.ts +18 -0
- package/dist/files.js +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -0
- package/dist/proxy.d.ts +19 -0
- package/dist/proxy.js +294 -0
- package/dist/rpc.d.ts +2479 -0
- package/dist/rpc.js +322 -0
- package/dist/tools.d.ts +234 -0
- package/dist/tools.js +104 -0
- package/dist/tunnel.d.ts +16 -0
- package/dist/tunnel.js +125 -0
- package/package.json +37 -3
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
export * as BrowserProxy from "./proxy.js";
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { Agent, createServer, request, } from "node:http";
|
|
4
|
+
import { Duplex } from "node:stream";
|
|
5
|
+
import { Schema } from "effect";
|
|
6
|
+
import { Browser } from "./rpc.js";
|
|
7
|
+
// Desktop-only leaf. This listener is never loaded by the server plugin.
|
|
8
|
+
export async function make(transport) {
|
|
9
|
+
const username = randomBytes(16).toString("hex");
|
|
10
|
+
const password = randomBytes(32).toString("hex");
|
|
11
|
+
const expected = Buffer.from(`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`);
|
|
12
|
+
const clients = new Set();
|
|
13
|
+
const tunnels = new Set();
|
|
14
|
+
const pending = new Set();
|
|
15
|
+
let closed = false;
|
|
16
|
+
const authorized = (value) => {
|
|
17
|
+
if (!value)
|
|
18
|
+
return false;
|
|
19
|
+
const actual = Buffer.from(value);
|
|
20
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
21
|
+
};
|
|
22
|
+
const connect = async (target, signal) => {
|
|
23
|
+
if (closed)
|
|
24
|
+
throw new Error("Browser proxy is closed");
|
|
25
|
+
const abort = new AbortController();
|
|
26
|
+
const cancel = () => abort.abort();
|
|
27
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
28
|
+
if (signal.aborted)
|
|
29
|
+
cancel();
|
|
30
|
+
pending.add(abort);
|
|
31
|
+
try {
|
|
32
|
+
const id = await transport.open(target, abort.signal);
|
|
33
|
+
const socket = new TunnelSocket(transport, id);
|
|
34
|
+
if (closed || abort.signal.aborted) {
|
|
35
|
+
socket.destroy();
|
|
36
|
+
throw new Error("Browser proxy connection was cancelled");
|
|
37
|
+
}
|
|
38
|
+
tunnels.add(socket);
|
|
39
|
+
socket.once("close", () => tunnels.delete(socket));
|
|
40
|
+
return socket;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
pending.delete(abort);
|
|
44
|
+
signal.removeEventListener("abort", cancel);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const server = createServer({ maxHeaderSize: 64 * 1024 }, (incoming, response) => {
|
|
48
|
+
void forward(incoming, response, connect, authorized).catch(() => {
|
|
49
|
+
if (!response.headersSent) {
|
|
50
|
+
response.writeHead(502);
|
|
51
|
+
response.end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
response.destroy();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
server.requestTimeout = 30_000;
|
|
58
|
+
server.headersTimeout = 10_000;
|
|
59
|
+
server.on("connection", (socket) => {
|
|
60
|
+
clients.add(socket);
|
|
61
|
+
socket.on("error", () => socket.destroy());
|
|
62
|
+
socket.once("close", () => clients.delete(socket));
|
|
63
|
+
});
|
|
64
|
+
const upgrade = (incoming, socket, head, connectMethod) => {
|
|
65
|
+
void (async () => {
|
|
66
|
+
if (!authorized(incoming.headers["proxy-authorization"])) {
|
|
67
|
+
socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="OpenCode Browser Proxy"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const url = parseURL(connectMethod ? `https://${incoming.url ?? ""}` : incoming.url);
|
|
71
|
+
if (!url || (!connectMethod && incoming.headers.upgrade?.toLowerCase() !== "websocket")) {
|
|
72
|
+
socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const abort = new AbortController();
|
|
76
|
+
const cancel = () => abort.abort();
|
|
77
|
+
socket.once("close", cancel);
|
|
78
|
+
socket.pause();
|
|
79
|
+
try {
|
|
80
|
+
const tunnel = await connect(target(url), abort.signal);
|
|
81
|
+
if (socket.destroyed) {
|
|
82
|
+
tunnel.destroy();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (connectMethod)
|
|
86
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
87
|
+
if (!connectMethod) {
|
|
88
|
+
const headers = forwardedHeaders(incoming.headers);
|
|
89
|
+
headers.host = url.host;
|
|
90
|
+
headers.connection = "Upgrade";
|
|
91
|
+
headers.upgrade = "websocket";
|
|
92
|
+
tunnel.write(`${incoming.method} ${url.pathname}${url.search} HTTP/1.1\r\n${Object.entries(headers)
|
|
93
|
+
.flatMap(([key, value]) => value === undefined
|
|
94
|
+
? []
|
|
95
|
+
: (Array.isArray(value) ? value : [value]).map((item) => `${key}: ${item}\r\n`))
|
|
96
|
+
.join("")}\r\n`);
|
|
97
|
+
}
|
|
98
|
+
if (head.byteLength)
|
|
99
|
+
tunnel.write(head);
|
|
100
|
+
socket.once("close", () => tunnel.destroy());
|
|
101
|
+
tunnel.once("close", () => socket.destroy());
|
|
102
|
+
socket.pipe(tunnel);
|
|
103
|
+
tunnel.pipe(socket);
|
|
104
|
+
socket.resume();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
socket.off("close", cancel);
|
|
108
|
+
}
|
|
109
|
+
})().catch(() => {
|
|
110
|
+
if (!socket.destroyed)
|
|
111
|
+
socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
server.on("connect", (incoming, socket, head) => upgrade(incoming, socket, head, true));
|
|
115
|
+
server.on("upgrade", (incoming, socket, head) => upgrade(incoming, socket, head, false));
|
|
116
|
+
server.on("clientError", (_error, socket) => {
|
|
117
|
+
if (!socket.destroyed)
|
|
118
|
+
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
119
|
+
});
|
|
120
|
+
await new Promise((resolve, reject) => {
|
|
121
|
+
server.once("error", reject);
|
|
122
|
+
server.listen(0, "127.0.0.1", () => {
|
|
123
|
+
server.off("error", reject);
|
|
124
|
+
resolve();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
const address = server.address();
|
|
128
|
+
if (!address || typeof address === "string")
|
|
129
|
+
throw new Error("Browser proxy did not bind a TCP address");
|
|
130
|
+
let closing;
|
|
131
|
+
return {
|
|
132
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
133
|
+
host: "127.0.0.1",
|
|
134
|
+
port: address.port,
|
|
135
|
+
credentials: { username, password },
|
|
136
|
+
close() {
|
|
137
|
+
if (closing)
|
|
138
|
+
return closing;
|
|
139
|
+
closed = true;
|
|
140
|
+
pending.forEach((abort) => abort.abort());
|
|
141
|
+
tunnels.forEach((socket) => socket.destroy());
|
|
142
|
+
clients.forEach((socket) => socket.destroy());
|
|
143
|
+
closing = new Promise((resolve) => server.close(() => resolve()));
|
|
144
|
+
return closing;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function forward(incoming, response, connect, authorized) {
|
|
149
|
+
if (!authorized(incoming.headers["proxy-authorization"])) {
|
|
150
|
+
response.writeHead(407, { "Proxy-Authenticate": 'Basic realm="OpenCode Browser Proxy"' });
|
|
151
|
+
response.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const url = parseURL(incoming.url);
|
|
155
|
+
if (!url || url.protocol !== "http:") {
|
|
156
|
+
response.writeHead(400);
|
|
157
|
+
response.end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const abort = new AbortController();
|
|
161
|
+
const cancel = () => abort.abort();
|
|
162
|
+
incoming.once("aborted", cancel);
|
|
163
|
+
response.once("close", cancel);
|
|
164
|
+
const agent = new Agent({ keepAlive: false, maxSockets: 1 });
|
|
165
|
+
try {
|
|
166
|
+
const tunnel = await connect(target(url), abort.signal);
|
|
167
|
+
agent.createConnection = () => tunnel;
|
|
168
|
+
const headers = forwardedHeaders(incoming.headers);
|
|
169
|
+
headers.host = url.host;
|
|
170
|
+
headers.connection = "close";
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
const upstream = request({
|
|
173
|
+
agent,
|
|
174
|
+
hostname: url.hostname,
|
|
175
|
+
port: url.port || 80,
|
|
176
|
+
path: `${url.pathname}${url.search}`,
|
|
177
|
+
method: incoming.method,
|
|
178
|
+
headers,
|
|
179
|
+
signal: abort.signal,
|
|
180
|
+
}, (result) => {
|
|
181
|
+
response.writeHead(result.statusCode ?? 502, result.statusMessage, {
|
|
182
|
+
...forwardedHeaders(result.headers),
|
|
183
|
+
connection: "close",
|
|
184
|
+
});
|
|
185
|
+
result.once("error", reject);
|
|
186
|
+
response.once("finish", resolve);
|
|
187
|
+
result.pipe(response);
|
|
188
|
+
});
|
|
189
|
+
upstream.once("error", reject);
|
|
190
|
+
incoming.pipe(upstream);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
incoming.off("aborted", cancel);
|
|
195
|
+
response.off("close", cancel);
|
|
196
|
+
agent.destroy();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function forwardedHeaders(input) {
|
|
200
|
+
const headers = { ...input };
|
|
201
|
+
headers.connection?.split(",").forEach((name) => delete headers[name.trim().toLowerCase()]);
|
|
202
|
+
[
|
|
203
|
+
"connection",
|
|
204
|
+
"keep-alive",
|
|
205
|
+
"proxy-authenticate",
|
|
206
|
+
"proxy-authorization",
|
|
207
|
+
"proxy-connection",
|
|
208
|
+
"te",
|
|
209
|
+
"trailer",
|
|
210
|
+
"transfer-encoding",
|
|
211
|
+
"upgrade",
|
|
212
|
+
].forEach((name) => delete headers[name]);
|
|
213
|
+
return headers;
|
|
214
|
+
}
|
|
215
|
+
function parseURL(value) {
|
|
216
|
+
if (!value || !URL.canParse(value))
|
|
217
|
+
return;
|
|
218
|
+
const url = new URL(value);
|
|
219
|
+
if (!["http:", "https:", "ws:", "wss:"].includes(url.protocol) || url.username || url.password)
|
|
220
|
+
return;
|
|
221
|
+
return url;
|
|
222
|
+
}
|
|
223
|
+
function target(url) {
|
|
224
|
+
return Schema.decodeUnknownSync(Browser.TunnelTarget)({
|
|
225
|
+
host: url.hostname.replace(/^\[|\]$/g, ""),
|
|
226
|
+
port: url.port ? Number(url.port) : url.protocol === "https:" || url.protocol === "wss:" ? 443 : 80,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
class TunnelSocket extends Duplex {
|
|
230
|
+
transport;
|
|
231
|
+
id;
|
|
232
|
+
connecting = false;
|
|
233
|
+
abort = new AbortController();
|
|
234
|
+
pending = false;
|
|
235
|
+
constructor(transport, id) {
|
|
236
|
+
super({ highWaterMark: Browser.TUNNEL_CHUNK_BYTES, allowHalfOpen: true });
|
|
237
|
+
this.transport = transport;
|
|
238
|
+
this.id = id;
|
|
239
|
+
this.on("error", () => this.destroy());
|
|
240
|
+
}
|
|
241
|
+
_read() {
|
|
242
|
+
if (this.pending || this.destroyed)
|
|
243
|
+
return;
|
|
244
|
+
this.pending = true;
|
|
245
|
+
void this.transport.read(this.id, this.abort.signal).then((result) => {
|
|
246
|
+
this.pending = false;
|
|
247
|
+
if (this.destroyed)
|
|
248
|
+
return;
|
|
249
|
+
if (result.eof) {
|
|
250
|
+
this.push(null);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (this.push(result.data))
|
|
254
|
+
this._read();
|
|
255
|
+
}, (error) => this.destroy(asError(error)));
|
|
256
|
+
}
|
|
257
|
+
_write(chunk, encoding, callback) {
|
|
258
|
+
const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk;
|
|
259
|
+
void (async () => {
|
|
260
|
+
for (let offset = 0; offset < data.byteLength; offset += Browser.TUNNEL_CHUNK_BYTES)
|
|
261
|
+
await this.transport.write(this.id, data.subarray(offset, offset + Browser.TUNNEL_CHUNK_BYTES), false, this.abort.signal);
|
|
262
|
+
})().then(() => callback(), (error) => callback(asError(error)));
|
|
263
|
+
}
|
|
264
|
+
_final(callback) {
|
|
265
|
+
void this.transport.write(this.id, new Uint8Array(), true, this.abort.signal).then(() => callback(), (error) => callback(asError(error)));
|
|
266
|
+
}
|
|
267
|
+
_destroy(error, callback) {
|
|
268
|
+
this.abort.abort();
|
|
269
|
+
void this.transport
|
|
270
|
+
.close(this.id)
|
|
271
|
+
.catch(() => undefined)
|
|
272
|
+
.then(() => callback(error));
|
|
273
|
+
}
|
|
274
|
+
setKeepAlive() {
|
|
275
|
+
return this;
|
|
276
|
+
}
|
|
277
|
+
setNoDelay() {
|
|
278
|
+
return this;
|
|
279
|
+
}
|
|
280
|
+
setTimeout(_timeout, callback) {
|
|
281
|
+
if (callback)
|
|
282
|
+
this.once("timeout", callback);
|
|
283
|
+
return this;
|
|
284
|
+
}
|
|
285
|
+
ref() {
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
unref() {
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function asError(error) {
|
|
293
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
294
|
+
}
|