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.
- package/README.md +10 -10
- package/README.zh-CN.md +10 -10
- package/package.json +7 -5
- package/public/assets/index-CRAlQZDz.css +32 -0
- package/public/assets/index-CzlitgRX.js +335 -0
- package/public/index.html +2 -2
- package/src/browser/bootstrap.js +68 -0
- package/src/browser/cookieProfiles.js +403 -0
- package/src/browser/coordinator.js +47 -0
- package/src/browser/hammerheadCompat.js +86 -0
- package/src/browser/hammerheadRedirectCompat.js +38 -0
- package/src/browser/manager.js +530 -0
- package/src/browser/originLabel.js +17 -0
- package/src/browser/profilePersistence.js +241 -0
- package/src/browser/protocol.js +1 -0
- package/src/browser/publicProxy.js +153 -0
- package/src/browser/routes.js +145 -0
- package/src/browser/sessionStore.js +78 -0
- package/src/browser/targetPolicy.js +137 -0
- package/src/browser/worker.js +32 -0
- package/src/browser/workerClient.js +253 -0
- package/src/browser/workerServer.js +98 -0
- package/src/cli/i18n/en.js +6 -11
- package/src/cli/i18n/zh.js +6 -11
- package/src/cli/options.js +3 -4
- package/src/cli/setupModel.js +3 -3
- package/src/cli/setupWizard.js +5 -9
- package/src/cli/supervisor.js +8 -3
- package/src/httpApi.js +10 -2
- package/src/previewServer.js +123 -151
- package/src/previews.js +54 -56
- package/src/push.js +16 -7
- package/src/routes/previews.js +9 -16
- package/src/routes/push.js +5 -4
- package/src/routes/system.js +7 -2
- package/src/routes/terminal.js +12 -0
- package/src/server.js +24 -14
- package/src/workspace/checkpointer.js +6 -2
- package/public/assets/index-C8Bg8Ecf.css +0 -32
- package/public/assets/index-CYZJ6vVb.js +0 -335
- package/src/internalPreviewGateway.js +0 -487
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as importedHammerhead from 'testcafe-hammerhead';
|
|
5
|
+
import { createDeviceCookieProfiles } from './cookieProfiles.js';
|
|
6
|
+
import { createBrowserProfilePersistence } from './profilePersistence.js';
|
|
7
|
+
import { claimPublicOrigin } from './originLabel.js';
|
|
8
|
+
import { browserLabelForOrigin } from './originLabel.js';
|
|
9
|
+
import { classifyIp, createBrowserTargetPolicy } from './targetPolicy.js';
|
|
10
|
+
import {
|
|
11
|
+
hammerheadRebindHeaders,
|
|
12
|
+
installHammerheadRebindLocationCompat,
|
|
13
|
+
} from './hammerheadRedirectCompat.js';
|
|
14
|
+
|
|
15
|
+
const defaultHammerhead = importedHammerhead.default || importedHammerhead;
|
|
16
|
+
const POOL_IDLE_CLOSE_MS = 1_000;
|
|
17
|
+
|
|
18
|
+
function normalizedOrigin(raw) {
|
|
19
|
+
const url = new URL(raw);
|
|
20
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('browser origin must use http or https');
|
|
21
|
+
return url.origin;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizedTarget(raw) {
|
|
25
|
+
const url = new URL(raw);
|
|
26
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('browser URL must use http or https');
|
|
27
|
+
return url.toString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isLoopbackUrl(raw) {
|
|
31
|
+
const hostname = new URL(raw).hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
32
|
+
return hostname === 'localhost' || classifyIp(hostname) === 'loopback';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bridgeScript(channel) {
|
|
36
|
+
const encoded = JSON.stringify(channel);
|
|
37
|
+
return `(() => {
|
|
38
|
+
const channel = ${encoded};
|
|
39
|
+
const hammerhead = window['%hammerhead%'];
|
|
40
|
+
const destinationUrl = (url) => {
|
|
41
|
+
try { return hammerhead?.utils?.url?.parseProxyUrl(url)?.destUrl || url; }
|
|
42
|
+
catch { return url; }
|
|
43
|
+
};
|
|
44
|
+
const send = (type, url) => parent.postMessage({ source: 'handmux-browser', channel, type, url: url === undefined ? destinationUrl(location.href) : url, title: document.title }, '*');
|
|
45
|
+
let pending = false;
|
|
46
|
+
const activity = () => {
|
|
47
|
+
if (pending) return;
|
|
48
|
+
pending = true;
|
|
49
|
+
setTimeout(() => { pending = false; send('activity'); }, 250);
|
|
50
|
+
};
|
|
51
|
+
for (const name of ['pointerdown', 'keydown', 'input', 'scroll']) addEventListener(name, activity, { capture: true, passive: true });
|
|
52
|
+
addEventListener('load', () => send('load'));
|
|
53
|
+
addEventListener('popstate', () => send('urlchange'));
|
|
54
|
+
addEventListener('hashchange', () => send('urlchange'));
|
|
55
|
+
for (const name of ['pushState', 'replaceState']) {
|
|
56
|
+
const original = history[name];
|
|
57
|
+
history[name] = function (...args) {
|
|
58
|
+
const result = original.apply(this, args);
|
|
59
|
+
send('urlchange');
|
|
60
|
+
return result;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
addEventListener('pagehide', () => send('navigate'));
|
|
64
|
+
let lastTitle;
|
|
65
|
+
let lastTitleUrl;
|
|
66
|
+
const sendTitle = () => {
|
|
67
|
+
const title = document.title;
|
|
68
|
+
const url = destinationUrl(location.href);
|
|
69
|
+
if (title === lastTitle && url === lastTitleUrl) return;
|
|
70
|
+
lastTitle = title;
|
|
71
|
+
lastTitleUrl = url;
|
|
72
|
+
send('title', url);
|
|
73
|
+
};
|
|
74
|
+
const observeTitle = () => {
|
|
75
|
+
if (!document.head) return;
|
|
76
|
+
const observer = new MutationObserver(sendTitle);
|
|
77
|
+
observer.observe(document.head, { subtree: true, childList: true, characterData: true });
|
|
78
|
+
sendTitle();
|
|
79
|
+
};
|
|
80
|
+
if (document.head) observeTitle();
|
|
81
|
+
else addEventListener('DOMContentLoaded', observeTitle, { once: true });
|
|
82
|
+
addEventListener('message', (event) => {
|
|
83
|
+
if (event.source !== parent || event.data?.source !== 'handmux-browser-parent' || event.data?.channel !== channel) return;
|
|
84
|
+
if (event.data.command === 'back') history.back();
|
|
85
|
+
else if (event.data.command === 'forward') history.forward();
|
|
86
|
+
else if (event.data.command === 'reload') location.reload();
|
|
87
|
+
else if (event.data.command === 'stop') window.stop();
|
|
88
|
+
});
|
|
89
|
+
send('ready');
|
|
90
|
+
})();`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function browserSessionClass(hammerhead) {
|
|
94
|
+
return class BrowserSession extends hammerhead.Session {
|
|
95
|
+
constructor(channel) {
|
|
96
|
+
super([], {
|
|
97
|
+
disablePageCaching: true,
|
|
98
|
+
allowMultipleWindows: true,
|
|
99
|
+
windowId: channel,
|
|
100
|
+
requestTimeout: { page: 30_000, ajax: 30_000 },
|
|
101
|
+
nativeAutomation: false,
|
|
102
|
+
});
|
|
103
|
+
this.channel = channel;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getPayloadScript() { return bridgeScript(this.channel); }
|
|
107
|
+
async getIframePayloadScript() { return ''; }
|
|
108
|
+
getAuthCredentials() { return null; }
|
|
109
|
+
handleAttachment() {}
|
|
110
|
+
handleFileDownload() {}
|
|
111
|
+
handlePageError() {}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyPublicOrigin(proxy, origin) {
|
|
116
|
+
const url = new URL(origin);
|
|
117
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
|
|
118
|
+
for (const info of [proxy.server1Info, proxy.server2Info]) {
|
|
119
|
+
info.hostname = url.hostname;
|
|
120
|
+
info.port = port;
|
|
121
|
+
info.crossDomainPort = port;
|
|
122
|
+
info.protocol = url.protocol;
|
|
123
|
+
info.domain = url.origin;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function waitForListening(server) {
|
|
128
|
+
if (!server || server.listening || typeof server.once !== 'function') return;
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
const cleanup = () => {
|
|
131
|
+
server.removeListener('listening', onListening);
|
|
132
|
+
server.removeListener('error', onError);
|
|
133
|
+
server.removeListener('close', onClose);
|
|
134
|
+
};
|
|
135
|
+
const onListening = () => { cleanup(); resolve(); };
|
|
136
|
+
const onError = (error) => { cleanup(); reject(error); };
|
|
137
|
+
const onClose = () => { cleanup(); reject(new Error('browser manager closing')); };
|
|
138
|
+
server.once('listening', onListening);
|
|
139
|
+
server.once('error', onError);
|
|
140
|
+
server.once('close', onClose);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function publicLease(lease) {
|
|
145
|
+
if (!lease) return null;
|
|
146
|
+
return {
|
|
147
|
+
tabId: lease.tabId,
|
|
148
|
+
url: lease.url,
|
|
149
|
+
originalUrl: lease.originalUrl,
|
|
150
|
+
channel: lease.channel,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function createBrowserPreviewManager({
|
|
155
|
+
hammerhead = defaultHammerhead,
|
|
156
|
+
internalPorts = [0, 0],
|
|
157
|
+
randomId = () => randomBytes(18).toString('base64url'),
|
|
158
|
+
randomChannel = () => randomBytes(18).toString('base64url'),
|
|
159
|
+
targetPolicyFactory = createBrowserTargetPolicy,
|
|
160
|
+
handmuxOrigin = 'http://127.0.0.1',
|
|
161
|
+
previewDomain = null,
|
|
162
|
+
browserBootstrap = null,
|
|
163
|
+
cookieProfiles: suppliedCookieProfiles,
|
|
164
|
+
profilePersistence: suppliedProfilePersistence,
|
|
165
|
+
profileDir = path.join(os.homedir(), '.handmux', 'browser-profiles'),
|
|
166
|
+
setTimer = setTimeout,
|
|
167
|
+
clearTimer = clearTimeout,
|
|
168
|
+
leaseTtlMs = 2 * 60 * 60 * 1000,
|
|
169
|
+
} = {}) {
|
|
170
|
+
installHammerheadRebindLocationCompat();
|
|
171
|
+
const ProxyClass = hammerhead.Proxy;
|
|
172
|
+
const SessionClass = browserSessionClass(hammerhead);
|
|
173
|
+
const profilePersistence = suppliedProfilePersistence || createBrowserProfilePersistence({
|
|
174
|
+
dir: profileDir,
|
|
175
|
+
keyFile: path.join(profileDir, 'profile.key'),
|
|
176
|
+
});
|
|
177
|
+
try {
|
|
178
|
+
await profilePersistence.pruneExpiredProfiles?.();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.warn(`[handmux] browser profile retention cleanup deferred: ${error?.message || error}`);
|
|
181
|
+
}
|
|
182
|
+
const cookieProfiles = suppliedCookieProfiles || createDeviceCookieProfiles({
|
|
183
|
+
createCookies: () => new SessionClass('').cookies,
|
|
184
|
+
persistence: profilePersistence,
|
|
185
|
+
setTimer,
|
|
186
|
+
clearTimer,
|
|
187
|
+
});
|
|
188
|
+
const pools = new Map();
|
|
189
|
+
const pendingPools = new Map();
|
|
190
|
+
const leases = new Map();
|
|
191
|
+
const leaseQueues = new Map();
|
|
192
|
+
const publicOriginClaims = new Map();
|
|
193
|
+
let poolCount = 0;
|
|
194
|
+
let closing = false;
|
|
195
|
+
let rehomeNavigation = async () => false;
|
|
196
|
+
|
|
197
|
+
const leaseKey = (deviceId, tabId) => `${deviceId}\u0000${tabId}`;
|
|
198
|
+
const publicOriginFor = (target) => {
|
|
199
|
+
if (!previewDomain) return null;
|
|
200
|
+
const base = new URL(/^https?:\/\//i.test(previewDomain) ? previewDomain : `https://${previewDomain}`);
|
|
201
|
+
base.hostname = `${browserLabelForOrigin(new URL(target).origin)}.${base.hostname}`;
|
|
202
|
+
return base.origin;
|
|
203
|
+
};
|
|
204
|
+
const serializeLease = (key, operation) => {
|
|
205
|
+
const previous = leaseQueues.get(key) || Promise.resolve();
|
|
206
|
+
const current = previous.then(operation);
|
|
207
|
+
const queued = current.catch(() => {});
|
|
208
|
+
leaseQueues.set(key, queued);
|
|
209
|
+
return current.finally(() => {
|
|
210
|
+
if (leaseQueues.get(key) === queued) leaseQueues.delete(key);
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
const poolFor = async (origin) => {
|
|
214
|
+
if (pools.has(origin)) {
|
|
215
|
+
const pool = pools.get(origin);
|
|
216
|
+
if (pool.closeTimer != null) clearTimer(pool.closeTimer);
|
|
217
|
+
pool.closeTimer = null;
|
|
218
|
+
pool.references += 1;
|
|
219
|
+
return pool;
|
|
220
|
+
}
|
|
221
|
+
if (pendingPools.has(origin)) {
|
|
222
|
+
const pool = await pendingPools.get(origin);
|
|
223
|
+
pool.references += 1;
|
|
224
|
+
return pool;
|
|
225
|
+
}
|
|
226
|
+
const pending = (async () => {
|
|
227
|
+
const proxy = new ProxyClass();
|
|
228
|
+
const ports = poolCount++ === 0 ? internalPorts : [0, 0];
|
|
229
|
+
try {
|
|
230
|
+
proxy.start({
|
|
231
|
+
hostname: '127.0.0.1',
|
|
232
|
+
port1: ports[0],
|
|
233
|
+
port2: ports[1],
|
|
234
|
+
disableCrossDomain: true,
|
|
235
|
+
disableHttp2: true,
|
|
236
|
+
});
|
|
237
|
+
await Promise.all([waitForListening(proxy.server1), waitForListening(proxy.server2)]);
|
|
238
|
+
if (closing) throw new Error('browser manager closing');
|
|
239
|
+
applyPublicOrigin(proxy, origin);
|
|
240
|
+
const pool = {
|
|
241
|
+
origin,
|
|
242
|
+
proxy,
|
|
243
|
+
references: 0,
|
|
244
|
+
closeTimer: null,
|
|
245
|
+
ports: [
|
|
246
|
+
proxy.server1?.address?.()?.port ?? ports[0],
|
|
247
|
+
proxy.server2?.address?.()?.port ?? ports[1],
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
pools.set(origin, pool);
|
|
251
|
+
return pool;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
proxy.close();
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
})();
|
|
257
|
+
pendingPools.set(origin, pending);
|
|
258
|
+
try {
|
|
259
|
+
const pool = await pending;
|
|
260
|
+
pool.references += 1;
|
|
261
|
+
return pool;
|
|
262
|
+
} finally {
|
|
263
|
+
if (pendingPools.get(origin) === pending) pendingPools.delete(origin);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const releasePool = (pool) => {
|
|
268
|
+
if (!pool || pool.references <= 0) return;
|
|
269
|
+
pool.references -= 1;
|
|
270
|
+
if (pool.references !== 0) return;
|
|
271
|
+
// A request hook can replace the final lease while the old pool is still writing its redirect.
|
|
272
|
+
// Close after a short idle window so that response can drain; a new lease for the same origin reuses
|
|
273
|
+
// the pool and cancels this timer. This still releases idle Hammerhead ports without worker shutdown.
|
|
274
|
+
pool.closeTimer = setTimer(() => {
|
|
275
|
+
pool.closeTimer = null;
|
|
276
|
+
if (pool.references !== 0) return;
|
|
277
|
+
if (pools.get(pool.origin) === pool) pools.delete(pool.origin);
|
|
278
|
+
publicOriginClaims.delete(pool.origin);
|
|
279
|
+
pool.proxy.close();
|
|
280
|
+
}, POOL_IDLE_CLOSE_MS);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const disposeLease = (lease) => {
|
|
284
|
+
if (!lease || lease.disposed) return;
|
|
285
|
+
lease.disposed = true;
|
|
286
|
+
if (lease.timer != null) clearTimer(lease.timer);
|
|
287
|
+
lease.timer = null;
|
|
288
|
+
lease.detachCookies();
|
|
289
|
+
lease.pool.proxy.closeSession(lease.session);
|
|
290
|
+
releasePool(lease.pool);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const setDeviceActive = (deviceId) => cookieProfiles.setActive?.(
|
|
294
|
+
deviceId,
|
|
295
|
+
[...leases.values()].some((lease) => lease.deviceId === deviceId),
|
|
296
|
+
);
|
|
297
|
+
const release = (lease) => {
|
|
298
|
+
if (!lease || leases.get(lease.key) !== lease) return false;
|
|
299
|
+
leases.delete(lease.key);
|
|
300
|
+
disposeLease(lease);
|
|
301
|
+
setDeviceActive(lease.deviceId);
|
|
302
|
+
return true;
|
|
303
|
+
};
|
|
304
|
+
const touch = (lease) => {
|
|
305
|
+
if (lease.timer != null) clearTimer(lease.timer);
|
|
306
|
+
lease.timer = setTimer(() => release(lease), leaseTtlMs);
|
|
307
|
+
};
|
|
308
|
+
const createLease = async ({ tabId, deviceId, url, origin, channel }) => {
|
|
309
|
+
if (closing) throw new Error('browser manager closing');
|
|
310
|
+
const target = normalizedTarget(url);
|
|
311
|
+
const publicOrigin = normalizedOrigin(origin);
|
|
312
|
+
const targetOrigin = new URL(target).origin;
|
|
313
|
+
claimPublicOrigin(publicOriginClaims, publicOrigin, targetOrigin);
|
|
314
|
+
const pool = await poolFor(publicOrigin);
|
|
315
|
+
let session = null;
|
|
316
|
+
let detachCookies = null;
|
|
317
|
+
try {
|
|
318
|
+
if (closing) throw new Error('browser manager closing');
|
|
319
|
+
session = new SessionClass(channel);
|
|
320
|
+
session.id = `_browser-${randomId()}-${encodeURIComponent(tabId)}`;
|
|
321
|
+
const policy = targetPolicyFactory({ topLevelUrl: target, handmuxOrigin });
|
|
322
|
+
const hooks = session.requestHookEventProvider || session;
|
|
323
|
+
hooks.addRequestEventListeners(hammerhead.RequestFilterRule.ANY, {
|
|
324
|
+
onRequest: async (event) => {
|
|
325
|
+
if (await rehomeNavigation(event, session)) return;
|
|
326
|
+
const result = await policy.check(event._requestInfo.url);
|
|
327
|
+
if (result.allowed) {
|
|
328
|
+
if (result.addresses?.length && event.requestOptions) {
|
|
329
|
+
const approved = result.addresses.map(({ address, family }) => ({ address, family }));
|
|
330
|
+
event.requestOptions.autoSelectFamily = true;
|
|
331
|
+
event.requestOptions.lookup = (_hostname, options, callback) => {
|
|
332
|
+
if (options?.all) callback(null, approved);
|
|
333
|
+
else callback(null, approved[0].address, approved[0].family);
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
await event.setMock(new hammerhead.ResponseMock(
|
|
339
|
+
JSON.stringify({ error: 'browser target blocked', reason: result.reason }),
|
|
340
|
+
403,
|
|
341
|
+
{ 'content-type': 'application/json; charset=utf-8' },
|
|
342
|
+
));
|
|
343
|
+
},
|
|
344
|
+
}, () => {});
|
|
345
|
+
detachCookies = cookieProfiles.attach(deviceId, session.cookies);
|
|
346
|
+
const publicUrl = pool.proxy.openSession(target, session);
|
|
347
|
+
return {
|
|
348
|
+
key: leaseKey(deviceId, tabId),
|
|
349
|
+
tabId,
|
|
350
|
+
deviceId,
|
|
351
|
+
originalUrl: target,
|
|
352
|
+
url: publicUrl,
|
|
353
|
+
publicOrigin,
|
|
354
|
+
channel,
|
|
355
|
+
pool,
|
|
356
|
+
session,
|
|
357
|
+
policy,
|
|
358
|
+
detachCookies,
|
|
359
|
+
timer: null,
|
|
360
|
+
disposed: false,
|
|
361
|
+
};
|
|
362
|
+
} catch (error) {
|
|
363
|
+
detachCookies?.();
|
|
364
|
+
if (session) pool.proxy.closeSession(session);
|
|
365
|
+
releasePool(pool);
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const putImpl = async ({ tabId, deviceId, url, origin }, requireExisting = false) => {
|
|
371
|
+
if (!deviceId || !tabId) throw new Error('browser lease identity required');
|
|
372
|
+
const key = leaseKey(deviceId, tabId);
|
|
373
|
+
const existing = leases.get(key);
|
|
374
|
+
if (requireExisting && !existing) return null;
|
|
375
|
+
const target = normalizedTarget(url);
|
|
376
|
+
const publicOrigin = normalizedOrigin(origin);
|
|
377
|
+
if (existing && existing.originalUrl === target && existing.publicOrigin === publicOrigin) {
|
|
378
|
+
touch(existing);
|
|
379
|
+
return publicLease(existing);
|
|
380
|
+
}
|
|
381
|
+
const next = await createLease({
|
|
382
|
+
tabId,
|
|
383
|
+
deviceId,
|
|
384
|
+
url: target,
|
|
385
|
+
origin: publicOrigin,
|
|
386
|
+
channel: existing?.channel || randomChannel(),
|
|
387
|
+
});
|
|
388
|
+
leases.set(key, next);
|
|
389
|
+
touch(next);
|
|
390
|
+
if (existing) disposeLease(existing);
|
|
391
|
+
setDeviceActive(deviceId);
|
|
392
|
+
return publicLease(next);
|
|
393
|
+
};
|
|
394
|
+
const put = ({ tabId, deviceId, ...options }, requireExisting = false) => {
|
|
395
|
+
const key = leaseKey(deviceId, tabId);
|
|
396
|
+
return serializeLease(key, () => putImpl(
|
|
397
|
+
{ tabId, deviceId, ...options },
|
|
398
|
+
requireExisting,
|
|
399
|
+
));
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
rehomeNavigation = async (event, session) => {
|
|
403
|
+
const info = event?._requestInfo || {};
|
|
404
|
+
const headers = info.headers || {};
|
|
405
|
+
const destination = String(headers['sec-fetch-dest'] || headers['Sec-Fetch-Dest'] || '').toLowerCase();
|
|
406
|
+
const acceptsHtml = String(headers.accept || headers.Accept || '').toLowerCase().includes('text/html');
|
|
407
|
+
const topLevelDocument = !info.isAjax && !info.isIframe
|
|
408
|
+
&& (destination === 'document' || destination === 'iframe' || acceptsHtml);
|
|
409
|
+
if (!topLevelDocument) return false;
|
|
410
|
+
let target;
|
|
411
|
+
try { target = normalizedTarget(info.url); } catch { return false; }
|
|
412
|
+
const observed = [...leases.values()].find((lease) => lease.session === session);
|
|
413
|
+
if (!observed || new URL(target).origin === new URL(observed.originalUrl).origin) return false;
|
|
414
|
+
const origin = publicOriginFor(target);
|
|
415
|
+
if (!origin || !browserBootstrap) return false;
|
|
416
|
+
|
|
417
|
+
return serializeLease(observed.key, async () => {
|
|
418
|
+
const current = leases.get(observed.key);
|
|
419
|
+
if (!current || current.session !== session) return false;
|
|
420
|
+
const result = await current.policy.check(target);
|
|
421
|
+
const loopbackPortChange = isLoopbackUrl(current.originalUrl)
|
|
422
|
+
&& isLoopbackUrl(target)
|
|
423
|
+
&& new URL(current.originalUrl).origin !== new URL(target).origin;
|
|
424
|
+
if (!result.allowed || loopbackPortChange) {
|
|
425
|
+
await event.setMock(new hammerhead.ResponseMock(
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
error: 'browser target blocked',
|
|
428
|
+
reason: loopbackPortChange ? 'loopback-not-authorized' : result.reason,
|
|
429
|
+
}),
|
|
430
|
+
403,
|
|
431
|
+
{ 'content-type': 'application/json; charset=utf-8' },
|
|
432
|
+
));
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
const next = await createLease({
|
|
436
|
+
tabId: current.tabId,
|
|
437
|
+
deviceId: current.deviceId,
|
|
438
|
+
url: target,
|
|
439
|
+
origin,
|
|
440
|
+
channel: current.channel,
|
|
441
|
+
});
|
|
442
|
+
try {
|
|
443
|
+
const bootstrapUrl = browserBootstrap.issue({
|
|
444
|
+
url: next.url,
|
|
445
|
+
origin: next.publicOrigin,
|
|
446
|
+
deviceId: next.deviceId,
|
|
447
|
+
preserveMethod: true,
|
|
448
|
+
redirectStatus: 307,
|
|
449
|
+
});
|
|
450
|
+
await event.setMock(new hammerhead.ResponseMock(
|
|
451
|
+
'',
|
|
452
|
+
307,
|
|
453
|
+
hammerheadRebindHeaders(bootstrapUrl),
|
|
454
|
+
));
|
|
455
|
+
leases.set(current.key, next);
|
|
456
|
+
touch(next);
|
|
457
|
+
disposeLease(current);
|
|
458
|
+
return true;
|
|
459
|
+
} catch (error) {
|
|
460
|
+
disposeLease(next);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const sessionIdForPath = (pathname) => {
|
|
467
|
+
const descriptor = String(pathname || '').split('/')[1] || '';
|
|
468
|
+
const sessionId = descriptor.split(/[!*]/, 1)[0];
|
|
469
|
+
return sessionId.startsWith('_browser-') ? sessionId : null;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
putLease: (options) => put(options),
|
|
474
|
+
navigateLease(tabId, url, deviceId, origin) {
|
|
475
|
+
return put({ tabId, url, deviceId, origin }, true);
|
|
476
|
+
},
|
|
477
|
+
getLease(tabId, deviceId) {
|
|
478
|
+
return publicLease(leases.get(leaseKey(deviceId, tabId)));
|
|
479
|
+
},
|
|
480
|
+
deleteLease(tabId, deviceId) {
|
|
481
|
+
return release(leases.get(leaseKey(deviceId, tabId)));
|
|
482
|
+
},
|
|
483
|
+
hasDevice(deviceId) {
|
|
484
|
+
return [...leases.values()].some((lease) => lease.deviceId === deviceId);
|
|
485
|
+
},
|
|
486
|
+
ownsPublicPath(pathname, deviceId) {
|
|
487
|
+
const sessionId = sessionIdForPath(pathname);
|
|
488
|
+
return [...leases.values()].some((lease) => (
|
|
489
|
+
lease.deviceId === deviceId && lease.session.id === sessionId
|
|
490
|
+
));
|
|
491
|
+
},
|
|
492
|
+
resolvePublicRequest(pathname, deviceId, rawOrigin) {
|
|
493
|
+
let origin;
|
|
494
|
+
try { origin = normalizedOrigin(rawOrigin); } catch { return null; }
|
|
495
|
+
const sessionId = sessionIdForPath(pathname);
|
|
496
|
+
const lease = [...leases.values()].find((item) => (
|
|
497
|
+
item.deviceId === deviceId
|
|
498
|
+
&& item.publicOrigin === origin
|
|
499
|
+
&& (!sessionId || item.session.id === sessionId)
|
|
500
|
+
));
|
|
501
|
+
if (!lease) return null;
|
|
502
|
+
touch(lease);
|
|
503
|
+
return { port: lease.pool.ports[0], origin: lease.publicOrigin };
|
|
504
|
+
},
|
|
505
|
+
configureDeviceProfile(deviceId, prefs) {
|
|
506
|
+
return cookieProfiles.configure(deviceId, prefs);
|
|
507
|
+
},
|
|
508
|
+
async clearDeviceProfile(deviceId, { origin } = {}) {
|
|
509
|
+
await cookieProfiles.clear(deviceId, {
|
|
510
|
+
hostname: origin === null ? undefined : new URL(origin).hostname,
|
|
511
|
+
});
|
|
512
|
+
await cookieProfiles.flush?.(deviceId);
|
|
513
|
+
return { closedTabIds: [] };
|
|
514
|
+
},
|
|
515
|
+
async close() {
|
|
516
|
+
if (closing) return;
|
|
517
|
+
closing = true;
|
|
518
|
+
for (const lease of [...leases.values()]) release(lease);
|
|
519
|
+
await Promise.allSettled([...pendingPools.values(), ...leaseQueues.values()]);
|
|
520
|
+
for (const lease of [...leases.values()]) release(lease);
|
|
521
|
+
for (const pool of pools.values()) {
|
|
522
|
+
if (pool.closeTimer != null) clearTimer(pool.closeTimer);
|
|
523
|
+
pool.closeTimer = null;
|
|
524
|
+
pool.proxy.close();
|
|
525
|
+
}
|
|
526
|
+
pools.clear();
|
|
527
|
+
await cookieProfiles.close?.();
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function browserLabelForOrigin(rawOrigin) {
|
|
4
|
+
const origin = new URL(rawOrigin).origin;
|
|
5
|
+
const value = createHash('sha256').update(origin).digest().readBigUInt64BE(0);
|
|
6
|
+
return `b-${value.toString(36).padStart(13, '0')}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function claimPublicOrigin(claims, publicOrigin, targetOrigin) {
|
|
10
|
+
const normalizedPublic = new URL(publicOrigin).origin;
|
|
11
|
+
const normalizedTarget = new URL(targetOrigin).origin;
|
|
12
|
+
const existing = claims.get(normalizedPublic);
|
|
13
|
+
if (existing && existing !== normalizedTarget) {
|
|
14
|
+
throw new Error('browser public origin collision');
|
|
15
|
+
}
|
|
16
|
+
claims.set(normalizedPublic, normalizedTarget);
|
|
17
|
+
}
|