@tonalisk-labs/kit 1.0.0 → 1.0.6

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 (80) hide show
  1. package/README.md +7 -80
  2. package/dist/testing/src/config-coverage.d.ts.map +1 -1
  3. package/dist/testing/src/config-coverage.js +3 -2
  4. package/dist/testing/src/config-coverage.js.map +1 -1
  5. package/dist/testing/src/fixtures.d.ts +9 -3
  6. package/dist/testing/src/fixtures.d.ts.map +1 -1
  7. package/dist/testing/src/fixtures.js +98 -42
  8. package/dist/testing/src/fixtures.js.map +1 -1
  9. package/dist/testing/src/floors/node.js +11 -3
  10. package/dist/testing/src/floors/node.js.map +1 -1
  11. package/dist/testing/src/stack/browsers.d.ts +13 -3
  12. package/dist/testing/src/stack/browsers.d.ts.map +1 -1
  13. package/dist/testing/src/stack/browsers.js +117 -14
  14. package/dist/testing/src/stack/browsers.js.map +1 -1
  15. package/dist/testing/src/stack/containers.d.ts +6 -2
  16. package/dist/testing/src/stack/containers.d.ts.map +1 -1
  17. package/dist/testing/src/stack/containers.js +8 -1
  18. package/dist/testing/src/stack/containers.js.map +1 -1
  19. package/dist/testing/src/stack/control.d.ts +18 -3
  20. package/dist/testing/src/stack/control.d.ts.map +1 -1
  21. package/dist/testing/src/stack/control.js +50 -10
  22. package/dist/testing/src/stack/control.js.map +1 -1
  23. package/dist/testing/src/stack/gate-member.d.ts +10 -0
  24. package/dist/testing/src/stack/gate-member.d.ts.map +1 -1
  25. package/dist/testing/src/stack/gate-member.js +68 -7
  26. package/dist/testing/src/stack/gate-member.js.map +1 -1
  27. package/dist/testing/src/stack/gate-server.d.ts +37 -4
  28. package/dist/testing/src/stack/gate-server.d.ts.map +1 -1
  29. package/dist/testing/src/stack/gate-server.js +124 -12
  30. package/dist/testing/src/stack/gate-server.js.map +1 -1
  31. package/dist/testing/src/stack/hold.d.ts +24 -3
  32. package/dist/testing/src/stack/hold.d.ts.map +1 -1
  33. package/dist/testing/src/stack/hold.js +136 -11
  34. package/dist/testing/src/stack/hold.js.map +1 -1
  35. package/dist/testing/src/stack/host.d.ts.map +1 -1
  36. package/dist/testing/src/stack/host.js +26 -6
  37. package/dist/testing/src/stack/host.js.map +1 -1
  38. package/dist/testing/src/stack/lock.d.ts +15 -4
  39. package/dist/testing/src/stack/lock.d.ts.map +1 -1
  40. package/dist/testing/src/stack/lock.js +47 -18
  41. package/dist/testing/src/stack/lock.js.map +1 -1
  42. package/dist/testing/src/stack/preview-child.js +14 -3
  43. package/dist/testing/src/stack/preview-child.js.map +1 -1
  44. package/dist/testing/src/stack/runtime-apple.d.ts.map +1 -1
  45. package/dist/testing/src/stack/runtime-apple.js +16 -0
  46. package/dist/testing/src/stack/runtime-apple.js.map +1 -1
  47. package/dist/testing/src/stack/runtime-docker.d.ts +2 -1
  48. package/dist/testing/src/stack/runtime-docker.d.ts.map +1 -1
  49. package/dist/testing/src/stack/runtime-docker.js +29 -3
  50. package/dist/testing/src/stack/runtime-docker.js.map +1 -1
  51. package/dist/testing/src/stack/runtime.d.ts +10 -0
  52. package/dist/testing/src/stack/runtime.d.ts.map +1 -1
  53. package/dist/testing/src/stack/runtime.js +1 -0
  54. package/dist/testing/src/stack/runtime.js.map +1 -1
  55. package/dist/testing/src/stack/socks.d.ts +25 -0
  56. package/dist/testing/src/stack/socks.d.ts.map +1 -0
  57. package/dist/testing/src/stack/socks.js +195 -0
  58. package/dist/testing/src/stack/socks.js.map +1 -0
  59. package/dist/testing/src/stack/stall.d.ts +36 -0
  60. package/dist/testing/src/stack/stall.d.ts.map +1 -0
  61. package/dist/testing/src/stack/stall.js +93 -0
  62. package/dist/testing/src/stack/stall.js.map +1 -0
  63. package/package.json +4 -4
  64. package/testing/src/config-coverage.ts +3 -2
  65. package/testing/src/fixtures.ts +277 -197
  66. package/testing/src/floors/node.ts +19 -3
  67. package/testing/src/stack/browsers.ts +129 -15
  68. package/testing/src/stack/containers.ts +14 -2
  69. package/testing/src/stack/control.ts +62 -11
  70. package/testing/src/stack/gate-member.ts +90 -8
  71. package/testing/src/stack/gate-server.ts +166 -21
  72. package/testing/src/stack/hold.ts +163 -11
  73. package/testing/src/stack/host.ts +32 -9
  74. package/testing/src/stack/lock.ts +61 -21
  75. package/testing/src/stack/preview-child.ts +21 -4
  76. package/testing/src/stack/runtime-apple.ts +20 -1
  77. package/testing/src/stack/runtime-docker.ts +47 -3
  78. package/testing/src/stack/runtime.ts +11 -0
  79. package/testing/src/stack/socks.ts +233 -0
  80. package/testing/src/stack/stall.ts +145 -0
@@ -1,6 +1,14 @@
1
1
  import type { ServerResponse } from 'node:http';
2
2
 
3
- import { holdOpen, semaphore } from './gate-server.ts';
3
+ import { holdingsNamed, holdOpen, semaphore } from './gate-server.ts';
4
+ import { STILLNESS, type Stillness, type Watch, watchOf } from './stall.ts';
5
+
6
+ /** Who asks for a hold, by when their test must end, and the id its request gave. */
7
+ export interface Holder {
8
+ readonly who: string;
9
+ readonly deadline: number | undefined;
10
+ readonly id: string | undefined;
11
+ }
4
12
 
5
13
  /** Who holds a target's stack: many tests share it, one that disturbs it owns it alone. */
6
14
  export type Hold = 'share' | 'own';
@@ -13,6 +21,7 @@ interface Waiting {
13
21
  interface Stack {
14
22
  sharers: number;
15
23
  owned: boolean;
24
+ readonly watch: Watch;
16
25
  /** Each hold's waiters, first come, first served. */
17
26
  readonly queued: Readonly<Record<Hold, Waiting[]>>;
18
27
  }
@@ -36,21 +45,32 @@ const admit = (stack: Stack): void => {
36
45
  };
37
46
 
38
47
  const take = async (stack: Stack, hold: Hold, gone: AbortSignal): Promise<void> => {
39
- const { promise, resolve } = Promise.withResolvers<boolean>();
48
+ const { promise, resolve, reject } = Promise.withResolvers<boolean>();
49
+ const queue = stack.queued[hold];
50
+ // A test that gave up waiting (its timeout), or whose wait stalled, leaves the queue, so it
51
+ // never holds on.
52
+ const leave = (): void => {
53
+ const at = queue.indexOf(waiting);
54
+ if (at !== -1) {
55
+ queue.splice(at, 1);
56
+ admit(stack);
57
+ }
58
+ };
59
+ const done = stack.watch.waiting((stalled) => {
60
+ leave();
61
+ reject(stalled);
62
+ });
40
63
  const waiting: Waiting = {
41
64
  hold,
42
65
  grant: () => {
66
+ done();
67
+ stack.watch.moved();
43
68
  resolve(true);
44
69
  },
45
70
  };
46
- const queue = stack.queued[hold];
47
- // A test that gave up waiting (its timeout) leaves the queue, so it never holds on.
48
71
  gone.addEventListener('abort', () => {
49
- const at = queue.indexOf(waiting);
50
- if (at !== -1) {
51
- queue.splice(at, 1);
52
- admit(stack);
53
- }
72
+ done();
73
+ leave();
54
74
  });
55
75
  queue.push(waiting);
56
76
  admit(stack);
@@ -58,6 +78,7 @@ const take = async (stack: Stack, hold: Hold, gone: AbortSignal): Promise<void>
58
78
  };
59
79
 
60
80
  const give = (stack: Stack, hold: Hold): void => {
81
+ stack.watch.moved();
61
82
  if (hold === 'own') {
62
83
  stack.owned = false;
63
84
  } else {
@@ -67,14 +88,24 @@ const give = (stack: Stack, hold: Hold): void => {
67
88
  };
68
89
 
69
90
  /** A readers-writer lock per target, kept by the stack host. A waiting owner stops no sharer:
70
- * owners queue after every shared test, so stopping sharers would only idle workers. */
71
- export const stackLocks = (): {
91
+ * owners queue after every shared test, so stopping sharers would only idle workers. A wait fails
92
+ * once no hold on its stack has changed hands for the stillness's stall. */
93
+ export const stackLocks = (
94
+ stillness: Stillness = STILLNESS,
95
+ ): {
72
96
  readonly take: (key: string, hold: Hold, gone: AbortSignal) => Promise<void>;
73
97
  readonly give: (key: string, hold: Hold) => void;
98
+ /** The watch over a stack's waits, which a server counts each holder in. */
99
+ readonly watch: (key: string) => Watch;
74
100
  } => {
75
101
  const stacks = new Map<string, Stack>();
76
102
  const stackOf = (key: string): Stack => {
77
- const found = stacks.get(key) ?? { sharers: 0, owned: false, queued: { share: [], own: [] } };
103
+ const found = stacks.get(key) ?? {
104
+ sharers: 0,
105
+ owned: false,
106
+ watch: watchOf(stillness, { what: `no hold on ${key}`, named: holdingsNamed }),
107
+ queued: { share: [], own: [] },
108
+ };
78
109
  stacks.set(key, found);
79
110
  return found;
80
111
  };
@@ -83,25 +114,34 @@ export const stackLocks = (): {
83
114
  give: (key, hold) => {
84
115
  give(stackOf(key), hold);
85
116
  },
117
+ watch: (key) => stackOf(key).watch,
86
118
  };
87
119
  };
88
120
 
89
121
  /** A pool of `size` seats, a holder taking the ones it asks for all at once, in the order asked;
90
- * more than the pool has is refused at once, as it could never be seated. */
91
- export const seatPool = (size: number): ((outgoing: ServerResponse, asked: string) => void) => {
92
- const pool = semaphore(size);
93
- return (outgoing, asked) => {
122
+ * more than the pool has is refused at once, as it could never be seated. A wait fails once no
123
+ * seat has changed hands for the stillness's stall. */
124
+ export const seatPool = (
125
+ size: number,
126
+ stillness: Stillness = STILLNESS,
127
+ ): ((outgoing: ServerResponse, asked: string, holder: Holder) => void) => {
128
+ const pool = semaphore(size, { stillness, what: 'no seat' });
129
+ return (outgoing, asked, { who, deadline, id }) => {
94
130
  const count = Number(asked);
95
131
  if (!Number.isInteger(count) || count < 1 || count > size) {
96
132
  outgoing.writeHead(400).end(`the pool seats 1 to ${String(size)} at once, not ${asked}`);
97
133
  return;
98
134
  }
99
- holdOpen(
100
- outgoing,
101
- async (gone) => pool.take('seats', count, gone),
102
- () => {
135
+ holdOpen(outgoing, {
136
+ take: async (gone) => pool.take('seats', count, gone),
137
+ give: () => {
103
138
  pool.give(count);
104
139
  },
105
- );
140
+ what: `${String(count)} of the Linux browsers' ${String(size)} seats`,
141
+ who,
142
+ deadline,
143
+ watch: pool.watch,
144
+ id,
145
+ });
106
146
  };
107
147
  };
@@ -12,15 +12,27 @@ interface Astro {
12
12
  readonly outDir: string;
13
13
  readonly logLevel: 'error';
14
14
  readonly server: { readonly host: string; readonly port: number };
15
- }) => Promise<{ readonly port: number }>;
15
+ }) => Promise<{ readonly port: number; readonly stop: () => Promise<void> }>;
16
16
  }
17
17
 
18
18
  const isAstro = (value: unknown): value is Astro =>
19
19
  isObject(value) && typeof value['preview'] === 'function';
20
20
 
21
- // Its lifeline gone, it stops as a stop would have it.
21
+ interface Lifeline {
22
+ ended: boolean;
23
+ server: { readonly stop: () => Promise<void> } | undefined;
24
+ readonly stopping: Promise<void>[];
25
+ }
26
+
27
+ const lifeline: Lifeline = { ended: false, server: undefined, stopping: [] };
28
+
29
+ // Its lifeline gone, it stops its server and ends with nothing left to run, exiting 0. Vite would
30
+ // too, but only where CI isn't "true"; this comes first, and the stop takes Vite's listener away.
22
31
  process.stdin.on('end', () => {
23
- process.kill(process.pid, 'SIGTERM');
32
+ lifeline.ended = true;
33
+ if (lifeline.server !== undefined) {
34
+ lifeline.stopping.push(lifeline.server.stop());
35
+ }
24
36
  });
25
37
  process.stdin.resume();
26
38
  const [root = '', outDir = ''] = process.argv.slice(2);
@@ -35,4 +47,9 @@ const server = await astro.preview({
35
47
  logLevel: 'error',
36
48
  server: { host: '127.0.0.1', port: 0 },
37
49
  });
38
- console.log(`${PREVIEW_READY}${String(server.port)}`);
50
+ lifeline.server = server;
51
+ if (lifeline.ended) {
52
+ await server.stop();
53
+ } else {
54
+ console.log(`${PREVIEW_READY}${String(server.port)}`);
55
+ }
@@ -9,6 +9,7 @@ import {
9
9
  type Expiry,
10
10
  type Limits,
11
11
  LIMITS,
12
+ type Network,
12
13
  onTheClock,
13
14
  pairsOf,
14
15
  type Runtime,
@@ -22,6 +23,7 @@ const VERBS = {
22
23
  start: 'start',
23
24
  logs: 'logs',
24
25
  address: 'inspect',
26
+ network: 'inspect',
25
27
  exec: 'exec',
26
28
  stop: 'stop',
27
29
  remove: 'delete',
@@ -94,6 +96,22 @@ const publishedIn = (id: string, answer: string): Map<number, Address> => {
94
96
  );
95
97
  };
96
98
 
99
+ /** An address as an inspect records it, without its prefix length. */
100
+ const bare = (address: string): string => address.replace(/\/\d+$/u, '');
101
+
102
+ /** A container's address and its network's gateway, the host's address there, as an inspect
103
+ * records them: the address with its prefix length, which goes. */
104
+ const networkIn = (id: string, answer: string): Network => {
105
+ const networks = field(field(snapshotsIn('inspect', answer)[0], 'status'), 'networks');
106
+ const attached: unknown = Array.isArray(networks) ? networks.at(0) : undefined;
107
+ const [own, host] = [field(attached, 'ipv4Address'), field(attached, 'ipv4Gateway')];
108
+ const settled = field(attached, 'ipv6Address');
109
+ if (typeof own !== 'string' || typeof host !== 'string') {
110
+ throw new TypeError(`container inspect ${id} answered no network: ${answer}`);
111
+ }
112
+ return { own: bare(own), host, settled: typeof settled === 'string' ? bare(settled) : undefined };
113
+ };
114
+
97
115
  /** The ids a `list` answers. */
98
116
  const idsIn = (answer: string): Set<unknown> =>
99
117
  new Set(
@@ -190,8 +208,9 @@ const lifecycleOf = (call: Call, ports: Held): Pick<Runtime, 'create' | 'start'
190
208
  },
191
209
  });
192
210
 
193
- const lookupsOf = (call: Call): Pick<Runtime, 'addresses' | 'labelled'> => ({
211
+ const lookupsOf = (call: Call): Pick<Runtime, 'addresses' | 'labelled' | 'network'> => ({
194
212
  addresses: async (id) => publishedIn(id, await call('address', [id])),
213
+ network: async (id) => networkIn(id, await call('network', [id])),
195
214
  labelled: async (key) => labelledIn(await call('list', ['--all', '--format', 'json']), key),
196
215
  });
197
216
 
@@ -1,3 +1,4 @@
1
+ import { isObject } from '../unknown.ts';
1
2
  import {
2
3
  type Address,
3
4
  type Caller,
@@ -5,6 +6,7 @@ import {
5
6
  type ContainerSpec,
6
7
  type Expiry,
7
8
  type Limits,
9
+ type Network,
8
10
  pairsOf,
9
11
  type Runtime,
10
12
  sharedMemoryOf,
@@ -16,6 +18,7 @@ const VERBS = {
16
18
  start: 'start',
17
19
  logs: 'logs',
18
20
  address: 'port',
21
+ network: 'inspect',
19
22
  exec: 'exec',
20
23
  stop: 'stop',
21
24
  remove: 'rm',
@@ -57,6 +60,30 @@ const labelledIn = (listing: string): Map<string, string> =>
57
60
 
58
61
  type Call = Caller['call'];
59
62
 
63
+ /** A container's address and its network's gateway, as `docker inspect` gives its networks in
64
+ * JSON. On Linux the gateway is the host's own bridge; Docker Desktop's is in its virtual
65
+ * machine, so a host server there would be out of the container's reach. */
66
+ const networkIn = (id: string, answer: string, platform: NodeJS.Platform): Network => {
67
+ if (platform === 'darwin') {
68
+ throw new Error(
69
+ "Docker on macOS runs its containers in a virtual machine whose gateway isn't this Mac, so the Linux image's browsers can't reach its servers: run with Apple's container, unset KIT_CONTAINER_RUNTIME",
70
+ );
71
+ }
72
+ const parsed: unknown = JSON.parse(answer);
73
+ const [attached] = isObject(parsed) ? Object.values(parsed) : [];
74
+ const [own, host, settled] = isObject(attached)
75
+ ? [attached['IPAddress'], attached['Gateway'], attached['GlobalIPv6Address']]
76
+ : [];
77
+ if (typeof own !== 'string' || typeof host !== 'string' || own === '' || host === '') {
78
+ throw new TypeError(`docker inspect ${id} answered no network: ${answer}`);
79
+ }
80
+ return {
81
+ own,
82
+ host,
83
+ settled: typeof settled === 'string' && settled !== '' ? settled : undefined,
84
+ };
85
+ };
86
+
60
87
  /** Files go in by `docker cp`, which reads on the host side, so no host directory is shared with
61
88
  * Docker. */
62
89
  const lifecycleOf = (call: Call): Pick<Runtime, 'create' | 'start' | 'stop'> => ({
@@ -79,7 +106,16 @@ const lifecycleOf = (call: Call): Pick<Runtime, 'create' | 'start' | 'stop'> =>
79
106
  },
80
107
  });
81
108
 
82
- const lookupsOf = (call: Call): Pick<Runtime, 'addresses' | 'labelled'> => ({
109
+ const lookupsOf = (
110
+ call: Call,
111
+ platform: NodeJS.Platform,
112
+ ): Pick<Runtime, 'addresses' | 'labelled' | 'network'> => ({
113
+ network: async (id) =>
114
+ networkIn(
115
+ id,
116
+ await call('network', ['--format', '{{json .NetworkSettings.Networks}}', id]),
117
+ platform,
118
+ ),
83
119
  addresses: async (id, ports) =>
84
120
  new Map(
85
121
  await Promise.all(
@@ -89,10 +125,12 @@ const lookupsOf = (call: Call): Pick<Runtime, 'addresses' | 'labelled'> => ({
89
125
  }),
90
126
  ),
91
127
  ),
128
+ // A listing cuts each id to 12 characters unless told not to, and a create answers it whole.
92
129
  labelled: async (key) =>
93
130
  labelledIn(
94
131
  await call('list', [
95
132
  '--all',
133
+ '--no-trunc',
96
134
  '--filter',
97
135
  `label=${key}`,
98
136
  '--format',
@@ -106,7 +144,13 @@ export const dockerOf = ({
106
144
  command = 'docker',
107
145
  limits,
108
146
  expiry,
109
- }: { command?: string; limits?: Limits; expiry?: Expiry } = {}): Runtime & Caller => {
147
+ platform = process.platform,
148
+ }: {
149
+ command?: string;
150
+ limits?: Limits;
151
+ expiry?: Expiry;
152
+ platform?: NodeJS.Platform;
153
+ } = {}): Runtime & Caller => {
110
154
  const caller = callerOf({
111
155
  command,
112
156
  verbs: VERBS,
@@ -115,5 +159,5 @@ export const dockerOf = ({
115
159
  limits,
116
160
  expiry,
117
161
  });
118
- return Object.assign(caller, lifecycleOf(caller.call), lookupsOf(caller.call));
162
+ return Object.assign(caller, lifecycleOf(caller.call), lookupsOf(caller.call, platform));
119
163
  };
@@ -38,6 +38,15 @@ export interface Address {
38
38
  readonly port: number;
39
39
  }
40
40
 
41
+ /** Where a running container is on its network, and where the host is there: the one address a
42
+ * host server for the container answers, and the address it listens on. `settled` is the global
43
+ * IPv6 address the network gives it some seconds after it starts, where it gives one. */
44
+ export interface Network {
45
+ readonly own: string;
46
+ readonly host: string;
47
+ readonly settled?: string | undefined;
48
+ }
49
+
41
50
  /** How long each call may take on a busy machine, in milliseconds: a create may pull its image,
42
51
  * and a container may take minutes to say it's ready. A hung daemon answers no call at all. */
43
52
  export const LIMITS = {
@@ -46,6 +55,7 @@ export const LIMITS = {
46
55
  start: 120_000,
47
56
  logs: 300_000,
48
57
  address: 30_000,
58
+ network: 30_000,
49
59
  exec: 600_000,
50
60
  stop: 120_000,
51
61
  remove: 120_000,
@@ -70,6 +80,7 @@ export interface Runtime {
70
80
  id: string,
71
81
  ports: readonly number[],
72
82
  ) => Promise<ReadonlyMap<number, Address>>;
83
+ readonly network: (id: string) => Promise<Network>;
73
84
  readonly exec: (id: string, command: readonly string[], signal: AbortSignal) => Promise<string>;
74
85
  /** The command line of an exec whose standard input its caller holds. */
75
86
  readonly execLine: (id: string, command: readonly string[]) => readonly [string, ...string[]];
@@ -0,0 +1,233 @@
1
+ // The host's end of the Linux image's shared browsers: a SOCKS5 server on the host's address on
2
+ // their network. Playwright's launch server, unlike its run server, carries no tunnel to the host's
3
+ // loopback (exposeNetwork), so each browser is launched with this server as its proxy. With a SOCKS
4
+ // proxy, Playwright's Chromium resolves no name itself, so every request a browser makes, its
5
+ // page's and its context's own, comes here: the loopback's go to the host's, and the rest out, as
6
+ // a contained browser's went out before.
7
+ import { once } from 'node:events';
8
+ import { connect, createServer, isIP, type Socket } from 'node:net';
9
+
10
+ import { codeOf } from '../unknown.ts';
11
+ import type { Service } from './process.ts';
12
+
13
+ /** Where a browser asked to connect. */
14
+ export interface Destination {
15
+ readonly host: string;
16
+ readonly port: number;
17
+ }
18
+
19
+ const VERSION = 5;
20
+ const NO_AUTHENTICATION = 0;
21
+ const NO_ACCEPTABLE_METHOD = 255;
22
+ const CONNECT = 1;
23
+ const TYPE = { ipv4: 1, name: 3, ipv6: 4 } as const;
24
+ const REPLY = {
25
+ succeeded: 0,
26
+ failed: 1,
27
+ networkUnreachable: 3,
28
+ hostUnreachable: 4,
29
+ refused: 5,
30
+ commandUnsupported: 7,
31
+ addressUnsupported: 8,
32
+ } as const;
33
+
34
+ /** IPv6's loopback, as `ipv6Of` writes it. */
35
+ const IPV6_LOOPBACK = '0:0:0:0:0:0:0:1';
36
+
37
+ /** The names and addresses the host's loopback answers: Playwright's `<loopback>`, and all of
38
+ * 127.0.0.0/8, which is the loopback too. */
39
+ export const onLoopback = (host: string): boolean =>
40
+ host === 'localhost' ||
41
+ host.endsWith('.localhost') ||
42
+ (isIP(host) === 4 && host.startsWith('127.')) ||
43
+ host === '::1' ||
44
+ host === IPV6_LOOPBACK;
45
+
46
+ /** Where a request goes from the host: a name on the loopback to 127.0.0.1, where every edge
47
+ * listens and macOS puts names under localhost; any other as it was asked. Behind a SOCKS proxy
48
+ * Playwright launches Chromium with `--host-resolver-rules=MAP * ~NOTFOUND`, so nothing else
49
+ * reaches out but through here, and the Stripe sandbox's journeys go to checkout.stripe.com. */
50
+ export const routeOf = ({ host, port }: Destination): Destination =>
51
+ onLoopback(host) && isIP(host) === 0 ? { host: '127.0.0.1', port } : { host, port };
52
+
53
+ /** A reply with no bound address, which a browser never reads. */
54
+ const reply = (code: number): Buffer =>
55
+ Buffer.from([VERSION, code, 0, TYPE.ipv4, 0, 0, 0, 0, 0, 0]);
56
+
57
+ /** Resolves once `socket` has more to read, or is gone. */
58
+ const stirred = async (socket: Socket): Promise<boolean> => {
59
+ const { promise, resolve } = Promise.withResolvers<boolean>();
60
+ const done = (): void => {
61
+ socket.off('readable', done).off('close', done);
62
+ resolve(true);
63
+ };
64
+ socket.on('readable', done).on('close', done);
65
+ return promise;
66
+ };
67
+
68
+ /** The next `count` bytes from `socket`, once they have come; a failure if it ends first. */
69
+ const bytesOf = async (socket: Socket, count: number): Promise<Buffer> => {
70
+ if (count === 0) {
71
+ return Buffer.alloc(0);
72
+ }
73
+ const chunk: unknown = socket.read(count);
74
+ if (Buffer.isBuffer(chunk) && chunk.length === count) {
75
+ return chunk;
76
+ }
77
+ if (Buffer.isBuffer(chunk) || socket.readableEnded || socket.destroyed) {
78
+ throw new Error('the client ended its SOCKS message part way');
79
+ }
80
+ await stirred(socket);
81
+ return bytesOf(socket, count);
82
+ };
83
+
84
+ /** The client's greeting, answered: it must offer to go without authentication. */
85
+ const greet = async (client: Socket): Promise<void> => {
86
+ const [version, count = 0] = await bytesOf(client, 2);
87
+ if (version !== VERSION) {
88
+ throw new Error(`a SOCKS${String(version)} greeting, not SOCKS5`);
89
+ }
90
+ const methods = await bytesOf(client, count);
91
+ if (!methods.includes(NO_AUTHENTICATION)) {
92
+ client.end(Buffer.from([VERSION, NO_ACCEPTABLE_METHOD]));
93
+ throw new Error('the client offered no way without authentication');
94
+ }
95
+ client.write(Buffer.from([VERSION, NO_AUTHENTICATION]));
96
+ };
97
+
98
+ /** Sixteen bytes as an IPv6 address's eight groups, written out whole. */
99
+ const ipv6Of = (bytes: Buffer): string =>
100
+ Array.from({ length: 8 }, (_unused, group) => bytes.readUInt16BE(group * 2).toString(16)).join(
101
+ ':',
102
+ );
103
+
104
+ /** The address a request names, by its type; undefined for a type SOCKS5 lacks. */
105
+ const hostOf = async (client: Socket, type: number | undefined): Promise<string | undefined> => {
106
+ if (type === TYPE.ipv4) {
107
+ const octets = await bytesOf(client, 4);
108
+ return [...octets].join('.');
109
+ }
110
+ if (type === TYPE.ipv6) {
111
+ return ipv6Of(await bytesOf(client, 16));
112
+ }
113
+ if (type === TYPE.name) {
114
+ const [length = 0] = await bytesOf(client, 1);
115
+ const name = await bytesOf(client, length);
116
+ return name.toString('latin1');
117
+ }
118
+ return undefined;
119
+ };
120
+
121
+ /** The destination of the client's CONNECT; any other command, or address type, is refused. */
122
+ const destinationOf = async (client: Socket): Promise<Destination> => {
123
+ const [, command, , type] = await bytesOf(client, 4);
124
+ if (command !== CONNECT) {
125
+ client.end(reply(REPLY.commandUnsupported));
126
+ throw new Error(`SOCKS command ${String(command)}, which this server doesn't serve`);
127
+ }
128
+ const host = await hostOf(client, type);
129
+ if (host === undefined) {
130
+ client.end(reply(REPLY.addressUnsupported));
131
+ throw new Error(`SOCKS address type ${String(type)}`);
132
+ }
133
+ const port = await bytesOf(client, 2);
134
+ return { host, port: port.readUInt16BE(0) };
135
+ };
136
+
137
+ /** The reply for a connection that failed, by its error's code. */
138
+ const failureOf = (error: unknown): number => {
139
+ const code = codeOf(error);
140
+ if (code === 'ECONNREFUSED') {
141
+ return REPLY.refused;
142
+ }
143
+ if (code === 'ENETUNREACH') {
144
+ return REPLY.networkUnreachable;
145
+ }
146
+ return code === 'EHOSTUNREACH' || code === 'ENOTFOUND' ? REPLY.hostUnreachable : REPLY.failed;
147
+ };
148
+
149
+ /** Each side's end ends the other's writing, through the pipes, and a side closed or broken
150
+ * closes the other, so neither outlives its pair. */
151
+ const joined = (client: Socket, upstream: Socket): void => {
152
+ for (const [one, other] of [
153
+ [client, upstream],
154
+ [upstream, client],
155
+ ] as const) {
156
+ const end = (): void => {
157
+ other.destroy();
158
+ };
159
+ one.on('error', end).on('close', end);
160
+ }
161
+ client.pipe(upstream);
162
+ upstream.pipe(client);
163
+ };
164
+
165
+ /** Serves one client: its greeting, its CONNECT, then its bytes both ways. */
166
+ const serve = async (client: Socket): Promise<void> => {
167
+ await greet(client);
168
+ const destination = routeOf(await destinationOf(client));
169
+ const upstream = connect({ port: destination.port, host: destination.host, allowHalfOpen: true });
170
+ // A browser that gives up first leaves no connection waiting on the operating system's timeout.
171
+ client.once('close', () => {
172
+ upstream.destroy();
173
+ });
174
+ try {
175
+ await once(upstream, 'connect');
176
+ } catch (error) {
177
+ client.end(reply(failureOf(error)));
178
+ return;
179
+ }
180
+ client.write(reply(REPLY.succeeded));
181
+ joined(client, upstream);
182
+ };
183
+
184
+ export interface Socks extends Service {
185
+ /** The port it listens on, at the address it was given. */
186
+ readonly port: number;
187
+ }
188
+
189
+ /** A SOCKS5 server on `host`, serving only clients from `peer`, the browsers' own address: any
190
+ * other is closed unanswered, as it may reach the host's loopback through it. */
191
+ export const startSocks = async ({
192
+ host,
193
+ peer,
194
+ }: {
195
+ readonly host: string;
196
+ readonly peer: string;
197
+ }): Promise<Socks> => {
198
+ const open = new Set<Socket>();
199
+ // Half open, so a side that has said all it will still hears the other's answer.
200
+ const server = createServer({ allowHalfOpen: true }, (client) => {
201
+ if (client.remoteAddress !== peer) {
202
+ client.destroy();
203
+ return;
204
+ }
205
+ open.add(client);
206
+ // A client that resets part way is closed, never an error the host's process throws.
207
+ client
208
+ .on('error', () => {
209
+ client.destroy();
210
+ })
211
+ .once('close', () => {
212
+ open.delete(client);
213
+ });
214
+ // A broken greeting or a client gone part way ends that client alone.
215
+ serve(client).catch(() => {
216
+ client.destroy();
217
+ });
218
+ });
219
+ server.listen(0, host);
220
+ await once(server, 'listening');
221
+ const address = server.address();
222
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
223
+ return {
224
+ port,
225
+ stop: async () => {
226
+ for (const client of open) {
227
+ client.destroy();
228
+ }
229
+ server.close();
230
+ await once(server, 'close');
231
+ },
232
+ };
233
+ };