@juxtapose/core 0.4.0 → 0.6.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.
Files changed (153) hide show
  1. package/dist/agent/handoff.d.ts +8 -1
  2. package/dist/agent/handoff.js +75 -7
  3. package/dist/agent/handoff.js.map +1 -1
  4. package/dist/analysis/affected.d.ts +37 -0
  5. package/dist/analysis/affected.js +97 -22
  6. package/dist/analysis/affected.js.map +1 -1
  7. package/dist/analysis/cause.d.ts +32 -0
  8. package/dist/analysis/cause.js +187 -0
  9. package/dist/analysis/cause.js.map +1 -0
  10. package/dist/baseline/approve.d.ts +24 -0
  11. package/dist/baseline/approve.js +54 -0
  12. package/dist/baseline/approve.js.map +1 -0
  13. package/dist/baseline/git-store.d.ts +4 -0
  14. package/dist/baseline/git-store.js +22 -0
  15. package/dist/baseline/git-store.js.map +1 -1
  16. package/dist/browser/acquire.d.ts +15 -0
  17. package/dist/browser/acquire.js +26 -0
  18. package/dist/browser/acquire.js.map +1 -0
  19. package/dist/browser/browser.d.ts +53 -0
  20. package/dist/browser/browser.js +122 -0
  21. package/dist/browser/browser.js.map +1 -0
  22. package/dist/browser/cdp.d.ts +81 -0
  23. package/dist/browser/cdp.js +230 -0
  24. package/dist/browser/cdp.js.map +1 -0
  25. package/dist/browser/daemon/client.d.ts +145 -0
  26. package/dist/browser/daemon/client.js +764 -0
  27. package/dist/browser/daemon/client.js.map +1 -0
  28. package/dist/browser/daemon/state.d.ts +99 -0
  29. package/dist/browser/daemon/state.js +297 -0
  30. package/dist/browser/daemon/state.js.map +1 -0
  31. package/dist/browser/daemon/supervisor-main.d.ts +1 -0
  32. package/dist/browser/daemon/supervisor-main.js +54 -0
  33. package/dist/browser/daemon/supervisor-main.js.map +1 -0
  34. package/dist/browser/daemon/supervisor.d.ts +37 -0
  35. package/dist/browser/daemon/supervisor.js +777 -0
  36. package/dist/browser/daemon/supervisor.js.map +1 -0
  37. package/dist/{capture/launch.d.ts → browser/flags.d.ts} +19 -1
  38. package/dist/browser/flags.js +146 -0
  39. package/dist/browser/flags.js.map +1 -0
  40. package/dist/browser/index.d.ts +14 -0
  41. package/dist/browser/index.js +14 -0
  42. package/dist/browser/index.js.map +1 -0
  43. package/dist/browser/install.d.ts +33 -0
  44. package/dist/browser/install.js +167 -0
  45. package/dist/browser/install.js.map +1 -0
  46. package/dist/browser/launch.d.ts +37 -0
  47. package/dist/browser/launch.js +254 -0
  48. package/dist/browser/launch.js.map +1 -0
  49. package/dist/browser/page.d.ts +125 -0
  50. package/dist/browser/page.js +564 -0
  51. package/dist/browser/page.js.map +1 -0
  52. package/dist/browser/pins.d.ts +12 -0
  53. package/dist/browser/pins.js +57 -0
  54. package/dist/browser/pins.js.map +1 -0
  55. package/dist/browser/private-dir.d.ts +7 -0
  56. package/dist/browser/private-dir.js +24 -0
  57. package/dist/browser/private-dir.js.map +1 -0
  58. package/dist/browser/resolve.d.ts +31 -0
  59. package/dist/browser/resolve.js +126 -0
  60. package/dist/browser/resolve.js.map +1 -0
  61. package/dist/browser/selectors.d.ts +10 -0
  62. package/dist/browser/selectors.js +60 -0
  63. package/dist/browser/selectors.js.map +1 -0
  64. package/dist/browser/transport.d.ts +51 -0
  65. package/dist/browser/transport.js +124 -0
  66. package/dist/browser/transport.js.map +1 -0
  67. package/dist/browser/types.d.ts +4 -0
  68. package/dist/browser/types.js +2 -0
  69. package/dist/browser/types.js.map +1 -0
  70. package/dist/browser/zip.d.ts +27 -0
  71. package/dist/browser/zip.js +241 -0
  72. package/dist/browser/zip.js.map +1 -0
  73. package/dist/capture/capture.d.ts +7 -1
  74. package/dist/capture/capture.js +25 -8
  75. package/dist/capture/capture.js.map +1 -1
  76. package/dist/capture/env.d.ts +1 -1
  77. package/dist/capture/pool.d.ts +1 -1
  78. package/dist/capture/pool.js +41 -8
  79. package/dist/capture/pool.js.map +1 -1
  80. package/dist/capture/settle.d.ts +1 -1
  81. package/dist/capture/settle.js +2 -0
  82. package/dist/capture/settle.js.map +1 -1
  83. package/dist/capture/snap.d.ts +43 -0
  84. package/dist/capture/snap.js +103 -0
  85. package/dist/capture/snap.js.map +1 -0
  86. package/dist/capture/structure.d.ts +77 -0
  87. package/dist/capture/structure.js +498 -0
  88. package/dist/capture/structure.js.map +1 -0
  89. package/dist/diff/compare.d.ts +19 -0
  90. package/dist/diff/compare.js +19 -0
  91. package/dist/diff/compare.js.map +1 -0
  92. package/dist/diff/crops.d.ts +26 -0
  93. package/dist/diff/crops.js +92 -0
  94. package/dist/diff/crops.js.map +1 -0
  95. package/dist/diff/structural/classify.d.ts +29 -0
  96. package/dist/diff/structural/classify.js +113 -0
  97. package/dist/diff/structural/classify.js.map +1 -0
  98. package/dist/diff/structural/coverage.d.ts +11 -0
  99. package/dist/diff/structural/coverage.js +37 -0
  100. package/dist/diff/structural/coverage.js.map +1 -0
  101. package/dist/diff/structural/deltas.d.ts +29 -0
  102. package/dist/diff/structural/deltas.js +218 -0
  103. package/dist/diff/structural/deltas.js.map +1 -0
  104. package/dist/diff/structural/disambiguate.d.ts +3 -0
  105. package/dist/diff/structural/disambiguate.js +89 -0
  106. package/dist/diff/structural/disambiguate.js.map +1 -0
  107. package/dist/diff/structural/label.d.ts +15 -0
  108. package/dist/diff/structural/label.js +64 -0
  109. package/dist/diff/structural/label.js.map +1 -0
  110. package/dist/diff/structural/match.d.ts +13 -0
  111. package/dist/diff/structural/match.js +135 -0
  112. package/dist/diff/structural/match.js.map +1 -0
  113. package/dist/diff/structural/shift.d.ts +2 -0
  114. package/dist/diff/structural/shift.js +144 -0
  115. package/dist/diff/structural/shift.js.map +1 -0
  116. package/dist/diff/structural/summary.d.ts +31 -0
  117. package/dist/diff/structural/summary.js +420 -0
  118. package/dist/diff/structural/summary.js.map +1 -0
  119. package/dist/diff/structural.d.ts +40 -0
  120. package/dist/diff/structural.js +219 -0
  121. package/dist/diff/structural.js.map +1 -0
  122. package/dist/index.d.ts +28 -4
  123. package/dist/index.js +32 -3
  124. package/dist/index.js.map +1 -1
  125. package/dist/platform.d.ts +1 -1
  126. package/dist/platforms/urls.d.ts +3 -0
  127. package/dist/platforms/urls.js +89 -0
  128. package/dist/platforms/urls.js.map +1 -0
  129. package/dist/report/comment.js +9 -3
  130. package/dist/report/comment.js.map +1 -1
  131. package/dist/report/format.d.ts +18 -0
  132. package/dist/report/format.js +20 -0
  133. package/dist/report/format.js.map +1 -1
  134. package/dist/report/html.js +2 -2
  135. package/dist/report/html.js.map +1 -1
  136. package/dist/run/groups.d.ts +2 -0
  137. package/dist/run/groups.js +45 -0
  138. package/dist/run/groups.js.map +1 -0
  139. package/dist/run/run.d.ts +6 -2
  140. package/dist/run/run.js +74 -6
  141. package/dist/run/run.js.map +1 -1
  142. package/dist/shell-quote.d.ts +2 -0
  143. package/dist/shell-quote.js +10 -0
  144. package/dist/shell-quote.js.map +1 -0
  145. package/dist/types.d.ts +201 -5
  146. package/dist/types.js +0 -2
  147. package/dist/types.js.map +1 -1
  148. package/dist/url-platform.d.ts +2 -2
  149. package/dist/url-platform.js +2 -2
  150. package/dist/url-platform.js.map +1 -1
  151. package/package.json +6 -10
  152. package/dist/capture/launch.js +0 -56
  153. package/dist/capture/launch.js.map +0 -1
@@ -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