@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.
- package/README.md +7 -80
- package/dist/testing/src/config-coverage.d.ts.map +1 -1
- package/dist/testing/src/config-coverage.js +3 -2
- package/dist/testing/src/config-coverage.js.map +1 -1
- package/dist/testing/src/fixtures.d.ts +9 -3
- package/dist/testing/src/fixtures.d.ts.map +1 -1
- package/dist/testing/src/fixtures.js +98 -42
- package/dist/testing/src/fixtures.js.map +1 -1
- package/dist/testing/src/floors/node.js +11 -3
- package/dist/testing/src/floors/node.js.map +1 -1
- package/dist/testing/src/stack/browsers.d.ts +13 -3
- package/dist/testing/src/stack/browsers.d.ts.map +1 -1
- package/dist/testing/src/stack/browsers.js +117 -14
- package/dist/testing/src/stack/browsers.js.map +1 -1
- package/dist/testing/src/stack/containers.d.ts +6 -2
- package/dist/testing/src/stack/containers.d.ts.map +1 -1
- package/dist/testing/src/stack/containers.js +8 -1
- package/dist/testing/src/stack/containers.js.map +1 -1
- package/dist/testing/src/stack/control.d.ts +18 -3
- package/dist/testing/src/stack/control.d.ts.map +1 -1
- package/dist/testing/src/stack/control.js +50 -10
- package/dist/testing/src/stack/control.js.map +1 -1
- package/dist/testing/src/stack/gate-member.d.ts +10 -0
- package/dist/testing/src/stack/gate-member.d.ts.map +1 -1
- package/dist/testing/src/stack/gate-member.js +68 -7
- package/dist/testing/src/stack/gate-member.js.map +1 -1
- package/dist/testing/src/stack/gate-server.d.ts +37 -4
- package/dist/testing/src/stack/gate-server.d.ts.map +1 -1
- package/dist/testing/src/stack/gate-server.js +124 -12
- package/dist/testing/src/stack/gate-server.js.map +1 -1
- package/dist/testing/src/stack/hold.d.ts +24 -3
- package/dist/testing/src/stack/hold.d.ts.map +1 -1
- package/dist/testing/src/stack/hold.js +136 -11
- package/dist/testing/src/stack/hold.js.map +1 -1
- package/dist/testing/src/stack/host.d.ts.map +1 -1
- package/dist/testing/src/stack/host.js +26 -6
- package/dist/testing/src/stack/host.js.map +1 -1
- package/dist/testing/src/stack/lock.d.ts +15 -4
- package/dist/testing/src/stack/lock.d.ts.map +1 -1
- package/dist/testing/src/stack/lock.js +47 -18
- package/dist/testing/src/stack/lock.js.map +1 -1
- package/dist/testing/src/stack/preview-child.js +14 -3
- package/dist/testing/src/stack/preview-child.js.map +1 -1
- package/dist/testing/src/stack/runtime-apple.d.ts.map +1 -1
- package/dist/testing/src/stack/runtime-apple.js +16 -0
- package/dist/testing/src/stack/runtime-apple.js.map +1 -1
- package/dist/testing/src/stack/runtime-docker.d.ts +2 -1
- package/dist/testing/src/stack/runtime-docker.d.ts.map +1 -1
- package/dist/testing/src/stack/runtime-docker.js +29 -3
- package/dist/testing/src/stack/runtime-docker.js.map +1 -1
- package/dist/testing/src/stack/runtime.d.ts +10 -0
- package/dist/testing/src/stack/runtime.d.ts.map +1 -1
- package/dist/testing/src/stack/runtime.js +1 -0
- package/dist/testing/src/stack/runtime.js.map +1 -1
- package/dist/testing/src/stack/socks.d.ts +25 -0
- package/dist/testing/src/stack/socks.d.ts.map +1 -0
- package/dist/testing/src/stack/socks.js +195 -0
- package/dist/testing/src/stack/socks.js.map +1 -0
- package/dist/testing/src/stack/stall.d.ts +36 -0
- package/dist/testing/src/stack/stall.d.ts.map +1 -0
- package/dist/testing/src/stack/stall.js +93 -0
- package/dist/testing/src/stack/stall.js.map +1 -0
- package/package.json +4 -4
- package/testing/src/config-coverage.ts +3 -2
- package/testing/src/fixtures.ts +277 -197
- package/testing/src/floors/node.ts +19 -3
- package/testing/src/stack/browsers.ts +129 -15
- package/testing/src/stack/containers.ts +14 -2
- package/testing/src/stack/control.ts +62 -11
- package/testing/src/stack/gate-member.ts +90 -8
- package/testing/src/stack/gate-server.ts +166 -21
- package/testing/src/stack/hold.ts +163 -11
- package/testing/src/stack/host.ts +32 -9
- package/testing/src/stack/lock.ts +61 -21
- package/testing/src/stack/preview-child.ts +21 -4
- package/testing/src/stack/runtime-apple.ts +20 -1
- package/testing/src/stack/runtime-docker.ts +47 -3
- package/testing/src/stack/runtime.ts +11 -0
- package/testing/src/stack/socks.ts +233 -0
- 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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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) ?? {
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
+
};
|