@juxtapose/core 0.5.0 → 0.7.0
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/dist/browser/acquire.d.ts +5 -1
- package/dist/browser/acquire.js +14 -3
- package/dist/browser/acquire.js.map +1 -1
- package/dist/browser/browser.d.ts +1 -1
- package/dist/browser/daemon/client.d.ts +145 -0
- package/dist/browser/daemon/client.js +764 -0
- package/dist/browser/daemon/client.js.map +1 -0
- package/dist/browser/daemon/state.d.ts +99 -0
- package/dist/browser/daemon/state.js +297 -0
- package/dist/browser/daemon/state.js.map +1 -0
- package/dist/browser/daemon/supervisor-main.d.ts +1 -0
- package/dist/browser/daemon/supervisor-main.js +54 -0
- package/dist/browser/daemon/supervisor-main.js.map +1 -0
- package/dist/browser/daemon/supervisor.d.ts +37 -0
- package/dist/browser/daemon/supervisor.js +777 -0
- package/dist/browser/daemon/supervisor.js.map +1 -0
- package/dist/{capture/launch.js → browser/flags.js} +1 -1
- package/dist/browser/flags.js.map +1 -0
- package/dist/browser/index.d.ts +7 -2
- package/dist/browser/index.js +6 -2
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/launch.js +4 -15
- package/dist/browser/launch.js.map +1 -1
- package/dist/browser/page.d.ts +1 -1
- package/dist/browser/private-dir.d.ts +7 -0
- package/dist/browser/private-dir.js +24 -0
- package/dist/browser/private-dir.js.map +1 -0
- package/dist/browser/resolve.d.ts +6 -0
- package/dist/browser/resolve.js +11 -0
- package/dist/browser/resolve.js.map +1 -1
- package/dist/browser/transport.d.ts +12 -0
- package/dist/browser/transport.js +26 -1
- package/dist/browser/transport.js.map +1 -1
- package/dist/browser/types.d.ts +4 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +9 -4
- package/dist/types.js +0 -2
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/dist/capture/launch.js.map +0 -1
- /package/dist/{capture/launch.d.ts → browser/flags.d.ts} +0 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
// packages/core/src/browser/daemon/supervisor.ts
|
|
2
|
+
//
|
|
3
|
+
// Owns the pipe to one chrome-headless-shell and multiplexes local clients
|
|
4
|
+
// onto it over a 0600 Unix socket in a private directory. Chrome stays alive
|
|
5
|
+
// with zero targets as long as this process holds the pipe (verified
|
|
6
|
+
// 2026-10-13: ten client connect/disconnect cycles against one browser, and
|
|
7
|
+
// the spike's warm captures hashed byte-identical to fresh ones).
|
|
8
|
+
//
|
|
9
|
+
// Threat model: the socket is 0600 in a 0700 directory, so every client is
|
|
10
|
+
// the same uid. Isolation between clients is therefore not a privilege
|
|
11
|
+
// boundary (that user can ptrace Chrome anyway); it is a correctness
|
|
12
|
+
// guarantee: two concurrent jux runs sharing this browser must never affect
|
|
13
|
+
// each other's captures, by accident or by a stray command. It is still
|
|
14
|
+
// enforced strictly, as an allow-list, because "by accident" includes any
|
|
15
|
+
// future driver change.
|
|
16
|
+
//
|
|
17
|
+
// Clients share one browser, so the supervisor is the isolation boundary
|
|
18
|
+
// between them:
|
|
19
|
+
// - ids: every client id is rewritten to one from the connection's single
|
|
20
|
+
// allocator (preflight defect 9), so two clients' id 1 never collide with
|
|
21
|
+
// each other or with the supervisor's own sends;
|
|
22
|
+
// - ownership: a client owns the contexts it created, the targets created in
|
|
23
|
+
// them and the sessions attached to those (including child sessions
|
|
24
|
+
// auto-attached under one of its sessions, defect 12). On the shared
|
|
25
|
+
// browser session only an allow-list of commands passes, each naming ids
|
|
26
|
+
// the client owns; on its own page sessions only an allow-list of
|
|
27
|
+
// target-scoped domains passes, plus the few Target/Browser commands that
|
|
28
|
+
// stay within that page. Events go only to their owner; events about
|
|
29
|
+
// nothing a client owns are dropped;
|
|
30
|
+
// - cleanup: when a client's socket closes, the contexts it still owns are
|
|
31
|
+
// disposed, which is what Chrome's disposeOnDetach would do had the client
|
|
32
|
+
// held the pipe itself. A context the client disposed itself leaves its set
|
|
33
|
+
// at the request (defect 12), so it is not disposed twice.
|
|
34
|
+
import net from 'node:net';
|
|
35
|
+
import { chmod, lstat, rm, utimes } from 'node:fs/promises';
|
|
36
|
+
import { CHROME_VERSION } from '../pins.js';
|
|
37
|
+
import { launchChrome } from '../launch.js';
|
|
38
|
+
import { NullFramer } from '../transport.js';
|
|
39
|
+
import { clearState, ensureDaemonDirs, readState, recheckDaemonDirs, writeState, } from './state.js';
|
|
40
|
+
/**
|
|
41
|
+
* The state file's mtime is a heartbeat. A SIGSTOPped or event-loop-blocked
|
|
42
|
+
* daemon never idles out, and every client would pay a probe timeout forever;
|
|
43
|
+
* a client replaces a daemon whose heartbeat is over 10 s old (client.ts).
|
|
44
|
+
*/
|
|
45
|
+
const HEARTBEAT_MS = 1_000;
|
|
46
|
+
/** A client that has not said hello by then is not a jux client. */
|
|
47
|
+
const HELLO_TIMEOUT_MS = 5_000;
|
|
48
|
+
/** How long a connect probe may take before the listener counts as alive (a full backlog). */
|
|
49
|
+
const PROBE_TIMEOUT_MS = 2_000;
|
|
50
|
+
/**
|
|
51
|
+
* Frames queued for a client that stopped reading. A full-page screenshot is
|
|
52
|
+
* tens of MB of base64; a client this far behind is wedged, and holding more
|
|
53
|
+
* would let one stuck client exhaust the daemon every other client relies on.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_BACKLOG_CHARS = 256 * 1024 * 1024;
|
|
56
|
+
/**
|
|
57
|
+
* setTimeout takes at most 2^31-1 ms (about 35,791 minutes); past that Node
|
|
58
|
+
* prints a TimeoutOverflowWarning to the detached daemon's invisible stderr
|
|
59
|
+
* and fires after 1 ms, so JUX_BROWSER_IDLE_MINUTES=100000 ("keep it warm")
|
|
60
|
+
* stopped the daemon the moment it started (PR #102 review).
|
|
61
|
+
*/
|
|
62
|
+
const MAX_IDLE_MINUTES = 35_000;
|
|
63
|
+
const ID_KEYS = ['targetId', 'browserContextId', 'sessionId'];
|
|
64
|
+
/**
|
|
65
|
+
* Storage methods that take a browserContextId and, without one, act on the
|
|
66
|
+
* shared default context (every other Storage method ignores the id and acts
|
|
67
|
+
* on the session's own partition, or browser-wide). From any session they
|
|
68
|
+
* need an owned context id.
|
|
69
|
+
*/
|
|
70
|
+
const COOKIE_METHODS = [
|
|
71
|
+
'Storage.getCookies',
|
|
72
|
+
'Storage.setCookies',
|
|
73
|
+
'Storage.clearCookies',
|
|
74
|
+
];
|
|
75
|
+
/**
|
|
76
|
+
* The browser session is shared by every client, so it takes only these
|
|
77
|
+
* commands, each with the ids listed present and owned by the sender. Anything
|
|
78
|
+
* else there (Fetch.enable, Tracing.start, Storage.* on the default context,
|
|
79
|
+
* setDiscoverTargets, ...) would see or change other clients' pages. The driver
|
|
80
|
+
* sends Browser.getVersion, Target.createBrowserContext/createTarget/
|
|
81
|
+
* attachToTarget/closeTarget/disposeBrowserContext and Storage.clearCookies
|
|
82
|
+
* here (browser.ts); the real-capture test proves none of them is refused.
|
|
83
|
+
*
|
|
84
|
+
* Storage is listed method by method, not as a prefix: with a decoy owned
|
|
85
|
+
* browserContextId, a prefix rule passed clearDataForOrigin (acts on the
|
|
86
|
+
* default partition) and setAttributionReportingLocalTestingMode (flips a
|
|
87
|
+
* browser-wide mode), neither of which looks at the id (PR #102 review).
|
|
88
|
+
*/
|
|
89
|
+
const BROWSER_SESSION_ALLOW = {
|
|
90
|
+
'Browser.getVersion': [],
|
|
91
|
+
'Target.createBrowserContext': [],
|
|
92
|
+
'Target.getBrowserContexts': [],
|
|
93
|
+
'Target.getTargets': [], // reply filtered to the sender's targets
|
|
94
|
+
'Target.disposeBrowserContext': ['browserContextId'],
|
|
95
|
+
'Target.createTarget': ['browserContextId'],
|
|
96
|
+
'Target.closeTarget': ['targetId'],
|
|
97
|
+
'Target.attachToTarget': ['targetId'],
|
|
98
|
+
'Target.detachFromTarget': [], // sessionId or targetId, checked below
|
|
99
|
+
'Target.getTargetInfo': ['targetId'],
|
|
100
|
+
'Target.activateTarget': ['targetId'],
|
|
101
|
+
'Browser.getWindowForTarget': ['targetId'],
|
|
102
|
+
'Browser.setDownloadBehavior': ['browserContextId'],
|
|
103
|
+
'Browser.grantPermissions': ['browserContextId'],
|
|
104
|
+
...Object.fromEntries(COOKIE_METHODS.map((m) => [m, ['browserContextId']])),
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Domains whose commands on a page session act on that page (or its own
|
|
108
|
+
* context's partition) only. An allow-list, not a deny-list: some domains are
|
|
109
|
+
* browser singletons whichever session sends them. Tracing.start on A's page
|
|
110
|
+
* session made B's fail with "Tracing has already been started (possibly in
|
|
111
|
+
* another tab)" and put B's renderer activity in A's trace (PR #102 review);
|
|
112
|
+
* SystemInfo and Memory are the same kind. An unknown domain is refused until
|
|
113
|
+
* someone checks which kind it is.
|
|
114
|
+
*
|
|
115
|
+
* The driver sends Page, Runtime, Network, Log, Emulation, Storage
|
|
116
|
+
* (clearDataForOrigin) and DOMSnapshot on page sessions (page.ts,
|
|
117
|
+
* capture/structure.ts); the rest are the standard target-scoped domains.
|
|
118
|
+
*
|
|
119
|
+
* Storage passes method by method (PAGE_SESSION_STORAGE); some methods
|
|
120
|
+
* inside an allowed domain reach past the page (PAGE_SESSION_DENY).
|
|
121
|
+
*/
|
|
122
|
+
/**
|
|
123
|
+
* Storage on a page session: only methods that act on this target's own
|
|
124
|
+
* storage partition and origin. clearDataForOrigin is jux's own reset
|
|
125
|
+
* (page.ts clearOrigin); refusing it (the PR #102 rule) silently broke
|
|
126
|
+
* resetBetweenPages, so a page read localStorage the previous page wrote and
|
|
127
|
+
* hashed 0bc8f889… through the daemon against 89456235… from a fresh launch.
|
|
128
|
+
* Left out on purpose: trust tokens, interest groups, shared storage and
|
|
129
|
+
* its budget, bucket tracking, bounce tracking, related website sets and
|
|
130
|
+
* k-anonymity (browser- or profile-wide state), and overrideQuotaForOrigin
|
|
131
|
+
* (not verified to stay in one partition). The cookie methods go by
|
|
132
|
+
* COOKIE_METHODS instead. Method list from the pinned Chrome's /json/protocol.
|
|
133
|
+
*/
|
|
134
|
+
const PAGE_SESSION_STORAGE = new Set([
|
|
135
|
+
'Storage.getStorageKey',
|
|
136
|
+
'Storage.getStorageKeyForFrame',
|
|
137
|
+
'Storage.clearDataForOrigin',
|
|
138
|
+
'Storage.clearDataForStorageKey',
|
|
139
|
+
'Storage.getUsageAndQuota',
|
|
140
|
+
'Storage.trackCacheStorageForOrigin',
|
|
141
|
+
'Storage.trackCacheStorageForStorageKey',
|
|
142
|
+
'Storage.trackIndexedDBForOrigin',
|
|
143
|
+
'Storage.trackIndexedDBForStorageKey',
|
|
144
|
+
'Storage.untrackCacheStorageForOrigin',
|
|
145
|
+
'Storage.untrackCacheStorageForStorageKey',
|
|
146
|
+
'Storage.untrackIndexedDBForOrigin',
|
|
147
|
+
'Storage.untrackIndexedDBForStorageKey',
|
|
148
|
+
]);
|
|
149
|
+
/**
|
|
150
|
+
* Methods of allowed page-session domains whose names or descriptions say
|
|
151
|
+
* they reach past the page ("Clears browser cache", third-party cookie
|
|
152
|
+
* controls, "whether all certificate errors should be ignored"). Found by
|
|
153
|
+
* scanning the pinned Chrome's /json/protocol for those domains; refused
|
|
154
|
+
* conservatively since jux never sends them and one client clearing the
|
|
155
|
+
* cache or flipping cookie policy would change another's next load.
|
|
156
|
+
*/
|
|
157
|
+
const PAGE_SESSION_DENY = new Set([
|
|
158
|
+
'Network.clearBrowserCache',
|
|
159
|
+
'Network.clearBrowserCookies',
|
|
160
|
+
'Network.setCookieControls',
|
|
161
|
+
'Security.setIgnoreCertificateErrors',
|
|
162
|
+
]);
|
|
163
|
+
const PAGE_SESSION_DOMAINS = new Set([
|
|
164
|
+
'Accessibility',
|
|
165
|
+
'Animation',
|
|
166
|
+
'Audits',
|
|
167
|
+
'CacheStorage',
|
|
168
|
+
'Console',
|
|
169
|
+
'CSS',
|
|
170
|
+
'Debugger',
|
|
171
|
+
'DOM',
|
|
172
|
+
'DOMDebugger',
|
|
173
|
+
'DOMSnapshot',
|
|
174
|
+
'DOMStorage',
|
|
175
|
+
'Emulation',
|
|
176
|
+
'Fetch',
|
|
177
|
+
'HeapProfiler',
|
|
178
|
+
'IndexedDB',
|
|
179
|
+
'Input',
|
|
180
|
+
'Inspector',
|
|
181
|
+
'IO',
|
|
182
|
+
'LayerTree',
|
|
183
|
+
'Log',
|
|
184
|
+
'Network',
|
|
185
|
+
'Overlay',
|
|
186
|
+
'Page',
|
|
187
|
+
'Performance',
|
|
188
|
+
'Profiler',
|
|
189
|
+
'Runtime',
|
|
190
|
+
'Security',
|
|
191
|
+
'Storage',
|
|
192
|
+
]);
|
|
193
|
+
/**
|
|
194
|
+
* Target.* and Browser.* reach the browser from any session (setDiscoverTargets
|
|
195
|
+
* there reports every target; Browser.close closes the browser), so on a page
|
|
196
|
+
* session they pass only from this list, with owned ids as above.
|
|
197
|
+
*/
|
|
198
|
+
const PAGE_SESSION_ALLOW = {
|
|
199
|
+
'Browser.getVersion': [],
|
|
200
|
+
'Target.setAutoAttach': [], // children of this page only
|
|
201
|
+
'Target.autoAttachRelated': ['targetId'],
|
|
202
|
+
'Target.getTargets': [],
|
|
203
|
+
'Target.getTargetInfo': [],
|
|
204
|
+
'Target.createTarget': ['browserContextId'],
|
|
205
|
+
'Target.closeTarget': ['targetId'],
|
|
206
|
+
'Target.attachToTarget': ['targetId'],
|
|
207
|
+
'Target.detachFromTarget': [],
|
|
208
|
+
'Target.activateTarget': ['targetId'],
|
|
209
|
+
};
|
|
210
|
+
/** What an unlisted command would reach, for the refusal message. */
|
|
211
|
+
const browserWideWhy = (method) => method.startsWith('Storage.')
|
|
212
|
+
? 'the default browser context'
|
|
213
|
+
: 'the browser session (it is shared by every client of the warm-browser daemon)';
|
|
214
|
+
/**
|
|
215
|
+
* Another daemon owns the socket (Ruling 21: the stale-lock reclaim race is
|
|
216
|
+
* backstopped here). The loser has closed its own Chrome and touched none of
|
|
217
|
+
* the winner's files; its process should exit 0 and the client connect to
|
|
218
|
+
* the winner.
|
|
219
|
+
*/
|
|
220
|
+
export class SupervisorLostRace extends Error {
|
|
221
|
+
chromePid;
|
|
222
|
+
constructor(
|
|
223
|
+
/** The loser's Chrome, already closed; undefined when none was launched. */
|
|
224
|
+
chromePid) {
|
|
225
|
+
super('jux: another warm-browser daemon already serves this socket');
|
|
226
|
+
this.chromePid = chromePid;
|
|
227
|
+
this.name = 'SupervisorLostRace';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
231
|
+
/** True when something accepts connections on `socketPath`. */
|
|
232
|
+
async function socketIsLive(socketPath) {
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
const s = net.connect(socketPath);
|
|
235
|
+
const done = (live) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
s.destroy();
|
|
238
|
+
resolve(live);
|
|
239
|
+
};
|
|
240
|
+
const timer = setTimeout(() => done(true), PROBE_TIMEOUT_MS);
|
|
241
|
+
s.once('connect', () => done(true));
|
|
242
|
+
s.once('error', () => done(false));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
export async function startSupervisor(opts) {
|
|
246
|
+
const { paths } = opts;
|
|
247
|
+
if (!Number.isFinite(opts.idleMinutes) || opts.idleMinutes <= 0)
|
|
248
|
+
throw new Error(`jux: idleMinutes must be a positive number, got ${opts.idleMinutes}`);
|
|
249
|
+
const idleMinutes = Math.min(opts.idleMinutes, MAX_IDLE_MINUTES);
|
|
250
|
+
const dirs = await ensureDaemonDirs(paths);
|
|
251
|
+
// Cheap first check: a live daemon means there is nothing to launch.
|
|
252
|
+
if (await socketIsLive(paths.socket))
|
|
253
|
+
throw new SupervisorLostRace();
|
|
254
|
+
const chrome = await launchChrome({
|
|
255
|
+
executablePath: opts.executablePath,
|
|
256
|
+
args: opts.args,
|
|
257
|
+
});
|
|
258
|
+
let product;
|
|
259
|
+
try {
|
|
260
|
+
({ product } = await chrome.connection.send('Browser.getVersion'));
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
await chrome.close();
|
|
264
|
+
throw e;
|
|
265
|
+
}
|
|
266
|
+
const clients = new Set();
|
|
267
|
+
const owners = new Map();
|
|
268
|
+
const contextOwner = new Map();
|
|
269
|
+
const targetOwner = new Map();
|
|
270
|
+
const sessionOwner = new Map();
|
|
271
|
+
let idleTimer = null;
|
|
272
|
+
// Assigned once serving; stop() may run before that (a failed writeState).
|
|
273
|
+
let heartbeat = undefined;
|
|
274
|
+
let stopping = null;
|
|
275
|
+
let socketIno = -1;
|
|
276
|
+
const claimTarget = (c, targetId) => {
|
|
277
|
+
if (!targetId || c.gone)
|
|
278
|
+
return;
|
|
279
|
+
// Never re-assign a target: ownership comes from creating it (fix round 1:
|
|
280
|
+
// a discovery event on B's session must not hand B A's target).
|
|
281
|
+
if (targetOwner.has(targetId) && targetOwner.get(targetId) !== c)
|
|
282
|
+
return;
|
|
283
|
+
targetOwner.set(targetId, c);
|
|
284
|
+
c.targets.add(targetId);
|
|
285
|
+
};
|
|
286
|
+
const claimSession = (c, sessionId) => {
|
|
287
|
+
if (!sessionId || c.gone)
|
|
288
|
+
return;
|
|
289
|
+
sessionOwner.set(sessionId, c);
|
|
290
|
+
c.sessions.add(sessionId);
|
|
291
|
+
};
|
|
292
|
+
const claimContext = (c, id) => {
|
|
293
|
+
contextOwner.set(id, c);
|
|
294
|
+
c.contexts.add(id);
|
|
295
|
+
};
|
|
296
|
+
// Honors write() back-pressure: once the socket says stop, frames wait in
|
|
297
|
+
// our queue (bounded) until 'drain' instead of piling into Node's buffer.
|
|
298
|
+
const deliver = (c, obj) => {
|
|
299
|
+
if (c.socket.destroyed)
|
|
300
|
+
return;
|
|
301
|
+
const data = JSON.stringify(obj) + '\0';
|
|
302
|
+
if (c.blocked) {
|
|
303
|
+
c.backlog.push(data);
|
|
304
|
+
c.backlogChars += data.length;
|
|
305
|
+
if (c.backlogChars > MAX_BACKLOG_CHARS)
|
|
306
|
+
c.socket.destroy();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
c.blocked = !c.socket.write(data);
|
|
310
|
+
};
|
|
311
|
+
const flush = (c) => {
|
|
312
|
+
c.blocked = false;
|
|
313
|
+
while (c.backlog.length > 0 && !c.blocked && !c.socket.destroyed) {
|
|
314
|
+
const data = c.backlog.shift();
|
|
315
|
+
c.backlogChars -= data.length;
|
|
316
|
+
c.blocked = !c.socket.write(data);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const disposeContext = (id) => chrome.connection
|
|
320
|
+
.send('Target.disposeBrowserContext', { browserContextId: id })
|
|
321
|
+
.catch(() => { });
|
|
322
|
+
const onReply = (m, o) => {
|
|
323
|
+
const c = o.client;
|
|
324
|
+
const result = (m.result ?? {});
|
|
325
|
+
if (!m.error) {
|
|
326
|
+
if (o.method === 'Target.createBrowserContext') {
|
|
327
|
+
const id = str(result['browserContextId']);
|
|
328
|
+
// Created for a client that has since gone: nobody else will dispose it.
|
|
329
|
+
if (id && c.gone)
|
|
330
|
+
void disposeContext(id);
|
|
331
|
+
else if (id)
|
|
332
|
+
claimContext(c, id);
|
|
333
|
+
}
|
|
334
|
+
else if (o.method === 'Target.createTarget') {
|
|
335
|
+
claimTarget(c, str(result['targetId']));
|
|
336
|
+
}
|
|
337
|
+
else if (o.method === 'Target.attachToTarget') {
|
|
338
|
+
claimSession(c, str(result['sessionId']));
|
|
339
|
+
}
|
|
340
|
+
else if (o.method === 'Target.getTargets' &&
|
|
341
|
+
Array.isArray(result['targetInfos'])) {
|
|
342
|
+
const mine = result['targetInfos'].filter((t) => targetOwner.get(str(t['targetId']) ?? '') === c ||
|
|
343
|
+
contextOwner.get(str(t['browserContextId']) ?? '') === c);
|
|
344
|
+
m = { ...m, result: { ...result, targetInfos: mine } };
|
|
345
|
+
}
|
|
346
|
+
else if (o.method === 'Target.getBrowserContexts' &&
|
|
347
|
+
Array.isArray(result['browserContextIds'])) {
|
|
348
|
+
// Same as getTargets: another client's context ids are not this one's
|
|
349
|
+
// to see. The default context is nobody's, so its id goes too.
|
|
350
|
+
const mine = result['browserContextIds'].filter((id) => contextOwner.get(str(id) ?? '') === c);
|
|
351
|
+
const rest = { ...result, browserContextIds: mine };
|
|
352
|
+
delete rest['defaultBrowserContextId'];
|
|
353
|
+
m = { ...m, result: rest };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else if (o.method === 'Target.disposeBrowserContext' && !c.gone) {
|
|
357
|
+
// The dispose failed, so the context still exists: dispose it on drop after all.
|
|
358
|
+
const id = o.browserContextId;
|
|
359
|
+
if (id && contextOwner.get(id) === c)
|
|
360
|
+
c.contexts.add(id);
|
|
361
|
+
}
|
|
362
|
+
deliver(c, { ...m, id: o.clientId });
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* The client a browser-session Target.* event is about: the target's
|
|
366
|
+
* context when the event carries one (no fallback: a context nobody owns
|
|
367
|
+
* means nobody may hear it), else the session or target it names.
|
|
368
|
+
*/
|
|
369
|
+
const ownerOfBrowserEvent = (p) => {
|
|
370
|
+
const ctx = str((p['targetInfo'] ?? {})['browserContextId']);
|
|
371
|
+
if (ctx !== undefined)
|
|
372
|
+
return contextOwner.get(ctx);
|
|
373
|
+
const session = str(p['sessionId']);
|
|
374
|
+
if (session !== undefined)
|
|
375
|
+
return sessionOwner.get(session);
|
|
376
|
+
const target = str(p['targetId']);
|
|
377
|
+
if (target !== undefined)
|
|
378
|
+
return targetOwner.get(target);
|
|
379
|
+
const context = str(p['browserContextId']);
|
|
380
|
+
return context === undefined ? undefined : contextOwner.get(context);
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Chrome never answers a command in flight on a session whose target died
|
|
384
|
+
* (probe: Runtime.evaluate 'while(1){}', then dispose the context). Its
|
|
385
|
+
* owner entry would stay forever, one per hung story, in a daemon that
|
|
386
|
+
* lives for hours, so the entry goes. Nothing is answered in Chrome's
|
|
387
|
+
* place: a synthetic error reply reached the client before the detach
|
|
388
|
+
* event, so the page saw CdpError where a fresh launch gives
|
|
389
|
+
* TargetClosedError, and the pool, which rebuilds a worker only on
|
|
390
|
+
* TargetClosedError or PageCrashedError, failed the next story on that
|
|
391
|
+
* worker too (PR #102 re-review N1). The client's own detach handling
|
|
392
|
+
* rejects page work; any other sender times out, as against a fresh Chrome.
|
|
393
|
+
*/
|
|
394
|
+
const purgeSession = (sessionId) => {
|
|
395
|
+
for (const [id, o] of owners)
|
|
396
|
+
if (o.sessionId === sessionId)
|
|
397
|
+
owners.delete(id);
|
|
398
|
+
};
|
|
399
|
+
const onEvent = (m) => {
|
|
400
|
+
const p = (m.params ?? {});
|
|
401
|
+
// Before any owner check: entries of a client that is gone are purged
|
|
402
|
+
// too (dropClient does that as well, but this covers a late detach).
|
|
403
|
+
if (m.method === 'Target.detachedFromTarget') {
|
|
404
|
+
const s = str(p['sessionId']);
|
|
405
|
+
if (s !== undefined)
|
|
406
|
+
purgeSession(s);
|
|
407
|
+
}
|
|
408
|
+
let owner;
|
|
409
|
+
if (m.sessionId !== undefined)
|
|
410
|
+
owner = sessionOwner.get(m.sessionId);
|
|
411
|
+
// Browser-session events: only Target.* ones about a client's own
|
|
412
|
+
// targets. Nothing else there is any one client's to hear.
|
|
413
|
+
else if (m.method.startsWith('Target.'))
|
|
414
|
+
owner = ownerOfBrowserEvent(p);
|
|
415
|
+
if (!owner)
|
|
416
|
+
return;
|
|
417
|
+
const info = (p['targetInfo'] ?? {});
|
|
418
|
+
const infoCtx = str(info['browserContextId']);
|
|
419
|
+
// A target event on the owner's session about a target in someone else's
|
|
420
|
+
// context is not the owner's to hear (defense in depth: discovery is refused).
|
|
421
|
+
if (infoCtx !== undefined && contextOwner.get(infoCtx) !== owner)
|
|
422
|
+
return;
|
|
423
|
+
const infoTarget = str(info['targetId']);
|
|
424
|
+
if (m.method === 'Target.attachedToTarget') {
|
|
425
|
+
// A child auto-attached under the owner's session, or a target in its context.
|
|
426
|
+
const held = infoTarget === undefined ? undefined : targetOwner.get(infoTarget);
|
|
427
|
+
if (held && held !== owner)
|
|
428
|
+
return;
|
|
429
|
+
claimSession(owner, str(p['sessionId']));
|
|
430
|
+
if (infoCtx !== undefined)
|
|
431
|
+
claimTarget(owner, infoTarget);
|
|
432
|
+
}
|
|
433
|
+
else if (m.method === 'Target.targetCreated' && infoCtx !== undefined) {
|
|
434
|
+
claimTarget(owner, infoTarget);
|
|
435
|
+
}
|
|
436
|
+
deliver(owner, m);
|
|
437
|
+
if (m.method === 'Target.detachedFromTarget') {
|
|
438
|
+
const s = str(p['sessionId']);
|
|
439
|
+
if (s && sessionOwner.get(s) === owner) {
|
|
440
|
+
sessionOwner.delete(s);
|
|
441
|
+
owner.sessions.delete(s);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else if (m.method === 'Target.targetDestroyed') {
|
|
445
|
+
const t = str(p['targetId']);
|
|
446
|
+
if (t && targetOwner.get(t) === owner) {
|
|
447
|
+
targetOwner.delete(t);
|
|
448
|
+
owner.targets.delete(t);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
chrome.connection.onRaw = (m) => {
|
|
453
|
+
if (m.id !== undefined) {
|
|
454
|
+
const o = owners.get(m.id);
|
|
455
|
+
if (!o)
|
|
456
|
+
return; // the supervisor's own send; CdpConnection resolves it
|
|
457
|
+
owners.delete(m.id);
|
|
458
|
+
onReply(m, o);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (m.method)
|
|
462
|
+
onEvent(m);
|
|
463
|
+
};
|
|
464
|
+
/** Why `c` may not send this, or null. Every refusal names the thing it does not own. */
|
|
465
|
+
const refusal = (c, method, params, sessionId) => {
|
|
466
|
+
const no = (what) => `jux: ${method} refused: ${what} is not this client's`;
|
|
467
|
+
const byKey = {
|
|
468
|
+
targetId: targetOwner,
|
|
469
|
+
browserContextId: contextOwner,
|
|
470
|
+
sessionId: sessionOwner,
|
|
471
|
+
};
|
|
472
|
+
let required;
|
|
473
|
+
if (sessionId === undefined) {
|
|
474
|
+
required = BROWSER_SESSION_ALLOW[method];
|
|
475
|
+
if (!required)
|
|
476
|
+
return no(browserWideWhy(method));
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
if (sessionOwner.get(sessionId) !== c)
|
|
480
|
+
return no(`session ${sessionId}`);
|
|
481
|
+
const domain = method.slice(0, Math.max(0, method.indexOf('.')));
|
|
482
|
+
if (domain === 'Target' || domain === 'Browser') {
|
|
483
|
+
required = PAGE_SESSION_ALLOW[method];
|
|
484
|
+
if (!required)
|
|
485
|
+
return no(browserWideWhy(method));
|
|
486
|
+
}
|
|
487
|
+
else if (COOKIE_METHODS.includes(method)) {
|
|
488
|
+
required = ['browserContextId'];
|
|
489
|
+
}
|
|
490
|
+
else if (domain === 'Storage'
|
|
491
|
+
? PAGE_SESSION_STORAGE.has(method)
|
|
492
|
+
: PAGE_SESSION_DOMAINS.has(domain) && !PAGE_SESSION_DENY.has(method)) {
|
|
493
|
+
required = [];
|
|
494
|
+
}
|
|
495
|
+
else
|
|
496
|
+
return no(`the browser (${domain || method} is not scoped to one page)`);
|
|
497
|
+
}
|
|
498
|
+
if (method === 'Target.createTarget' && params['browserContextId'] === undefined)
|
|
499
|
+
return `${no('the default browser context')}; create one with Target.createBrowserContext`;
|
|
500
|
+
if (method === 'Target.detachFromTarget' &&
|
|
501
|
+
params['sessionId'] === undefined &&
|
|
502
|
+
params['targetId'] === undefined)
|
|
503
|
+
return no('an unnamed session');
|
|
504
|
+
for (const key of required)
|
|
505
|
+
if (params[key] === undefined)
|
|
506
|
+
return no(key === 'browserContextId'
|
|
507
|
+
? 'the default browser context'
|
|
508
|
+
: `an unnamed ${key}`);
|
|
509
|
+
// Every id a command names, required or not, must be an owned string.
|
|
510
|
+
for (const key of ID_KEYS) {
|
|
511
|
+
if (!(key in params))
|
|
512
|
+
continue;
|
|
513
|
+
const id = params[key];
|
|
514
|
+
if (typeof id !== 'string' || byKey[key].get(id) !== c)
|
|
515
|
+
return no(`${key} ${JSON.stringify(id)}`);
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
};
|
|
519
|
+
const onFrame = (c, frame) => {
|
|
520
|
+
let parsed;
|
|
521
|
+
try {
|
|
522
|
+
parsed = JSON.parse(frame);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
parsed = undefined;
|
|
526
|
+
}
|
|
527
|
+
// `null`, arrays and scalars parse fine and crashed the daemon on a
|
|
528
|
+
// property read (fix round 1): only plain objects get past here.
|
|
529
|
+
const m = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
530
|
+
? parsed
|
|
531
|
+
: null;
|
|
532
|
+
if (!c.ready) {
|
|
533
|
+
clearTimeout(c.helloTimer);
|
|
534
|
+
if (m?.['jux'] !== 'hello') {
|
|
535
|
+
c.socket.destroy();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (m['version'] !== CHROME_VERSION) {
|
|
539
|
+
deliver(c, {
|
|
540
|
+
method: 'jux.refused',
|
|
541
|
+
params: { reason: `version ${String(m['version'])} != ${CHROME_VERSION}` },
|
|
542
|
+
});
|
|
543
|
+
c.socket.end();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
c.ready = true;
|
|
547
|
+
deliver(c, { method: 'jux.ready', params: { product } });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// After hello a frame without a usable id has nobody to answer: ignore it.
|
|
551
|
+
const clientId = m?.['id'];
|
|
552
|
+
if (!m || typeof clientId !== 'number' || !Number.isSafeInteger(clientId))
|
|
553
|
+
return;
|
|
554
|
+
const method = m['method'];
|
|
555
|
+
const sessionId = m['sessionId'];
|
|
556
|
+
const rawParams = m['params'] ?? {};
|
|
557
|
+
const fail = (message) => deliver(c, {
|
|
558
|
+
id: clientId,
|
|
559
|
+
error: { code: -32000, message },
|
|
560
|
+
...(typeof sessionId === 'string' ? { sessionId } : {}),
|
|
561
|
+
});
|
|
562
|
+
if (typeof method !== 'string' ||
|
|
563
|
+
(sessionId !== undefined && typeof sessionId !== 'string') ||
|
|
564
|
+
typeof rawParams !== 'object' ||
|
|
565
|
+
rawParams === null ||
|
|
566
|
+
Array.isArray(rawParams)) {
|
|
567
|
+
fail('jux: malformed command: needs a string method, an object params and a string sessionId if any');
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const params = rawParams;
|
|
571
|
+
const why = refusal(c, method, params, sessionId);
|
|
572
|
+
if (why) {
|
|
573
|
+
fail(why);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (method === 'Target.disposeBrowserContext') {
|
|
577
|
+
// The client is disposing it: do not dispose again on drop.
|
|
578
|
+
const ctx = str(params['browserContextId']);
|
|
579
|
+
if (ctx !== undefined)
|
|
580
|
+
c.contexts.delete(ctx);
|
|
581
|
+
}
|
|
582
|
+
const id = chrome.connection.allocateId();
|
|
583
|
+
owners.set(id, {
|
|
584
|
+
client: c,
|
|
585
|
+
clientId,
|
|
586
|
+
method,
|
|
587
|
+
...(method === 'Target.disposeBrowserContext'
|
|
588
|
+
? { browserContextId: str(params['browserContextId']) }
|
|
589
|
+
: {}),
|
|
590
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
591
|
+
});
|
|
592
|
+
try {
|
|
593
|
+
chrome.connection.sendRaw({
|
|
594
|
+
id,
|
|
595
|
+
method,
|
|
596
|
+
params,
|
|
597
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
owners.delete(id);
|
|
602
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
const scheduleIdle = () => {
|
|
606
|
+
if (idleTimer)
|
|
607
|
+
clearTimeout(idleTimer);
|
|
608
|
+
idleTimer = null;
|
|
609
|
+
if (clients.size > 0 || stopping)
|
|
610
|
+
return;
|
|
611
|
+
idleTimer = setTimeout(() => void stop(), idleMinutes * 60_000);
|
|
612
|
+
idleTimer.unref();
|
|
613
|
+
};
|
|
614
|
+
const dropClient = async (c) => {
|
|
615
|
+
if (!clients.delete(c))
|
|
616
|
+
return;
|
|
617
|
+
c.gone = true;
|
|
618
|
+
clearTimeout(c.helloTimer);
|
|
619
|
+
c.backlog = [];
|
|
620
|
+
// Chrome may never answer this client's in-flight commands (its contexts
|
|
621
|
+
// are about to be disposed); nobody is left to hear them anyway.
|
|
622
|
+
for (const [id, o] of owners)
|
|
623
|
+
if (o.client === c)
|
|
624
|
+
owners.delete(id);
|
|
625
|
+
for (const s of c.sessions)
|
|
626
|
+
if (sessionOwner.get(s) === c)
|
|
627
|
+
sessionOwner.delete(s);
|
|
628
|
+
for (const t of c.targets)
|
|
629
|
+
if (targetOwner.get(t) === c)
|
|
630
|
+
targetOwner.delete(t);
|
|
631
|
+
for (const [id, owner] of contextOwner)
|
|
632
|
+
if (owner === c)
|
|
633
|
+
contextOwner.delete(id);
|
|
634
|
+
scheduleIdle();
|
|
635
|
+
if (!chrome.connection.closed)
|
|
636
|
+
await Promise.all([...c.contexts].map(disposeContext));
|
|
637
|
+
};
|
|
638
|
+
const server = net.createServer((socket) => {
|
|
639
|
+
const c = {
|
|
640
|
+
socket,
|
|
641
|
+
framer: new NullFramer(opts.maxFrameChars ?? MAX_BACKLOG_CHARS),
|
|
642
|
+
ready: false,
|
|
643
|
+
gone: false,
|
|
644
|
+
contexts: new Set(),
|
|
645
|
+
targets: new Set(),
|
|
646
|
+
sessions: new Set(),
|
|
647
|
+
blocked: false,
|
|
648
|
+
backlog: [],
|
|
649
|
+
backlogChars: 0,
|
|
650
|
+
helloTimer: setTimeout(() => socket.destroy(), HELLO_TIMEOUT_MS),
|
|
651
|
+
};
|
|
652
|
+
c.helloTimer.unref();
|
|
653
|
+
if (stopping) {
|
|
654
|
+
socket.destroy();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
clients.add(c);
|
|
658
|
+
scheduleIdle();
|
|
659
|
+
socket.setNoDelay(true);
|
|
660
|
+
socket.on('data', (chunk) => {
|
|
661
|
+
// A frame past the cap (FrameTooLargeError) or any bug in handling
|
|
662
|
+
// one client's frame costs that client its connection, never the
|
|
663
|
+
// daemon every other client shares.
|
|
664
|
+
try {
|
|
665
|
+
c.framer.push(chunk, (f) => onFrame(c, f));
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
socket.destroy();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
socket.on('drain', () => flush(c));
|
|
672
|
+
socket.on('close', () => void dropClient(c));
|
|
673
|
+
socket.on('error', () => socket.destroy());
|
|
674
|
+
});
|
|
675
|
+
async function stop() {
|
|
676
|
+
return (stopping ??= (async () => {
|
|
677
|
+
if (idleTimer)
|
|
678
|
+
clearTimeout(idleTimer);
|
|
679
|
+
clearInterval(heartbeat);
|
|
680
|
+
for (const c of clients)
|
|
681
|
+
c.socket.destroy();
|
|
682
|
+
await new Promise((r) => server.close(() => r()));
|
|
683
|
+
// Only our own files: a daemon that started after we stopped serving
|
|
684
|
+
// may already have replaced them.
|
|
685
|
+
const s = await readState(paths);
|
|
686
|
+
if (s?.pid === process.pid && s.startedAt === state.startedAt)
|
|
687
|
+
await clearState(paths);
|
|
688
|
+
const st = await lstat(paths.socket).catch(() => null);
|
|
689
|
+
if (st?.ino === socketIno)
|
|
690
|
+
await rm(paths.socket, { force: true });
|
|
691
|
+
await chrome.close();
|
|
692
|
+
opts.onExit?.();
|
|
693
|
+
})());
|
|
694
|
+
}
|
|
695
|
+
// Ruling 21 and defect 11: a crashed daemon leaves its socket file behind
|
|
696
|
+
// (listen would fail with EADDRINUSE forever), but a live one owns it. Only
|
|
697
|
+
// a socket nobody answers on is unlinked; a live one means we lost the race.
|
|
698
|
+
const lose = async () => {
|
|
699
|
+
await chrome.close();
|
|
700
|
+
throw new SupervisorLostRace(chrome.pid);
|
|
701
|
+
};
|
|
702
|
+
const abort = async (e) => {
|
|
703
|
+
server.close();
|
|
704
|
+
await chrome.close();
|
|
705
|
+
throw e;
|
|
706
|
+
};
|
|
707
|
+
await recheckDaemonDirs(paths, dirs).catch(abort);
|
|
708
|
+
if (await socketIsLive(paths.socket))
|
|
709
|
+
await lose();
|
|
710
|
+
await rm(paths.socket, { force: true }).catch(abort);
|
|
711
|
+
await opts.beforeListen?.();
|
|
712
|
+
// Directly before listen: the directory we verified is still the one we bind in.
|
|
713
|
+
await recheckDaemonDirs(paths, dirs).catch(abort);
|
|
714
|
+
const listenError = await new Promise((resolve) => {
|
|
715
|
+
server.once('error', resolve);
|
|
716
|
+
server.listen(paths.socket, () => resolve(null));
|
|
717
|
+
});
|
|
718
|
+
if (listenError?.code === 'EADDRINUSE')
|
|
719
|
+
await lose();
|
|
720
|
+
if (listenError)
|
|
721
|
+
await abort(listenError);
|
|
722
|
+
// The directory is 0700 already; 0600 on the socket is the second fence.
|
|
723
|
+
try {
|
|
724
|
+
await chmod(paths.socket, 0o600);
|
|
725
|
+
const st = await lstat(paths.socket);
|
|
726
|
+
const uid = process.getuid?.();
|
|
727
|
+
if (!st.isSocket() ||
|
|
728
|
+
(st.mode & 0o777) !== 0o600 ||
|
|
729
|
+
(uid !== undefined && st.uid !== uid))
|
|
730
|
+
throw new Error(`jux: refusing to serve ${paths.socket}: not a private socket of ours.`);
|
|
731
|
+
socketIno = st.ino;
|
|
732
|
+
}
|
|
733
|
+
catch (e) {
|
|
734
|
+
await abort(e);
|
|
735
|
+
}
|
|
736
|
+
server.on('error', () => void stop());
|
|
737
|
+
const state = {
|
|
738
|
+
pid: process.pid,
|
|
739
|
+
socket: paths.socket,
|
|
740
|
+
binary: opts.executablePath,
|
|
741
|
+
version: CHROME_VERSION,
|
|
742
|
+
key: opts.key,
|
|
743
|
+
startedAt: new Date().toISOString(),
|
|
744
|
+
idleMinutes,
|
|
745
|
+
};
|
|
746
|
+
try {
|
|
747
|
+
await writeState(paths, state);
|
|
748
|
+
}
|
|
749
|
+
catch (e) {
|
|
750
|
+
await stop();
|
|
751
|
+
throw e;
|
|
752
|
+
}
|
|
753
|
+
// Only our own file: a daemon that replaced us owns its heartbeat.
|
|
754
|
+
heartbeat = setInterval(() => {
|
|
755
|
+
void readState(paths).then(async (s) => {
|
|
756
|
+
if (s?.pid !== process.pid || s.startedAt !== state.startedAt)
|
|
757
|
+
return;
|
|
758
|
+
const now = new Date();
|
|
759
|
+
await utimes(paths.state, now, now).catch(() => { });
|
|
760
|
+
});
|
|
761
|
+
}, HEARTBEAT_MS);
|
|
762
|
+
heartbeat.unref();
|
|
763
|
+
chrome.connection.onClose = () => void stop();
|
|
764
|
+
if (chrome.connection.closed)
|
|
765
|
+
void stop();
|
|
766
|
+
scheduleIdle();
|
|
767
|
+
return {
|
|
768
|
+
state,
|
|
769
|
+
chromePid: chrome.pid,
|
|
770
|
+
stop,
|
|
771
|
+
get inFlight() {
|
|
772
|
+
return owners.size;
|
|
773
|
+
},
|
|
774
|
+
browserContextIds: async () => (await chrome.connection.send('Target.getBrowserContexts')).browserContextIds,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
//# sourceMappingURL=supervisor.js.map
|