handmux 0.20.3 → 0.22.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 (41) hide show
  1. package/README.md +10 -10
  2. package/README.zh-CN.md +10 -10
  3. package/package.json +7 -5
  4. package/public/assets/index-CRAlQZDz.css +32 -0
  5. package/public/assets/index-CzlitgRX.js +335 -0
  6. package/public/index.html +2 -2
  7. package/src/browser/bootstrap.js +68 -0
  8. package/src/browser/cookieProfiles.js +403 -0
  9. package/src/browser/coordinator.js +47 -0
  10. package/src/browser/hammerheadCompat.js +86 -0
  11. package/src/browser/hammerheadRedirectCompat.js +38 -0
  12. package/src/browser/manager.js +530 -0
  13. package/src/browser/originLabel.js +17 -0
  14. package/src/browser/profilePersistence.js +241 -0
  15. package/src/browser/protocol.js +1 -0
  16. package/src/browser/publicProxy.js +153 -0
  17. package/src/browser/routes.js +145 -0
  18. package/src/browser/sessionStore.js +78 -0
  19. package/src/browser/targetPolicy.js +137 -0
  20. package/src/browser/worker.js +32 -0
  21. package/src/browser/workerClient.js +253 -0
  22. package/src/browser/workerServer.js +98 -0
  23. package/src/cli/i18n/en.js +6 -11
  24. package/src/cli/i18n/zh.js +6 -11
  25. package/src/cli/options.js +3 -4
  26. package/src/cli/setupModel.js +3 -3
  27. package/src/cli/setupWizard.js +5 -9
  28. package/src/cli/supervisor.js +8 -3
  29. package/src/httpApi.js +10 -2
  30. package/src/previewServer.js +123 -151
  31. package/src/previews.js +54 -56
  32. package/src/push.js +16 -7
  33. package/src/routes/previews.js +9 -16
  34. package/src/routes/push.js +5 -4
  35. package/src/routes/system.js +7 -2
  36. package/src/routes/terminal.js +12 -0
  37. package/src/server.js +24 -14
  38. package/src/workspace/checkpointer.js +6 -2
  39. package/public/assets/index-C8Bg8Ecf.css +0 -32
  40. package/public/assets/index-CYZJ6vVb.js +0 -335
  41. package/src/internalPreviewGateway.js +0 -487
package/public/index.html CHANGED
@@ -48,8 +48,8 @@
48
48
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
49
49
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
50
50
  </style>
51
- <script type="module" crossorigin src="/assets/index-CYZJ6vVb.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-C8Bg8Ecf.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-C8Bg8Ecf.css"></noscript>
51
+ <script type="module" crossorigin src="/assets/index-CzlitgRX.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-CRAlQZDz.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CRAlQZDz.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
@@ -0,0 +1,68 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ const BOOTSTRAP_PREFIX = '/_browser-bootstrap/';
4
+
5
+ function normalizedOrigin(raw) {
6
+ const url = new URL(raw);
7
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('browser bootstrap origin must use http or https');
8
+ return url.origin;
9
+ }
10
+
11
+ export function isBrowserBootstrapPath(pathname) {
12
+ return String(pathname || '').startsWith(BOOTSTRAP_PREFIX);
13
+ }
14
+
15
+ export function createBrowserBootstrapStore({
16
+ randomToken = () => randomBytes(24).toString('base64url'),
17
+ now = Date.now,
18
+ ttlMs = 60_000,
19
+ } = {}) {
20
+ const tickets = new Map();
21
+
22
+ const prune = () => {
23
+ const current = now();
24
+ for (const [token, entry] of tickets) if (entry.expiresAt <= current) tickets.delete(token);
25
+ };
26
+
27
+ return {
28
+ issue({ url, origin, deviceId, preserveMethod = false, redirectStatus = 302 }) {
29
+ prune();
30
+ const expectedOrigin = normalizedOrigin(origin);
31
+ const target = new URL(url);
32
+ if (target.origin !== expectedOrigin || !target.pathname.split('/')[1]?.startsWith('_browser-')) {
33
+ throw new Error('browser bootstrap target must be a session on its public origin');
34
+ }
35
+ const token = randomToken();
36
+ tickets.set(token, {
37
+ url: target.toString(),
38
+ origin: expectedOrigin,
39
+ deviceId,
40
+ preserveMethod,
41
+ redirectStatus,
42
+ expiresAt: now() + ttlMs,
43
+ });
44
+ return new URL(`${BOOTSTRAP_PREFIX}${encodeURIComponent(token)}`, expectedOrigin).toString();
45
+ },
46
+
47
+ consume(pathname, origin) {
48
+ prune();
49
+ if (!isBrowserBootstrapPath(pathname)) return null;
50
+ let token;
51
+ try { token = decodeURIComponent(String(pathname).slice(BOOTSTRAP_PREFIX.length)); } catch { return null; }
52
+ const entry = tickets.get(token);
53
+ let requestedOrigin;
54
+ try { requestedOrigin = normalizedOrigin(origin); } catch { return null; }
55
+ if (!entry || entry.origin !== requestedOrigin) return null;
56
+ tickets.delete(token);
57
+ const consumed = {
58
+ url: entry.url,
59
+ deviceId: entry.deviceId,
60
+ };
61
+ if (entry.preserveMethod) {
62
+ consumed.preserveMethod = true;
63
+ consumed.redirectStatus = entry.redirectStatus;
64
+ }
65
+ return consumed;
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,403 @@
1
+ const MUTATION_METHODS = ['setByServer', 'setByClient', 'setCookies', 'deleteCookies'];
2
+ const DAY = 24 * 60 * 60 * 1000;
3
+ const MAX_TIMER_DELAY = 2_147_483_647;
4
+
5
+ const noPersistence = {
6
+ async read() { return null; },
7
+ async write() {},
8
+ async remove() {},
9
+ async readMetadata() { return null; },
10
+ async writeMetadata() {},
11
+ async removeMetadata() {},
12
+ async close() {},
13
+ };
14
+
15
+ export function createDeviceCookieProfiles({
16
+ createCookies,
17
+ onMutation = () => {},
18
+ persistence = noPersistence,
19
+ now = Date.now,
20
+ setTimer = setTimeout,
21
+ clearTimer = clearTimeout,
22
+ }) {
23
+ const profiles = new Map();
24
+ const pending = new Set();
25
+ let closing = false;
26
+
27
+ const track = (operation) => {
28
+ pending.add(operation);
29
+ operation.then(() => pending.delete(operation), () => pending.delete(operation));
30
+ return operation;
31
+ };
32
+
33
+ const profileFor = (deviceId) => {
34
+ let profile = profiles.get(deviceId);
35
+ if (!profile) {
36
+ profile = {
37
+ cookies: createCookies(),
38
+ attached: new Set(),
39
+ persist: false,
40
+ retentionDays: 30,
41
+ loaded: false,
42
+ used: false,
43
+ active: false,
44
+ idleSince: null,
45
+ retentionTimer: null,
46
+ flushTimer: null,
47
+ dirty: false,
48
+ operationPromise: Promise.resolve(),
49
+ configurePromise: Promise.resolve(),
50
+ useVersion: 0,
51
+ retentionEpoch: 0,
52
+ };
53
+ profiles.set(deviceId, profile);
54
+ }
55
+ return profile;
56
+ };
57
+
58
+ const installCookies = (profile, cookies) => {
59
+ profile.cookies = cookies;
60
+ for (const attached of profile.attached) {
61
+ attached._cookieJar = cookies._cookieJar;
62
+ attached._pendingSyncCookies = [];
63
+ }
64
+ };
65
+
66
+ const replaceJar = (profile, serialized) => {
67
+ profile.cookies.setJar(serialized);
68
+ for (const cookies of profile.attached) {
69
+ cookies._cookieJar = profile.cookies._cookieJar;
70
+ cookies._pendingSyncCookies = [];
71
+ }
72
+ };
73
+
74
+ const queueOperation = (profile, operation) => {
75
+ const result = profile.operationPromise.then(operation);
76
+ profile.operationPromise = result.catch(() => {});
77
+ return track(result);
78
+ };
79
+
80
+ const queueWrite = (deviceId, profile, serialized = profile.cookies.serializeJar()) => (
81
+ queueOperation(profile, () => persistence.write(deviceId, serialized))
82
+ );
83
+ const queueMetadata = (deviceId, profile) => queueOperation(
84
+ profile,
85
+ () => persistence.writeMetadata?.(deviceId, {
86
+ persist: profile.persist,
87
+ retentionDays: profile.retentionDays,
88
+ noLeaseSince: profile.idleSince,
89
+ }),
90
+ );
91
+
92
+ const flush = async (deviceId) => {
93
+ const profile = profiles.get(deviceId);
94
+ if (!profile || !profile.persist) return;
95
+ if (!profile.dirty) {
96
+ await profile.operationPromise;
97
+ return;
98
+ }
99
+ if (profile.flushTimer !== null) {
100
+ clearTimer(profile.flushTimer);
101
+ profile.flushTimer = null;
102
+ }
103
+ profile.dirty = false;
104
+ try {
105
+ await queueWrite(deviceId, profile);
106
+ } catch (error) {
107
+ profile.dirty = true;
108
+ throw error;
109
+ }
110
+ };
111
+
112
+ const markDirty = (deviceId) => {
113
+ const profile = profileFor(deviceId);
114
+ profile.used = true;
115
+ profile.useVersion += 1;
116
+ onMutation(deviceId);
117
+ if (!profile.persist) return;
118
+ profile.dirty = true;
119
+ if (closing || profile.flushTimer !== null) return;
120
+ profile.flushTimer = setTimer(() => {
121
+ profile.flushTimer = null;
122
+ void flush(deviceId).catch(() => {});
123
+ }, 500);
124
+ };
125
+
126
+ const clearRetentionTimer = (profile) => {
127
+ if (profile.retentionTimer === null) return;
128
+ clearTimer(profile.retentionTimer);
129
+ profile.retentionTimer = null;
130
+ };
131
+
132
+ const clearExpired = async (deviceId, profile, expectedEpoch, expectedIdleSince) => {
133
+ clearRetentionTimer(profile);
134
+ if (profile.dirty) await flush(deviceId);
135
+ else await profile.operationPromise;
136
+ if (profile.active
137
+ || profile.retentionEpoch !== expectedEpoch
138
+ || profile.idleSince !== expectedIdleSince) return;
139
+ installCookies(profile, createCookies());
140
+ profile.used = false;
141
+ profile.useVersion += 1;
142
+ profile.idleSince = null;
143
+ profile.dirty = false;
144
+ await queueOperation(profile, () => persistence.remove(deviceId));
145
+ };
146
+
147
+ const scheduleRetention = (deviceId, profile) => {
148
+ clearRetentionTimer(profile);
149
+ if (closing || profile.active || profile.idleSince === null || profile.retentionDays === null) return null;
150
+ const remaining = profile.idleSince + profile.retentionDays * DAY - now();
151
+ if (remaining <= 0) {
152
+ return track(clearExpired(
153
+ deviceId,
154
+ profile,
155
+ profile.retentionEpoch,
156
+ profile.idleSince,
157
+ ));
158
+ }
159
+ profile.retentionTimer = setTimer(() => {
160
+ profile.retentionTimer = null;
161
+ const operation = scheduleRetention(deviceId, profile);
162
+ if (operation) void operation.catch(() => {});
163
+ }, Math.min(remaining, MAX_TIMER_DELAY));
164
+ return null;
165
+ };
166
+
167
+ const saveCurrent = async (deviceId, profile) => {
168
+ if (profile.flushTimer !== null) {
169
+ clearTimer(profile.flushTimer);
170
+ profile.flushTimer = null;
171
+ }
172
+ profile.dirty = false;
173
+ await queueWrite(deviceId, profile);
174
+ };
175
+
176
+ const configureImpl = async (deviceId, prefs) => {
177
+ if (typeof prefs?.persist !== 'boolean' || ![1, 7, 30, null].includes(prefs?.retentionDays)) {
178
+ throw new Error('invalid browser profile preferences');
179
+ }
180
+ const profile = profileFor(deviceId);
181
+ const wasPersisting = profile.persist;
182
+ profile.retentionEpoch += 1;
183
+ let warning = null;
184
+
185
+ if (prefs.persist && !wasPersisting) {
186
+ if (!profile.loaded) {
187
+ const metadata = await persistence.readMetadata?.(deviceId);
188
+ if (metadata?.persist) profile.idleSince = metadata.noLeaseSince;
189
+ }
190
+ profile.persist = true;
191
+ profile.retentionDays = prefs.retentionDays;
192
+ if (!profile.loaded && !profile.used) {
193
+ profile.loaded = true;
194
+ const useVersion = profile.useVersion;
195
+ try {
196
+ const serialized = await persistence.read(deviceId);
197
+ if (profile.used || profile.useVersion !== useVersion) {
198
+ await saveCurrent(deviceId, profile);
199
+ } else if (serialized !== null) {
200
+ replaceJar(profile, serialized);
201
+ }
202
+ } catch {
203
+ warning = 'profile-recovery-failed';
204
+ await queueOperation(profile, () => persistence.remove(deviceId));
205
+ if (profile.used || profile.useVersion !== useVersion) {
206
+ await saveCurrent(deviceId, profile);
207
+ } else {
208
+ installCookies(profile, createCookies());
209
+ profile.used = false;
210
+ profile.useVersion += 1;
211
+ }
212
+ }
213
+ } else {
214
+ profile.loaded = true;
215
+ await saveCurrent(deviceId, profile);
216
+ }
217
+ } else if (!prefs.persist && wasPersisting) {
218
+ const wasDirty = profile.dirty;
219
+ if (profile.flushTimer !== null) {
220
+ clearTimer(profile.flushTimer);
221
+ profile.flushTimer = null;
222
+ }
223
+ profile.dirty = false;
224
+ try {
225
+ await queueOperation(profile, () => persistence.remove(deviceId));
226
+ } catch (error) {
227
+ profile.dirty ||= wasDirty;
228
+ const retention = scheduleRetention(deviceId, profile);
229
+ if (retention) await retention;
230
+ throw error;
231
+ }
232
+ if (profile.flushTimer !== null) {
233
+ clearTimer(profile.flushTimer);
234
+ profile.flushTimer = null;
235
+ }
236
+ profile.dirty = false;
237
+ profile.persist = false;
238
+ profile.retentionDays = prefs.retentionDays;
239
+ await queueOperation(profile, () => persistence.removeMetadata?.(deviceId));
240
+ } else {
241
+ profile.persist = prefs.persist;
242
+ profile.retentionDays = prefs.retentionDays;
243
+ }
244
+
245
+ if (profile.persist) await queueMetadata(deviceId, profile);
246
+ const retention = scheduleRetention(deviceId, profile);
247
+ if (retention) await retention;
248
+ return { persist: profile.persist, retentionDays: profile.retentionDays, warning };
249
+ };
250
+
251
+ const configure = (deviceId, prefs) => {
252
+ const profile = profileFor(deviceId);
253
+ // Preference changes span reads, jar restoration, disk removal, and metadata writes. Serialize that
254
+ // whole transaction per device; serializing only the individual file operations lets an older enable
255
+ // finish after a newer disable and silently turn persistence back on.
256
+ const result = profile.configurePromise.catch(() => {}).then(() => configureImpl(deviceId, prefs));
257
+ profile.configurePromise = result.catch(() => {});
258
+ return track(result);
259
+ };
260
+
261
+ const setActive = (deviceId, active) => {
262
+ const profile = profiles.get(deviceId);
263
+ if (!profile) return;
264
+ profile.retentionEpoch += 1;
265
+ if (active) {
266
+ profile.active = true;
267
+ profile.idleSince = null;
268
+ clearRetentionTimer(profile);
269
+ if (profile.persist) void queueMetadata(deviceId, profile).catch(() => {});
270
+ return;
271
+ }
272
+ if (profile.active || profile.idleSince === null) profile.idleSince = now();
273
+ profile.active = false;
274
+ if (profile.persist) void queueMetadata(deviceId, profile).catch(() => {});
275
+ const retention = scheduleRetention(deviceId, profile);
276
+ if (retention) void retention.catch(() => {});
277
+ };
278
+
279
+ const attach = (deviceId, sessionCookies) => {
280
+ const profile = profileFor(deviceId);
281
+ if (!profile.cookies?._cookieJar || !sessionCookies?._cookieJar) {
282
+ throw new Error('browser cookie profile unsupported');
283
+ }
284
+ profile.used = true;
285
+ profile.useVersion += 1;
286
+ sessionCookies._cookieJar = profile.cookies._cookieJar;
287
+ sessionCookies._pendingSyncCookies = [];
288
+ profile.attached.add(sessionCookies);
289
+
290
+ const originals = new Map();
291
+ for (const name of MUTATION_METHODS) {
292
+ const original = sessionCookies[name];
293
+ originals.set(name, {
294
+ hadOwn: Object.prototype.hasOwnProperty.call(sessionCookies, name),
295
+ value: original,
296
+ });
297
+ sessionCookies[name] = (...args) => {
298
+ const result = original.apply(sessionCookies, args);
299
+ markDirty(deviceId);
300
+ return result;
301
+ };
302
+ }
303
+
304
+ let attached = true;
305
+ return () => {
306
+ if (!attached) return;
307
+ attached = false;
308
+ profile.attached.delete(sessionCookies);
309
+ for (const [name, original] of originals) {
310
+ if (original.hadOwn) sessionCookies[name] = original.value;
311
+ else delete sessionCookies[name];
312
+ }
313
+ };
314
+ };
315
+
316
+ const serialize = (deviceId) => {
317
+ const profile = profiles.get(deviceId);
318
+ if (!profile) return null;
319
+ profile.used = true;
320
+ profile.useVersion += 1;
321
+ return profile.cookies.serializeJar();
322
+ };
323
+
324
+ const clear = (deviceId, { url, hostname } = {}) => {
325
+ const fullClear = !hostname && !url;
326
+ const profile = profiles.get(deviceId) || (fullClear ? profileFor(deviceId) : null);
327
+ if (!profile) return { cleared: false };
328
+ profile.used = true;
329
+ profile.useVersion += 1;
330
+ let targetHostname = hostname;
331
+ if (!targetHostname && url) {
332
+ try { targetHostname = new URL(url).hostname; } catch { return { cleared: false }; }
333
+ }
334
+ if (!targetHostname) {
335
+ replaceJar(profile, null);
336
+ onMutation(deviceId);
337
+ profile.dirty = false;
338
+ if (profile.flushTimer !== null) {
339
+ clearTimer(profile.flushTimer);
340
+ profile.flushTimer = null;
341
+ }
342
+ return queueOperation(profile, () => persistence.remove(deviceId))
343
+ .then(() => ({ cleared: true }));
344
+ }
345
+
346
+ const normalizedHostname = targetHostname.toLowerCase();
347
+ const internalCookies = profile.cookies._getAllCookiesSync().filter((cookie) => {
348
+ const domain = cookie.domain?.replace(/^\./, '').toLowerCase();
349
+ if (!domain) return false;
350
+ if (cookie.hostOnly) return domain === normalizedHostname;
351
+ return normalizedHostname === domain || normalizedHostname.endsWith(`.${domain}`);
352
+ });
353
+ if (!internalCookies.length) return { cleared: false };
354
+ const matches = profile.cookies._convertToExternalCookies(internalCookies);
355
+ for (let index = 0; index < matches.length; index++) {
356
+ matches[index].expires = internalCookies[index].expires;
357
+ matches[index].maxAge = internalCookies[index].maxAge;
358
+ matches[index].sameSite = internalCookies[index].sameSite;
359
+ }
360
+ profile.cookies.deleteCookies(matches);
361
+ markDirty(deviceId);
362
+ return { cleared: true };
363
+ };
364
+
365
+ const remove = (deviceId) => {
366
+ const profile = profiles.get(deviceId);
367
+ if (!profile) return false;
368
+ clearRetentionTimer(profile);
369
+ if (profile.flushTimer !== null) clearTimer(profile.flushTimer);
370
+ profiles.delete(deviceId);
371
+ if (profile.persist) {
372
+ queueOperation(profile, () => persistence.remove(deviceId));
373
+ queueOperation(profile, () => persistence.removeMetadata?.(deviceId));
374
+ }
375
+ return true;
376
+ };
377
+ const has = (deviceId) => profiles.has(deviceId);
378
+
379
+ const close = async () => {
380
+ closing = true;
381
+ for (const profile of profiles.values()) clearRetentionTimer(profile);
382
+ while (true) {
383
+ const flushes = [];
384
+ for (const [deviceId, profile] of profiles) {
385
+ if (profile.dirty) flushes.push(flush(deviceId));
386
+ else if (profile.flushTimer !== null) {
387
+ clearTimer(profile.flushTimer);
388
+ profile.flushTimer = null;
389
+ }
390
+ }
391
+ await Promise.all(flushes);
392
+ const operations = [...pending];
393
+ if (!operations.length) break;
394
+ await Promise.all(operations);
395
+ }
396
+ for (const profile of profiles.values()) clearRetentionTimer(profile);
397
+ await persistence.close();
398
+ };
399
+
400
+ return {
401
+ attach, serialize, clear, remove, has, configure, setActive, flush, close,
402
+ };
403
+ }
@@ -0,0 +1,47 @@
1
+ const DEVICE_ID = /^[A-Za-z0-9_-]{32,128}$/;
2
+
3
+ function jsonBody(response) {
4
+ if (!response?.body?.length) return null;
5
+ try { return JSON.parse(response.body.toString('utf8')); } catch { return null; }
6
+ }
7
+
8
+ function sendProxy(res, response, generation, stampGeneration) {
9
+ if (!response) return res.status(503).json({ error: 'browser unavailable' });
10
+ for (const [name, value] of Object.entries(response.headers || {})) {
11
+ if (value != null && !['connection', 'content-length', 'transfer-encoding'].includes(name.toLowerCase())) {
12
+ res.setHeader(name, value);
13
+ }
14
+ }
15
+ if (stampGeneration && response.status >= 200 && response.status < 300) {
16
+ const payload = jsonBody(response);
17
+ if (payload) return res.status(response.status).json({ ...payload, generation });
18
+ }
19
+ return res.status(response.status).send(response.body);
20
+ }
21
+
22
+ export function createBrowserCoordinator({
23
+ proxyRequest,
24
+ getStatus = () => ({ ready: false, generation: 0 }),
25
+ } = {}) {
26
+ const handler = async (req, res) => {
27
+ if (req.method === 'GET' && req.path === '/status') {
28
+ return res.json(getStatus());
29
+ }
30
+ const deviceId = req.get('x-handmux-browser-device');
31
+ if (!DEVICE_ID.test(deviceId || '')) {
32
+ return res.status(400).json({ error: 'browser device id required' });
33
+ }
34
+ const path = `/api/browser-proxy${req.path}`;
35
+ const response = await proxyRequest({
36
+ req,
37
+ method: req.method,
38
+ path,
39
+ body: ['PUT', 'POST', 'PATCH'].includes(req.method) ? req.body : undefined,
40
+ });
41
+ const stampGeneration = /^\/leases\/[^/]+(?:\/navigate)?$/.test(req.path)
42
+ && ['PUT', 'POST'].includes(req.method);
43
+ return sendProxy(res, response, getStatus().generation, stampGeneration);
44
+ };
45
+ handler.close = () => {};
46
+ return handler;
47
+ }
@@ -0,0 +1,86 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+ const PATCHED = Symbol.for('handmux.hammerhead.websocket-upgrade-socket');
5
+ const RESPONDER_PATCHED = Symbol.for('handmux.hammerhead.websocket-mock-response');
6
+ const REQUEST_INFO_PATCHED = Symbol.for('handmux.hammerhead.request-info-frame-nature');
7
+
8
+ export function patchHammerheadDestinationRequest(DestinationRequest) {
9
+ const prototype = DestinationRequest?.prototype;
10
+ const original = prototype?._onUpgrade;
11
+ if (typeof original !== 'function' || original.length !== 3) {
12
+ throw new Error('unsupported Hammerhead DestinationRequest._onUpgrade signature');
13
+ }
14
+ if (original[PATCHED]) return false;
15
+
16
+ function onUpgrade(response, socket, head) {
17
+ if (response) response.socket ??= socket;
18
+ return original.call(this, response, socket, head);
19
+ }
20
+ Object.defineProperty(onUpgrade, PATCHED, { value: true });
21
+ prototype._onUpgrade = onUpgrade;
22
+ return true;
23
+ }
24
+
25
+ export function patchHammerheadWebSocketResponder(websocket, headerTransforms) {
26
+ const original = websocket?.respondOnWebSocket;
27
+ if (typeof original !== 'function') {
28
+ throw new Error('unsupported Hammerhead WebSocket responder');
29
+ }
30
+ if (original[RESPONDER_PATCHED]) return false;
31
+
32
+ function respondOnWebSocket(ctx) {
33
+ if (ctx?.destRes?.socket) return original(ctx);
34
+ const response = ctx?.res;
35
+ const destination = ctx?.destRes;
36
+ if (!destination || typeof response?.end !== 'function') return original(ctx);
37
+
38
+ const headers = headerTransforms.forResponse(ctx);
39
+ const missingUpgradeSocket = destination.statusCode === 101;
40
+ const statusCode = missingUpgradeSocket ? 502 : destination.statusCode || 502;
41
+ const statusMessage = missingUpgradeSocket ? 'Bad Gateway' : destination.statusMessage || 'Bad Gateway';
42
+ const lines = [`HTTP/${destination.httpVersion || '1.1'} ${statusCode} ${statusMessage}`];
43
+ for (const [name, value] of Object.entries(headers)) {
44
+ if (Array.isArray(value)) value.forEach((item) => lines.push(`${name}: ${item}`));
45
+ else if (value != null) lines.push(`${name}: ${value}`);
46
+ }
47
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === 'connection')) {
48
+ lines.push('connection: close');
49
+ }
50
+ response.end(`${lines.join('\r\n')}\r\n\r\n`);
51
+ return undefined;
52
+ }
53
+ Object.defineProperty(respondOnWebSocket, RESPONDER_PATCHED, { value: true });
54
+ websocket.respondOnWebSocket = respondOnWebSocket;
55
+ return true;
56
+ }
57
+
58
+ export function patchHammerheadRequestInfo(RequestInfo) {
59
+ const original = RequestInfo?.from;
60
+ if (typeof original !== 'function') {
61
+ throw new Error('unsupported Hammerhead RequestInfo.from');
62
+ }
63
+ if (original[REQUEST_INFO_PATCHED]) return false;
64
+
65
+ function from(ctx) {
66
+ const info = original.call(this, ctx);
67
+ info.isIframe = !!ctx?.isIframe;
68
+ return info;
69
+ }
70
+ Object.defineProperty(from, REQUEST_INFO_PATCHED, { value: true });
71
+ RequestInfo.from = from;
72
+ return true;
73
+ }
74
+
75
+ export function installHammerheadWebSocketUpgradeCompat() {
76
+ const DestinationRequest = require('testcafe-hammerhead/lib/request-pipeline/destination-request');
77
+ const websocket = require('testcafe-hammerhead/lib/request-pipeline/websocket');
78
+ const headerTransforms = require('testcafe-hammerhead/lib/request-pipeline/header-transforms');
79
+ const { RequestInfo } = require(
80
+ 'testcafe-hammerhead/lib/request-pipeline/request-hooks/events/info',
81
+ );
82
+ const destinationPatched = patchHammerheadDestinationRequest(DestinationRequest);
83
+ const responderPatched = patchHammerheadWebSocketResponder(websocket, headerTransforms);
84
+ const requestInfoPatched = patchHammerheadRequestInfo(RequestInfo);
85
+ return destinationPatched || responderPatched || requestInfoPatched;
86
+ }
@@ -0,0 +1,38 @@
1
+ import { createRequire } from 'node:module';
2
+ import { randomBytes } from 'node:crypto';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const MARKER = 'x-handmux-browser-rebind';
6
+ const MARKER_SECRET = randomBytes(32).toString('base64url');
7
+ const PATCHED = Symbol.for('handmux.hammerhead.raw-rebind-location');
8
+
9
+ export function hammerheadRebindHeaders(location) {
10
+ return { location, [MARKER]: MARKER_SECRET };
11
+ }
12
+
13
+ export function patchHammerheadRebindLocation(
14
+ responseTransforms,
15
+ markerSecret = MARKER_SECRET,
16
+ ) {
17
+ const original = responseTransforms?.location;
18
+ if (typeof original !== 'function') throw new Error('unsupported Hammerhead location transform');
19
+ if (original[PATCHED]) return false;
20
+
21
+ function location(src, ctx) {
22
+ if (ctx?.destRes?.headers?.[MARKER] === markerSecret) return src;
23
+ return original(src, ctx);
24
+ }
25
+ Object.defineProperty(location, PATCHED, { value: true });
26
+ responseTransforms.location = location;
27
+ responseTransforms[MARKER] = () => undefined;
28
+ return true;
29
+ }
30
+
31
+ export function installHammerheadRebindLocationCompat() {
32
+ const { responseTransforms } = require(
33
+ 'testcafe-hammerhead/lib/request-pipeline/header-transforms/transforms',
34
+ );
35
+ return patchHammerheadRebindLocation(responseTransforms, MARKER_SECRET);
36
+ }
37
+
38
+ export { MARKER as HAMMERHEAD_REBIND_HEADER };