@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
package/src/main/main.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // electron apps/desktop [--trace] app.xml [root-dir] [-- arguments...]
7
7
  //
8
8
  // --ignore-certificate-errors[=host,...] leaves HTTPS and WSS certificates unchecked.
9
+ // --proxy=socks5://[user:pass@]host:port (or http://...) sends the SWF's network through a proxy.
9
10
  //
10
11
  // The second runs an AIR application by its descriptor, as adl does.
11
12
  import { statSync } from "node:fs";
@@ -49,14 +50,23 @@ import {
49
50
  statOf,
50
51
  writeOf,
51
52
  } from "./air.ts";
53
+ import { resolveCase } from "./case-paths.ts";
52
54
  import { bridgeFiles } from "./files.ts";
53
55
  import { failureLine, requestLine } from "./netlog.ts";
54
56
  import { type Movie, Network, Refused, unverifiedHosts } from "./network.ts";
55
57
  import { APP_ORIGIN, registerScheme, serve } from "./protocol.ts";
58
+ import { describeProxy, proxyArgument, sameProxy } from "./proxy.ts";
56
59
  import { Sandbox } from "./sandbox.ts";
57
60
  import { SettingsFile } from "./settings.ts";
58
61
  import { bridgeSockets } from "./sockets.ts";
59
- import { hasWebSocket, unverifiedDispatcher, webSocketOpener } from "./websocket-connections.ts";
62
+ import {
63
+ dispatchedOpener,
64
+ hasWebSocket,
65
+ type Opener,
66
+ proxiedDispatcher,
67
+ unverifiedDispatcher,
68
+ webSocketOpener,
69
+ } from "./websocket-connections.ts";
60
70
  import { bridgeWebSockets } from "./websockets.ts";
61
71
 
62
72
  const preload = fileURLToPath(new URL("../preload/preload.cjs", import.meta.url));
@@ -106,16 +116,54 @@ if (unverified) {
106
116
 
107
117
  const unverifiedHost = (host: string) =>
108
118
  !!unverified && (unverified.all || unverified.hosts.includes(host.toLowerCase()));
109
- const network = new Network({ unverified: unverifiedHost });
119
+
120
+ /**
121
+ * --proxy=<url>: what the SWF's requests, sockets and WebSockets go
122
+ * through. For this run alone, never kept, and said aloud, without its
123
+ * credentials. One that does not parse stops the app rather than letting
124
+ * it run without.
125
+ */
126
+ const proxied = proxyArgument(process.argv.slice(1));
127
+ if (proxied && "error" in proxied) {
128
+ process.stderr.write(`swf2es: ${proxied.error}\n`);
129
+ process.exit(2);
130
+ }
131
+
132
+ const proxy = proxied?.proxy ?? null;
133
+ if (proxy) {
134
+ process.stderr.write(
135
+ `swf2es: --proxy: the SWF's requests, sockets and WebSockets go through ${describeProxy(proxy)}\n`,
136
+ );
137
+ }
138
+
139
+ /** What --log-network adds to a line of a connection that went through the proxy. */
140
+ const via = proxy ? " via proxy" : "";
141
+ const warn = (message: string) => process.stderr.write(`swf2es: ${message}\n`);
142
+ const network = new Network({ unverified: unverifiedHost, proxy });
110
143
  // What opens an AIR application's WebSockets: the global's client, a wss: server's certificate
111
- // unchecked where the switch names its host.
112
- const insecureWebSockets = unverified ? unverifiedDispatcher() : null;
113
- if (unverified && !insecureWebSockets) {
144
+ // unchecked where the switch names its host; or every one through the proxy.
145
+ const insecureWebSockets = unverified && !proxy ? unverifiedDispatcher() : null;
146
+ if (unverified && !proxy && !insecureWebSockets) {
114
147
  process.stderr.write(
115
148
  "swf2es: --ignore-certificate-errors: a WebSocket's certificate is verified all the same\n",
116
149
  );
117
150
  }
118
151
 
152
+ const proxiedWebSockets = proxy ? proxiedDispatcher(proxy, unverifiedHost, warn) : null;
153
+ // Never past the proxy: where none can be made, no WebSocket opens.
154
+ const webSocketOpen: Opener | undefined = proxy
155
+ ? proxiedWebSockets
156
+ ? dispatchedOpener(proxiedWebSockets)
157
+ : () => {
158
+ throw new Error("no WebSocket through the proxy");
159
+ }
160
+ : insecureWebSockets
161
+ ? webSocketOpener(unverifiedHost, insecureWebSockets)
162
+ : undefined;
163
+ if (proxy && !proxiedWebSockets) {
164
+ process.stderr.write("swf2es: --proxy: this Electron's WebSocket cannot go through a proxy\n");
165
+ }
166
+
119
167
  /** What the page's requests under way abort with, by the preload's number. */
120
168
  const requests = new Map<number, AbortController>();
121
169
  let settings: SettingsFile;
@@ -584,13 +632,15 @@ async function askForSocket(name: string, swf: string, endpoint: string): Promis
584
632
  * sockets do here. air.net.WebSocket is AIR's alone, so nothing else has one.
585
633
  */
586
634
  function permitWebSocket(url: string): boolean {
587
- if (sandbox.application && hasWebSocket()) {
635
+ if (sandbox.application && hasWebSocket() && (!proxy || proxiedWebSockets)) {
588
636
  return true;
589
637
  }
590
638
 
591
- const why = sandbox.application
592
- ? "this Electron has no WebSocket client"
593
- : "only an AIR application has one";
639
+ const why = !sandbox.application
640
+ ? "only an AIR application has one"
641
+ : !hasWebSocket()
642
+ ? "this Electron has no WebSocket client"
643
+ : "this Electron's WebSocket cannot go through the proxy";
594
644
  process.stderr.write(`swf2es: no WebSocket to ${url.slice(0, 2048)}: ${why}\n`);
595
645
  return false;
596
646
  }
@@ -643,10 +693,22 @@ async function chooseLibrary(name: LibraryName): Promise<void> {
643
693
  refuse("The settings could not be saved.", `swf2es uses ${path} until it quits.`);
644
694
  }
645
695
 
696
+ buildMenu();
697
+
646
698
  // The page fetched the libraries once; a fresh page fetches the new one.
647
699
  window.webContents.reload();
648
700
  }
649
701
 
702
+ /** Play on the library the app ships again, in place of one the user chose. */
703
+ function resetLibrary(name: LibraryName): void {
704
+ if (!settings.clearLibrary(name)) {
705
+ refuse("The settings could not be saved.", `swf2es uses its own ${name} until it quits.`);
706
+ }
707
+
708
+ buildMenu();
709
+ window?.webContents.reload();
710
+ }
711
+
650
712
  function buildMenu(): void {
651
713
  const recent = settings.settings.recent;
652
714
  const template: MenuItemConstructorOptions[] = [
@@ -681,6 +743,11 @@ function buildMenu(): void {
681
743
  },
682
744
  { type: "separator" },
683
745
  { label: "Choose playerglobal.abc…", click: () => void chooseLibrary("playerglobal") },
746
+ {
747
+ label: "Use the App's playerglobal",
748
+ enabled: !!settings.settings.playerglobal,
749
+ click: () => resetLibrary("playerglobal"),
750
+ },
684
751
  { type: "separator" },
685
752
  { role: "quit" },
686
753
  ],
@@ -913,14 +980,8 @@ function listen(): void {
913
980
  void chooseLibrary(name);
914
981
  }
915
982
  });
916
- bridgeSockets(trusted, permitSocket, netLog, logSockets);
917
- bridgeWebSockets(
918
- trusted,
919
- permitWebSocket,
920
- insecureWebSockets ? webSocketOpener(unverifiedHost, insecureWebSockets) : undefined,
921
- netLog,
922
- logSockets,
923
- );
983
+ bridgeSockets(trusted, permitSocket, netLog, logSockets, { proxy, warn });
984
+ bridgeWebSockets(trusted, permitWebSocket, webSocketOpen, netLog, logSockets, via);
924
985
  ipcMain.handle("net:fetch", (event, id: unknown, request: unknown) => {
925
986
  if (!trusted(event) || typeof id !== "number") {
926
987
  throw new Error("not the app's page");
@@ -1052,7 +1113,7 @@ function listenForAir(): void {
1052
1113
  }
1053
1114
 
1054
1115
  try {
1055
- const real = await realpath(path);
1116
+ const real = await realpath(resolveCase(path));
1056
1117
  return sandbox.startApplication(real) ? new Uint8Array(await readFile(real)) : null;
1057
1118
  } catch {
1058
1119
  return null;
@@ -1155,7 +1216,7 @@ async function fetchForPage(
1155
1216
  response.status,
1156
1217
  response.bytes?.length ?? null,
1157
1218
  performance.now() - started,
1158
- ),
1219
+ ) + via,
1159
1220
  );
1160
1221
  return response;
1161
1222
  } catch (error) {
@@ -1164,7 +1225,7 @@ async function fetchForPage(
1164
1225
  const why = error instanceof Refused ? error.message : `failed: ${error}`;
1165
1226
  process.stderr.write(`swf2es: not loading ${shown}: ${why}\n`);
1166
1227
  } else {
1167
- netLog?.(failureLine(method, url, "aborted", performance.now() - started));
1228
+ netLog?.(failureLine(method, url, "aborted", performance.now() - started) + via);
1168
1229
  }
1169
1230
 
1170
1231
  throw error;
@@ -1178,10 +1239,25 @@ async function fetchForPage(
1178
1239
  // One app to a user data directory: a second start hands its SWF to the first and quits.
1179
1240
  const first = app.requestSingleInstanceLock();
1180
1241
  if (!first) {
1242
+ if (proxied) {
1243
+ process.stderr.write(
1244
+ "swf2es: handed to the app already open, which opens it only if started with the same --proxy\n",
1245
+ );
1246
+ }
1247
+
1181
1248
  app.quit();
1182
1249
  }
1183
1250
 
1184
1251
  app.on("second-instance", (_event, argv, workingDirectory) => {
1252
+ // What it names would go through another proxy than this app's, or past it.
1253
+ const asked = proxyArgument(argv.slice(1));
1254
+ if (asked && !("proxy" in asked && sameProxy(asked.proxy, proxy))) {
1255
+ process.stderr.write(
1256
+ "swf2es: not opening what a second start named: its --proxy is not this app's\n",
1257
+ );
1258
+ return;
1259
+ }
1260
+
1185
1261
  if (window) {
1186
1262
  if (window.isMinimized()) {
1187
1263
  window.restore();
@@ -1231,6 +1307,15 @@ void app.whenReady().then(() => {
1231
1307
  }
1232
1308
 
1233
1309
  settings = new SettingsFile(app.getPath("userData"));
1310
+ // A playerglobal chosen once is kept across versions of the app: said at each start, as a
1311
+ // library older or newer than the app's own makes SWFs fail in ways nothing else explains.
1312
+ const chosen = settings.libraries().playerglobal;
1313
+ if (chosen) {
1314
+ process.stderr.write(
1315
+ `swf2es: playing on the playerglobal chosen in File, ${chosen}, not the app's own\n`,
1316
+ );
1317
+ }
1318
+
1234
1319
  serve(session.defaultSession, sandbox, () => settings.libraries());
1235
1320
  // The page never goes to the network itself: a SWF's requests go through
1236
1321
  // the main process (network.ts), which sends them without this session.
@@ -21,12 +21,17 @@
21
21
  // that changes its answer (rebinding) changes nothing.
22
22
  // - Requests are bounded: methods, headers, sizes, redirects, time, and
23
23
  // how many run at once; and each movie's are aborted as the next opens.
24
+ // - Through a proxy (--proxy, proxy.ts), each connection is a tunnel to
25
+ // the address its host was pinned to, judged as without one: the proxy
26
+ // is asked for that address, not the name, so a proxy's DNS that answers
27
+ // otherwise than this machine's reaches nothing the judgement did not.
24
28
  //
25
29
  // Without Electron, so that node tests it (tests/desktop/network.test.ts).
26
30
  import { lookup } from "node:dns/promises";
27
31
  import { type ClientRequest, request as httpRequest, type IncomingMessage } from "node:http";
28
32
  import { request as httpsRequest } from "node:https";
29
- import { connect, isIP, isIPv4 } from "node:net";
33
+ import { connect, isIP, isIPv4, type Socket } from "node:net";
34
+ import type { Duplex } from "node:stream";
30
35
  import * as tls from "node:tls";
31
36
  import { type AddressClass, addressClass, badPort, RANK } from "./addresses.ts";
32
37
  import { DECODED, Decompressor } from "./decompress.ts";
@@ -44,6 +49,7 @@ import {
44
49
  urlMetaPolicy,
45
50
  usablePolicies,
46
51
  } from "./policy.ts";
52
+ import { connectThrough, type ProxyConfig, ProxyError } from "./proxy.ts";
47
53
 
48
54
  export type Purpose = "movie" | "content" | "data" | "send";
49
55
 
@@ -145,6 +151,8 @@ export interface NetworkOptions {
145
151
  limits?: Partial<Limits>;
146
152
  /** Where a socket server keeps its master policy: 843. */
147
153
  socketPolicyPort?: number;
154
+ /** What every connection goes through (--proxy); none by default. */
155
+ proxy?: ProxyConfig | null;
148
156
  }
149
157
 
150
158
  /** What Flash Player 32 reported, sent as it sent it. */
@@ -239,6 +247,7 @@ export class Network {
239
247
  private readonly ca: string | string[] | undefined;
240
248
  private readonly unverified: (host: string) => boolean;
241
249
  private readonly socketPolicyPort: number;
250
+ private readonly proxy: ProxyConfig | null;
242
251
  private session: Session | null = null;
243
252
  private running = 0;
244
253
  private readonly waiting: (() => void)[] = [];
@@ -253,6 +262,7 @@ export class Network {
253
262
  this.ca = options.ca ?? defaultCa();
254
263
  this.unverified = options.unverified ?? (() => false);
255
264
  this.socketPolicyPort = options.socketPolicyPort ?? 843;
265
+ this.proxy = options.proxy ?? null;
256
266
  }
257
267
 
258
268
  /**
@@ -753,7 +763,13 @@ export class Network {
753
763
  timeout: this.limits.policyTimeout,
754
764
  deadline: this.limits.policyTimeout,
755
765
  });
756
- } catch {
766
+ } catch (error) {
767
+ // A proxy that failed is said, as the request that needed the policy fails;
768
+ // a server the proxy did not reach has no policy, as one that is not there.
769
+ if (proxyFault(error)) {
770
+ throw error;
771
+ }
772
+
757
773
  return null;
758
774
  }
759
775
 
@@ -790,15 +806,27 @@ export class Network {
790
806
  timeout: number,
791
807
  signal: AbortSignal,
792
808
  ): Promise<SocketPolicy | null> {
793
- return new Promise((answer) => {
809
+ return new Promise((answer, fail) => {
794
810
  const chunks: Buffer[] = [];
795
811
  let length = 0;
796
- const socket = connect({ host: address, port });
797
- const finish = (policy: SocketPolicy | null) => {
812
+ let socket: Socket | null = null;
813
+ let finished = false;
814
+ const opening = new AbortController();
815
+ const finish = (policy: SocketPolicy | null, error?: Error) => {
816
+ if (finished) {
817
+ return;
818
+ }
819
+
820
+ finished = true;
798
821
  clearTimeout(timer);
799
822
  signal.removeEventListener("abort", abort);
800
- socket.destroy();
801
- answer(policy);
823
+ opening.abort();
824
+ socket?.destroy();
825
+ if (error) {
826
+ fail(error);
827
+ } else {
828
+ answer(policy);
829
+ }
802
830
  };
803
831
  const read = () => {
804
832
  const text = Buffer.concat(chunks).toString("utf8");
@@ -808,24 +836,70 @@ export class Network {
808
836
  const abort = () => finish(null);
809
837
  const timer = setTimeout(abort, timeout);
810
838
  signal.addEventListener("abort", abort, { once: true });
811
- socket.on("connect", () => socket.write("<policy-file-request/>\0"));
812
- socket.on("data", (chunk: Buffer) => {
813
- length += chunk.length;
814
- if (length > this.limits.policy) {
815
- finish(null);
816
- return;
817
- }
839
+ const talk = (connected: Socket) => {
840
+ socket = connected;
841
+ connected.on("data", (chunk: Buffer) => {
842
+ length += chunk.length;
843
+ if (length > this.limits.policy) {
844
+ finish(null);
845
+ return;
846
+ }
818
847
 
819
- chunks.push(chunk);
820
- if (chunk.includes(0)) {
821
- read();
822
- }
823
- });
824
- socket.on("end", read);
825
- socket.on("error", () => finish(null));
848
+ chunks.push(chunk);
849
+ if (chunk.includes(0)) {
850
+ read();
851
+ }
852
+ });
853
+ connected.on("end", read);
854
+ connected.on("error", () => finish(null));
855
+ };
856
+
857
+ if (!this.proxy) {
858
+ const direct = connect({ host: address, port });
859
+ talk(direct);
860
+ direct.on("connect", () => direct.write("<policy-file-request/>\0"));
861
+ return;
862
+ }
863
+
864
+ connectThrough(this.proxy, address, port, { signal: opening.signal }).then(
865
+ (tunnel) => {
866
+ if (finished) {
867
+ tunnel.destroy();
868
+ return;
869
+ }
870
+
871
+ talk(tunnel);
872
+ tunnel.write("<policy-file-request/>\0");
873
+ },
874
+ (error: ProxyError) =>
875
+ // A server the proxy did not reach has no policy; a proxy that failed is said.
876
+ finish(null, error.fault ? new Refused(error.message, { cause: error }) : undefined),
877
+ );
826
878
  });
827
879
  }
828
880
 
881
+ /**
882
+ * A request's createConnection: a tunnel through `proxy` to `address`,
883
+ * with TLS over it where `secure` says how, as Node's callback form takes
884
+ * it. A proxy's failure is a refusal saying why, the ProxyError its cause.
885
+ */
886
+ private tunnel(
887
+ proxy: ProxyConfig,
888
+ address: string,
889
+ port: number,
890
+ secure: tls.ConnectionOptions | false,
891
+ signal: AbortSignal,
892
+ ) {
893
+ return (_options: unknown, done: (error: Error | null, socket: Duplex) => void) => {
894
+ connectThrough(proxy, address, port, { signal }).then(
895
+ (tunnel) => done(null, secure ? tls.connect({ ...secure, socket: tunnel }) : tunnel),
896
+ (error: ProxyError) =>
897
+ done(new Refused(error.message, { cause: error }), undefined as unknown as Duplex),
898
+ );
899
+ return undefined;
900
+ };
901
+ }
902
+
829
903
  /**
830
904
  * One HTTP exchange with `address`, the URL's host pinned to it: its
831
905
  * status and headers, and its body if `read` and the status is a
@@ -877,28 +951,41 @@ export class Network {
877
951
 
878
952
  const https = url.protocol === "https:";
879
953
  const family = isIP(address);
954
+ const port = Number(url.port || (https ? 443 : 80));
955
+ const secure: tls.ConnectionOptions = {
956
+ servername: isIP(host) ? undefined : host,
957
+ ca: this.ca,
958
+ // Only where the user asked for this host: its certificate goes unchecked.
959
+ ...(this.unverified(host) ? { rejectUnauthorized: false } : {}),
960
+ };
880
961
  let request: ClientRequest;
881
962
  try {
882
963
  request = (https ? httpsRequest : httpRequest)({
883
964
  host,
884
- port: url.port || (https ? 443 : 80),
965
+ port,
885
966
  path: `${url.pathname}${url.search}`,
886
967
  method,
887
968
  headers: outgoing,
888
- agent: false,
889
- // The address pinned for this request, whatever the resolver would say now.
890
- lookup: (_name, opts, callback) => {
891
- if ((opts as { all?: boolean }).all) {
892
- (callback as (e: null, a: { address: string; family: number }[]) => void)(null, [
893
- { address, family },
894
- ]);
895
- } else {
896
- (callback as (e: null, a: string, f: number) => void)(null, address, family);
897
- }
898
- },
899
- ...(https ? { servername: isIP(host) ? undefined : host, ca: this.ca } : {}),
900
- // Only where the user asked for this host: its certificate goes unchecked.
901
- ...(https && this.unverified(host) ? { rejectUnauthorized: false } : {}),
969
+ ...(this.proxy
970
+ ? // Through the proxy to the pinned address, TLS over the tunnel for https; the
971
+ // request's own timers and signal bound the proxy's part too. No agent: the
972
+ // connection is this one's.
973
+ { createConnection: this.tunnel(this.proxy, address, port, https && secure, signal) }
974
+ : {
975
+ agent: false,
976
+ // The address pinned for this request, whatever the resolver would say now.
977
+ lookup: (_name, opts, callback) => {
978
+ if ((opts as { all?: boolean }).all) {
979
+ (callback as (e: null, a: { address: string; family: number }[]) => void)(
980
+ null,
981
+ [{ address, family }],
982
+ );
983
+ } else {
984
+ (callback as (e: null, a: string, f: number) => void)(null, address, family);
985
+ }
986
+ },
987
+ ...(https ? secure : {}),
988
+ }),
902
989
  });
903
990
  } catch (error) {
904
991
  // What check() let through and Node still refuses: a refusal like any other.
@@ -931,7 +1018,10 @@ export class Network {
931
1018
  const abort = () => stop(new Refused("aborted"));
932
1019
  signal.addEventListener("abort", abort, { once: true });
933
1020
  arm();
934
- request.on("error", (error) => stop(new Refused(error.message)));
1021
+ // A proxy's refusal comes as it was made, with its cause.
1022
+ request.on("error", (error) =>
1023
+ stop(error instanceof Refused ? error : new Refused(error.message)),
1024
+ );
935
1025
  request.on("response", (response: IncomingMessage) => {
936
1026
  const status = response.statusCode ?? 0;
937
1027
  const received: [string, string][] = [];
@@ -1195,11 +1285,24 @@ function cached<T>(
1195
1285
 
1196
1286
  value = make();
1197
1287
  cache.set(key, value);
1288
+ // A failure, a proxy's, is not kept: the next request asks again.
1289
+ const made = value;
1290
+ made.catch(() => {
1291
+ if (cache.get(key) === made) {
1292
+ cache.delete(key);
1293
+ }
1294
+ });
1198
1295
  }
1199
1296
 
1200
1297
  return value;
1201
1298
  }
1202
1299
 
1300
+ /** Whether `error` is a refusal for a proxy that failed, not a server it did not reach. */
1301
+ function proxyFault(error: unknown): boolean {
1302
+ const cause = (error as Error | null)?.cause;
1303
+ return cause instanceof ProxyError && cause.fault;
1304
+ }
1305
+
1203
1306
  function describe(requester: Requester): string {
1204
1307
  return requester.host === null ? 'a local SWF (domain="*")' : requester.host;
1205
1308
  }