@swf2es/desktop 0.1.16 → 0.1.18

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.
Files changed (54) hide show
  1. package/README.md +41 -1
  2. package/dist/main/air.d.ts +3 -3
  3. package/dist/main/air.d.ts.map +1 -1
  4. package/dist/main/air.js +17 -8
  5. package/dist/main/air.js.map +1 -1
  6. package/dist/main/case-paths.d.ts +12 -0
  7. package/dist/main/case-paths.d.ts.map +1 -0
  8. package/dist/main/case-paths.js +84 -0
  9. package/dist/main/case-paths.js.map +1 -0
  10. package/dist/main/connections.d.ts +20 -1
  11. package/dist/main/connections.d.ts.map +1 -1
  12. package/dist/main/connections.js +71 -8
  13. package/dist/main/connections.js.map +1 -1
  14. package/dist/main/main.js +80 -14
  15. package/dist/main/main.js.map +1 -1
  16. package/dist/main/network.d.ts +10 -0
  17. package/dist/main/network.d.ts.map +1 -1
  18. package/dist/main/network.js +111 -35
  19. package/dist/main/network.js.map +1 -1
  20. package/dist/main/proxy.d.ts +48 -0
  21. package/dist/main/proxy.d.ts.map +1 -0
  22. package/dist/main/proxy.js +402 -0
  23. package/dist/main/proxy.js.map +1 -0
  24. package/dist/main/sandbox.d.ts.map +1 -1
  25. package/dist/main/sandbox.js +2 -1
  26. package/dist/main/sandbox.js.map +1 -1
  27. package/dist/main/settings.d.ts +2 -0
  28. package/dist/main/settings.d.ts.map +1 -1
  29. package/dist/main/settings.js +4 -0
  30. package/dist/main/settings.js.map +1 -1
  31. package/dist/main/sockets.d.ts +4 -2
  32. package/dist/main/sockets.d.ts.map +1 -1
  33. package/dist/main/sockets.js +6 -4
  34. package/dist/main/sockets.js.map +1 -1
  35. package/dist/main/websocket-connections.d.ts +11 -0
  36. package/dist/main/websocket-connections.d.ts.map +1 -1
  37. package/dist/main/websocket-connections.js +72 -4
  38. package/dist/main/websocket-connections.js.map +1 -1
  39. package/dist/main/websockets.d.ts +3 -2
  40. package/dist/main/websockets.d.ts.map +1 -1
  41. package/dist/main/websockets.js +4 -3
  42. package/dist/main/websockets.js.map +1 -1
  43. package/package.json +7 -7
  44. package/src/main/air.ts +18 -8
  45. package/src/main/case-paths.ts +97 -0
  46. package/src/main/connections.ts +90 -8
  47. package/src/main/main.ts +105 -20
  48. package/src/main/network.ts +140 -37
  49. package/src/main/proxy.ts +497 -0
  50. package/src/main/sandbox.ts +2 -1
  51. package/src/main/settings.ts +5 -0
  52. package/src/main/sockets.ts +7 -4
  53. package/src/main/websocket-connections.ts +84 -4
  54. package/src/main/websockets.ts +4 -2
@@ -0,0 +1,497 @@
1
+ // --proxy=<url>: a proxy that every connection the main process makes for
2
+ // a SWF goes through, its http and https requests, its sockets, its policy
3
+ // files and an AIR application's WebSockets: a SOCKS5 proxy (RFC 1928,
4
+ // with RFC 1929's username and password where the URL gives them), or an
5
+ // HTTP proxy, asked for a CONNECT tunnel for each connection, plain http
6
+ // too, with Basic credentials where given. The URL's credentials are the
7
+ // command line's alone: never kept, and never said.
8
+ //
9
+ // Without Electron, so that node tests it (tests/desktop/proxy.test.ts).
10
+ import { connect, isIP, isIPv4, type Socket } from "node:net";
11
+ import { domainToASCII } from "node:url";
12
+
13
+ export interface ProxyConfig {
14
+ kind: "socks5" | "http";
15
+ /** Its host, an IPv6 address without its brackets. */
16
+ host: string;
17
+ port: number;
18
+ /** Both or neither: a URL with a username and no password has an empty one. */
19
+ username: string | null;
20
+ password: string | null;
21
+ }
22
+
23
+ /**
24
+ * A connection through the proxy that did not come about. `fault` says
25
+ * whether the proxy itself failed, not reached, refusing the credentials
26
+ * or making no sense, rather than answering that the target was not
27
+ * reached, which is no more than a server that is not there.
28
+ */
29
+ export class ProxyError extends Error {
30
+ readonly fault: boolean;
31
+
32
+ constructor(message: string, fault: boolean) {
33
+ super(message);
34
+ this.fault = fault;
35
+ }
36
+ }
37
+
38
+ /** How long the proxy may take to connect and answer, all told. */
39
+ const TIMEOUT = 30_000;
40
+ /** The longest answer an HTTP proxy gives a CONNECT, its headers included. */
41
+ const MAX_HEADERS = 16 * 1024;
42
+
43
+ const SCHEMES: Record<string, ProxyConfig["kind"]> = {
44
+ "socks5:": "socks5",
45
+ // The same: the target's name always goes to the proxy, which resolves it.
46
+ "socks5h:": "socks5",
47
+ "http:": "http",
48
+ };
49
+ const DEFAULT_PORTS: Record<ProxyConfig["kind"], number> = { socks5: 1080, http: 80 };
50
+
51
+ /** `text` with whatever could be credentials left out, for a message. */
52
+ function redacted(text: string): string {
53
+ return text.includes("@") ? text.replace(/^([a-z][a-z0-9+.-]*:\/\/)?.*@/i, "$1…@") : text;
54
+ }
55
+
56
+ /** `text` as a proxy; a message that never holds its credentials, otherwise. */
57
+ export function parseProxy(text: string): ProxyConfig {
58
+ const shown = redacted(text).slice(0, 256);
59
+ let url: URL;
60
+ try {
61
+ url = new URL(text);
62
+ } catch {
63
+ throw new Error(`${shown} is not a URL`);
64
+ }
65
+
66
+ const kind = SCHEMES[url.protocol];
67
+ if (!kind) {
68
+ throw new Error(`${url.protocol}// is no proxy this app speaks: socks5:// or http://`);
69
+ }
70
+
71
+ // A URL standard's non-special scheme keeps its host as written.
72
+ const host = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
73
+ if (host === "") {
74
+ throw new Error(`${shown} names no host`);
75
+ }
76
+
77
+ if ((url.pathname !== "" && url.pathname !== "/") || url.search !== "" || url.hash !== "") {
78
+ throw new Error(`${shown} has more than a host and a port`);
79
+ }
80
+
81
+ let username: string | null = null;
82
+ let password: string | null = null;
83
+ if (url.username !== "" || url.password !== "") {
84
+ try {
85
+ username = decodeURIComponent(url.username);
86
+ password = decodeURIComponent(url.password);
87
+ } catch {
88
+ throw new Error(`${shown} has malformed %-escapes in its credentials`);
89
+ }
90
+
91
+ if (
92
+ kind === "socks5" &&
93
+ (Buffer.byteLength(username) > 255 || Buffer.byteLength(password) > 255)
94
+ ) {
95
+ throw new Error("a SOCKS5 username or password is 255 bytes at most");
96
+ }
97
+
98
+ // Basic's user-id ends at its first colon, and neither has a line break.
99
+ if (kind === "http" && (username.includes(":") || /[\r\n]/.test(username + password))) {
100
+ throw new Error("an HTTP proxy's username has no colon, nor either a line break");
101
+ }
102
+ }
103
+
104
+ return {
105
+ kind,
106
+ host,
107
+ port: url.port === "" ? DEFAULT_PORTS[kind] : Number(url.port),
108
+ username,
109
+ password,
110
+ };
111
+ }
112
+
113
+ /** The proxy as a message names it: its kind, host and port, never its credentials. */
114
+ export function describeProxy(proxy: ProxyConfig): string {
115
+ const host = isIP(proxy.host) === 6 ? `[${proxy.host}]` : proxy.host;
116
+ return `${proxy.kind}://${host}:${proxy.port}`;
117
+ }
118
+
119
+ /**
120
+ * The proxy --proxy=<url> names among the app's arguments, those before an
121
+ * AIR application's own after "--": null without one; its last if named
122
+ * twice. A malformed one, or --proxy with its URL apart, is an error, not
123
+ * a run without the proxy.
124
+ */
125
+ export function proxyArgument(argv: string[]): { proxy: ProxyConfig } | { error: string } | null {
126
+ const end = argv.indexOf("--");
127
+ const own = end < 0 ? argv : argv.slice(0, end);
128
+ let found: { proxy: ProxyConfig } | { error: string } | null = null;
129
+ for (const arg of own) {
130
+ if (arg === "--proxy") {
131
+ return { error: "--proxy takes its URL after an equals sign: --proxy=socks5://host:port" };
132
+ }
133
+
134
+ if (arg.startsWith("--proxy=")) {
135
+ try {
136
+ found = { proxy: parseProxy(arg.slice("--proxy=".length)) };
137
+ } catch (error) {
138
+ return { error: `--proxy: ${(error as Error).message}` };
139
+ }
140
+ }
141
+ }
142
+
143
+ return found;
144
+ }
145
+
146
+ /** Whether `a` and `b` are the same proxy, by the same credentials. */
147
+ export function sameProxy(a: ProxyConfig | null, b: ProxyConfig | null): boolean {
148
+ return (
149
+ a === b ||
150
+ (a !== null &&
151
+ b !== null &&
152
+ a.kind === b.kind &&
153
+ a.host === b.host &&
154
+ a.port === b.port &&
155
+ a.username === b.username &&
156
+ a.password === b.password)
157
+ );
158
+ }
159
+
160
+ /** A target as the proxy is asked for it: an address, or a name in ASCII. */
161
+ function targetHost(host: string): { address: string; family: 4 | 6 } | { name: string } {
162
+ const bare = host.replace(/^\[(.*)\]$/, "$1");
163
+ const family = isIP(bare);
164
+ if (family === 4 || family === 6) {
165
+ return { address: bare, family };
166
+ }
167
+
168
+ // A name of DNS's letters alone: nothing that would end a line of the HTTP proxy's request.
169
+ const name = domainToASCII(bare);
170
+ if (name === "" || name.length > 255 || !/^[A-Za-z0-9._-]+$/.test(name)) {
171
+ throw new ProxyError("its host is no name a proxy is asked for", false);
172
+ }
173
+
174
+ return { name };
175
+ }
176
+
177
+ /** An IPv6 address's 16 bytes; `address` is one, as isIP says. */
178
+ function ipv6Bytes(address: string): Buffer {
179
+ let text = address.replace(/%.*$/, "");
180
+ // A last part that is an IPv4 address is two of the eight.
181
+ const v4 = /:(\d+\.\d+\.\d+\.\d+)$/.exec(text);
182
+ if (v4 && isIPv4(v4[1])) {
183
+ const [a, b, c, d] = v4[1].split(".").map(Number);
184
+ text = `${text.slice(0, v4.index)}:${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
185
+ }
186
+
187
+ const [head, tail] = text.split("::") as [string, string | undefined];
188
+ const parts = (s: string | undefined) => (s ? s.split(":") : []);
189
+ const left = parts(head);
190
+ const right = parts(tail);
191
+ const groups =
192
+ tail === undefined
193
+ ? left
194
+ : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
195
+ const bytes = Buffer.alloc(16);
196
+ for (let i = 0; i < 8; i++) {
197
+ bytes.writeUInt16BE(Number.parseInt(groups[i], 16), i * 2);
198
+ }
199
+
200
+ return bytes;
201
+ }
202
+
203
+ /** Reads a socket's bytes as a handshake needs them, and gives back what it did not need. */
204
+ class Reader {
205
+ private buffered = Buffer.alloc(0);
206
+ private wake: (() => void) | null = null;
207
+ private ended = false;
208
+ private readonly socket: Socket;
209
+
210
+ constructor(socket: Socket) {
211
+ this.socket = socket;
212
+ socket.on("readable", this.readable);
213
+ socket.on("end", this.end);
214
+ }
215
+
216
+ private readonly readable = () => {
217
+ for (let chunk = this.socket.read() as Buffer | null; chunk; chunk = this.socket.read()) {
218
+ this.buffered = Buffer.concat([this.buffered, chunk]);
219
+ }
220
+
221
+ this.wake?.();
222
+ };
223
+
224
+ private readonly end = () => {
225
+ this.ended = true;
226
+ this.wake?.();
227
+ };
228
+
229
+ private async more(): Promise<void> {
230
+ if (this.ended) {
231
+ throw new Error("closed");
232
+ }
233
+
234
+ await new Promise<void>((woken) => {
235
+ this.wake = woken;
236
+ });
237
+ this.wake = null;
238
+ }
239
+
240
+ async take(length: number): Promise<Buffer> {
241
+ while (this.buffered.length < length) {
242
+ await this.more();
243
+ }
244
+
245
+ const bytes = this.buffered.subarray(0, length);
246
+ this.buffered = this.buffered.subarray(length);
247
+ return bytes;
248
+ }
249
+
250
+ /** Up to and with `marker`, failing past `max` bytes. */
251
+ async until(marker: string, max: number): Promise<Buffer> {
252
+ for (;;) {
253
+ const at = this.buffered.indexOf(marker);
254
+ if (at >= 0) {
255
+ return this.take(at + marker.length);
256
+ }
257
+
258
+ if (this.buffered.length > max) {
259
+ throw new Error("too long");
260
+ }
261
+
262
+ await this.more();
263
+ }
264
+ }
265
+
266
+ /** Stop reading; what came past the handshake goes back, for the socket's next reader. */
267
+ release(): void {
268
+ this.socket.removeListener("readable", this.readable);
269
+ this.socket.removeListener("end", this.end);
270
+ if (this.buffered.length > 0) {
271
+ this.socket.unshift(this.buffered);
272
+ }
273
+ }
274
+ }
275
+
276
+ /** RFC 1928's replies, as a message says them. */
277
+ const SOCKS_REPLIES: Record<number, string> = {
278
+ 1: "general failure",
279
+ 2: "not allowed by its rules",
280
+ 3: "network unreachable",
281
+ 4: "host unreachable",
282
+ 5: "connection refused",
283
+ 6: "TTL expired",
284
+ 7: "command not supported",
285
+ 8: "address type not supported",
286
+ };
287
+
288
+ async function socks5(
289
+ proxy: ProxyConfig,
290
+ socket: Socket,
291
+ reader: Reader,
292
+ target: ReturnType<typeof targetHost>,
293
+ port: number,
294
+ ): Promise<void> {
295
+ const where = describeProxy(proxy);
296
+ const credentials = proxy.username !== null;
297
+ socket.write(Buffer.from(credentials ? [5, 2, 0, 2] : [5, 1, 0]));
298
+ const [version, method] = await reader.take(2);
299
+ if (version !== 5) {
300
+ throw new ProxyError(`the proxy ${where} gave no SOCKS5 answer`, true);
301
+ }
302
+
303
+ if (method === 2 && credentials) {
304
+ const username = Buffer.from(proxy.username ?? "");
305
+ const password = Buffer.from(proxy.password ?? "");
306
+ socket.write(
307
+ Buffer.concat([
308
+ Buffer.from([1, username.length]),
309
+ username,
310
+ Buffer.from([password.length]),
311
+ password,
312
+ ]),
313
+ );
314
+ const [, status] = await reader.take(2);
315
+ if (status !== 0) {
316
+ throw new ProxyError(`the proxy ${where} refused the username and password`, true);
317
+ }
318
+ } else if (method === 0xff) {
319
+ throw new ProxyError(
320
+ credentials
321
+ ? `the proxy ${where} takes none of the ways to authenticate offered`
322
+ : `the proxy ${where} asks for a username and password`,
323
+ true,
324
+ );
325
+ } else if (method !== 0) {
326
+ throw new ProxyError(`the proxy ${where} chose a way to authenticate not offered`, true);
327
+ }
328
+
329
+ let address: Buffer;
330
+ if ("name" in target) {
331
+ const name = Buffer.from(target.name, "ascii");
332
+ address = Buffer.concat([Buffer.from([3, name.length]), name]);
333
+ } else if (target.family === 4) {
334
+ address = Buffer.from([1, ...target.address.split(".").map(Number)]);
335
+ } else {
336
+ address = Buffer.concat([Buffer.from([4]), ipv6Bytes(target.address)]);
337
+ }
338
+
339
+ const portBytes = Buffer.alloc(2);
340
+ portBytes.writeUInt16BE(port);
341
+ socket.write(Buffer.concat([Buffer.from([5, 1, 0]), address, portBytes]));
342
+ const [replyVersion, reply, , type] = await reader.take(4);
343
+ if (replyVersion !== 5) {
344
+ throw new ProxyError(`the proxy ${where} gave no SOCKS5 answer`, true);
345
+ }
346
+
347
+ if (reply !== 0) {
348
+ const why = SOCKS_REPLIES[reply] ?? "an unknown reply";
349
+ throw new ProxyError(`the proxy ${where} refused: ${why} (SOCKS reply ${reply})`, false);
350
+ }
351
+
352
+ // The address and port the proxy connected from, which nothing needs.
353
+ switch (type) {
354
+ case 1:
355
+ await reader.take(4 + 2);
356
+ break;
357
+ case 3:
358
+ await reader.take((await reader.take(1))[0] + 2);
359
+ break;
360
+ case 4:
361
+ await reader.take(16 + 2);
362
+ break;
363
+ default:
364
+ throw new ProxyError(`the proxy ${where} gave no SOCKS5 answer`, true);
365
+ }
366
+ }
367
+
368
+ async function httpConnect(
369
+ proxy: ProxyConfig,
370
+ socket: Socket,
371
+ reader: Reader,
372
+ target: ReturnType<typeof targetHost>,
373
+ port: number,
374
+ ): Promise<void> {
375
+ const where = describeProxy(proxy);
376
+ const host =
377
+ "name" in target ? target.name : target.family === 6 ? `[${target.address}]` : target.address;
378
+ const lines = [`CONNECT ${host}:${port} HTTP/1.1`, `Host: ${host}:${port}`];
379
+ if (proxy.username !== null) {
380
+ const basic = Buffer.from(`${proxy.username}:${proxy.password ?? ""}`).toString("base64");
381
+ lines.push(`Proxy-Authorization: Basic ${basic}`);
382
+ }
383
+
384
+ socket.write(`${lines.join("\r\n")}\r\n\r\n`);
385
+ let head: string;
386
+ try {
387
+ head = (await reader.until("\r\n\r\n", MAX_HEADERS)).toString("latin1");
388
+ } catch (error) {
389
+ if ((error as Error).message === "too long") {
390
+ throw new ProxyError(`the proxy ${where} answered with too long a head`, true);
391
+ }
392
+
393
+ throw error;
394
+ }
395
+
396
+ const status = /^HTTP\/1\.[01] (\d{3})(?: ([^\r\n]*))?\r\n/.exec(head);
397
+ if (!status) {
398
+ throw new ProxyError(`the proxy ${where} gave no HTTP answer`, true);
399
+ }
400
+
401
+ const code = Number(status[1]);
402
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is taken out
403
+ const reason = (status[2] ?? "").slice(0, 128).replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
404
+ if (code === 407) {
405
+ throw new ProxyError(
406
+ proxy.username !== null
407
+ ? `the proxy ${where} refused the username and password (HTTP 407)`
408
+ : `the proxy ${where} asks for a username and password (HTTP 407)`,
409
+ true,
410
+ );
411
+ }
412
+
413
+ if (code < 200 || code > 299) {
414
+ throw new ProxyError(`the proxy ${where} refused: HTTP ${code} ${reason}`.trimEnd(), false);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * A TCP connection to `host`:`port` through `proxy`, once the proxy says it
420
+ * is made: a name goes to the proxy as it is, for it to resolve, an
421
+ * address as that address. It fails with a ProxyError saying why, never
422
+ * naming the credentials; `signal` stops it.
423
+ */
424
+ export function connectThrough(
425
+ proxy: ProxyConfig,
426
+ host: string,
427
+ port: number,
428
+ options: { signal?: AbortSignal; timeout?: number } = {},
429
+ ): Promise<Socket> {
430
+ const where = describeProxy(proxy);
431
+ return new Promise((resolve, reject) => {
432
+ let target: ReturnType<typeof targetHost>;
433
+ try {
434
+ target = targetHost(host);
435
+ } catch (error) {
436
+ reject(error);
437
+ return;
438
+ }
439
+
440
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
441
+ reject(new ProxyError("its port is no TCP port", false));
442
+ return;
443
+ }
444
+
445
+ const signal = options.signal;
446
+ if (signal?.aborted) {
447
+ reject(new ProxyError("aborted", false));
448
+ return;
449
+ }
450
+
451
+ const socket = connect({ host: proxy.host, port: proxy.port });
452
+ const reader = new Reader(socket);
453
+ let settled = false;
454
+ const finish = (error: ProxyError | null) => {
455
+ if (settled) {
456
+ return;
457
+ }
458
+
459
+ settled = true;
460
+ clearTimeout(timer);
461
+ signal?.removeEventListener("abort", abort);
462
+ socket.removeListener("error", failed);
463
+ socket.removeListener("close", closed);
464
+ if (error) {
465
+ reader.release();
466
+ socket.destroy();
467
+ reject(error);
468
+ } else {
469
+ reader.release();
470
+ resolve(socket);
471
+ }
472
+ };
473
+ const abort = () => finish(new ProxyError("aborted", false));
474
+ const failed = (error: Error) =>
475
+ finish(new ProxyError(`the proxy ${where} is not reached: ${error.message}`, true));
476
+ const closed = () => finish(new ProxyError(`the proxy ${where} closed the connection`, true));
477
+ const timer = setTimeout(
478
+ () => finish(new ProxyError(`the proxy ${where} did not answer in time`, true)),
479
+ options.timeout ?? TIMEOUT,
480
+ );
481
+ signal?.addEventListener("abort", abort, { once: true });
482
+ socket.on("error", failed);
483
+ socket.on("close", closed);
484
+ socket.once("connect", () => {
485
+ const handshake = proxy.kind === "socks5" ? socks5 : httpConnect;
486
+ handshake(proxy, socket, reader, target, port).then(
487
+ () => finish(null),
488
+ (error: Error) =>
489
+ finish(
490
+ error instanceof ProxyError
491
+ ? error
492
+ : new ProxyError(`the proxy ${where} closed the connection`, true),
493
+ ),
494
+ );
495
+ });
496
+ });
497
+ }
@@ -15,6 +15,7 @@ import { realpathSync } from "node:fs";
15
15
  import { realpath } from "node:fs/promises";
16
16
  import { homedir, tmpdir } from "node:os";
17
17
  import { basename, dirname, isAbsolute, join, parse, relative, sep } from "node:path";
18
+ import { resolveCase } from "./case-paths.ts";
18
19
 
19
20
  export const FILE_ORIGIN = "swf2es://file";
20
21
 
@@ -35,7 +36,7 @@ interface Grant {
35
36
  */
36
37
  async function real(path: string): Promise<string | null> {
37
38
  try {
38
- return await realpath(path);
39
+ return await realpath(resolveCase(path));
39
40
  } catch {
40
41
  return null;
41
42
  }
@@ -44,6 +44,11 @@ export class SettingsFile {
44
44
  return this.update({ [name]: path });
45
45
  }
46
46
 
47
+ /** Back to the library the app ships; whether that was saved. */
48
+ clearLibrary(name: LibraryName): boolean {
49
+ return this.update({ [name]: undefined });
50
+ }
51
+
47
52
  setStage3D(enabled: boolean): boolean {
48
53
  return this.update({ stage3D: enabled });
49
54
  }
@@ -2,7 +2,7 @@
2
2
  // no network of its own beyond fetch, and carried over IPC. Each page's
3
3
  // connections (connections.ts) close as it navigates, reloads or goes.
4
4
  import { type IpcMainEvent, ipcMain, type WebContents } from "electron";
5
- import { Connections, validEndpoint } from "./connections.ts";
5
+ import { type ConnectionOptions, Connections, validEndpoint } from "./connections.ts";
6
6
  import { socketDataLine, socketLine } from "./netlog.ts";
7
7
 
8
8
  /**
@@ -10,13 +10,15 @@ import { socketDataLine, socketLine } from "./netlog.ts";
10
10
  * made only as `permit` allows it, to the address it gives where it gives
11
11
  * one; null refuses it. `log`, where given, is told each connection asked
12
12
  * for, opened, refused, failed and closed (--log-network), and with
13
- * `data` what each sends and receives too (--log-sockets).
13
+ * `data` what each sends and receives too (--log-sockets). With
14
+ * `options.proxy`, each goes through that proxy (--proxy).
14
15
  */
15
16
  export function bridgeSockets(
16
17
  trusted: (event: IpcMainEvent) => boolean,
17
18
  permit: (host: string, port: number) => Promise<{ address?: string } | null>,
18
19
  log: ((line: string) => void) | null = null,
19
20
  data = false,
21
+ options: ConnectionOptions = {},
20
22
  ): void {
21
23
  const pages = new Map<WebContents, Connections>();
22
24
 
@@ -36,7 +38,7 @@ export function bridgeSockets(
36
38
  if (!contents.isDestroyed()) {
37
39
  contents.send("socket:event", id, event);
38
40
  }
39
- });
41
+ }, options);
40
42
  pages.set(contents, connections);
41
43
  contents.on("did-start-navigation", (details) => {
42
44
  if (details.isMainFrame && !details.isSameDocument) {
@@ -70,7 +72,8 @@ export function bridgeSockets(
70
72
  return;
71
73
  }
72
74
 
73
- log?.(socketLine(id, `${allowed ? "connecting to" : "refused"} ${host}:${port}`));
75
+ const via = allowed && options.proxy ? " via proxy" : "";
76
+ log?.(socketLine(id, `${allowed ? "connecting to" : "refused"} ${host}:${port}${via}`));
74
77
  if (allowed) {
75
78
  connections.connect(id, host, port, allowed.address);
76
79
  } else {
@@ -2,7 +2,10 @@
2
2
  // of Electron's Node: what the WebSocket bridge (websockets.ts) holds for
3
3
  // each page, apart from Electron so that node runs it in the tests
4
4
  // (tests/desktop/websocket-connections.test.ts).
5
+ import { isIP, type Socket } from "node:net";
6
+ import * as tls from "node:tls";
5
7
  import type { WebSocketEvent } from "../shared/api.js";
8
+ import { connectThrough, type ProxyConfig, type ProxyError } from "./proxy.ts";
6
9
 
7
10
  /** As many as a page may hold open at once; past it a connection is refused. */
8
11
  const MAX_OPEN = 64;
@@ -61,6 +64,23 @@ export function hasWebSocket(): boolean {
61
64
  * made from the class of the dispatcher undici keeps for everything else.
62
65
  */
63
66
  export function unverifiedDispatcher(): unknown {
67
+ const Agent = agentClass();
68
+ if (!Agent) {
69
+ return null;
70
+ }
71
+
72
+ try {
73
+ const agent = new Agent({
74
+ connect: { rejectUnauthorized: false },
75
+ });
76
+ return typeof agent.dispatch === "function" ? agent : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** undici's Agent, the class of the dispatcher it keeps for everything else; null where none. */
83
+ function agentClass(): (new (options: object) => { dispatch?: unknown }) | null {
64
84
  // Naming the global loads undici, which then sets its dispatcher.
65
85
  if (!hasWebSocket()) {
66
86
  return null;
@@ -68,20 +88,80 @@ export function unverifiedDispatcher(): unknown {
68
88
 
69
89
  const shared = (globalThis as Record<symbol, unknown>)[Symbol.for("undici.globalDispatcher.1")];
70
90
  const Agent = (shared as { constructor?: unknown } | undefined)?.constructor;
71
- if (typeof Agent !== "function" || Agent.name !== "Agent") {
91
+ return typeof Agent === "function" && Agent.name === "Agent"
92
+ ? (Agent as new (
93
+ options: object,
94
+ ) => { dispatch?: unknown })
95
+ : null;
96
+ }
97
+
98
+ /**
99
+ * What sends every client's requests through `proxy` (--proxy): a tunnel to
100
+ * the URL's host, by name for the proxy to resolve, and TLS over it for
101
+ * wss:, its certificate unverified where `unverified` says so of its host.
102
+ * `warn` is told why a proxy did not make one. Null where this Node gives
103
+ * no way to make one, as unverifiedDispatcher.
104
+ */
105
+ export function proxiedDispatcher(
106
+ proxy: ProxyConfig,
107
+ unverified: (host: string) => boolean,
108
+ warn: (message: string) => void = () => {},
109
+ ): unknown {
110
+ const Agent = agentClass();
111
+ if (!Agent) {
72
112
  return null;
73
113
  }
74
114
 
115
+ // undici's connector: its options name the target, and its callback takes the socket made.
116
+ const connect = (
117
+ options: { hostname: string; protocol: string; port: string | number; servername?: string },
118
+ done: (error: Error | null, socket: Socket | null) => void,
119
+ ) => {
120
+ const host = options.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
121
+ const https = options.protocol === "https:";
122
+ const port = Number(options.port) || (https ? 443 : 80);
123
+ connectThrough(proxy, host, port).then(
124
+ (tunnel) => {
125
+ if (!https) {
126
+ done(null, tunnel);
127
+ return;
128
+ }
129
+
130
+ const secure = tls.connect({
131
+ socket: tunnel,
132
+ servername: isIP(host) ? undefined : (options.servername ?? host),
133
+ ALPNProtocols: ["http/1.1"],
134
+ ...(unverified(host) ? { rejectUnauthorized: false } : {}),
135
+ });
136
+ const failed = (error: Error) => done(error, null);
137
+ secure.once("error", failed);
138
+ secure.once("secureConnect", () => {
139
+ secure.removeListener("error", failed);
140
+ done(null, secure);
141
+ });
142
+ },
143
+ (error: ProxyError) => {
144
+ warn(`no WebSocket to ${host}:${port}: ${error.message}`);
145
+ done(error, null);
146
+ },
147
+ );
148
+ };
75
149
  try {
76
- const agent = new (Agent as new (options: object) => { dispatch?: unknown })({
77
- connect: { rejectUnauthorized: false },
78
- });
150
+ const agent = new Agent({ connect });
79
151
  return typeof agent.dispatch === "function" ? agent : null;
80
152
  } catch {
81
153
  return null;
82
154
  }
83
155
  }
84
156
 
157
+ /** An opener whose every connection is `dispatcher`'s: proxiedDispatcher's. */
158
+ export function dispatchedOpener(dispatcher: unknown): Opener {
159
+ return (url, protocols) => {
160
+ const Client = globalClient() as ClientConstructor;
161
+ return new Client(url, { protocols, dispatcher });
162
+ };
163
+ }
164
+
85
165
  /**
86
166
  * An opener that leaves a wss: server's certificate unverified where
87
167
  * `unverified` says so of its host, through `dispatcher`