handmux 0.22.0 → 0.22.1

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/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-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>
51
+ <script type="module" crossorigin src="/assets/index-ClOByNCn.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-rjrtUwoJ.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-rjrtUwoJ.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
@@ -11,6 +11,12 @@ import {
11
11
  hammerheadRebindHeaders,
12
12
  installHammerheadRebindLocationCompat,
13
13
  } from './hammerheadRedirectCompat.js';
14
+ import {
15
+ applySiteVersionHeaders,
16
+ normalizeSiteVersion,
17
+ siteVersionIdentity,
18
+ siteVersionNavigatorScript,
19
+ } from './siteVersion.js';
14
20
 
15
21
  const defaultHammerhead = importedHammerhead.default || importedHammerhead;
16
22
  const POOL_IDLE_CLOSE_MS = 1_000;
@@ -32,9 +38,10 @@ function isLoopbackUrl(raw) {
32
38
  return hostname === 'localhost' || classifyIp(hostname) === 'loopback';
33
39
  }
34
40
 
35
- function bridgeScript(channel) {
41
+ function bridgeScript(channel, identity) {
36
42
  const encoded = JSON.stringify(channel);
37
- return `(() => {
43
+ return `${siteVersionNavigatorScript(identity)}
44
+ (() => {
38
45
  const channel = ${encoded};
39
46
  const hammerhead = window['%hammerhead%'];
40
47
  const destinationUrl = (url) => {
@@ -92,7 +99,7 @@ function bridgeScript(channel) {
92
99
 
93
100
  function browserSessionClass(hammerhead) {
94
101
  return class BrowserSession extends hammerhead.Session {
95
- constructor(channel) {
102
+ constructor(channel, identity) {
96
103
  super([], {
97
104
  disablePageCaching: true,
98
105
  allowMultipleWindows: true,
@@ -101,10 +108,11 @@ function browserSessionClass(hammerhead) {
101
108
  nativeAutomation: false,
102
109
  });
103
110
  this.channel = channel;
111
+ this.identity = identity;
104
112
  }
105
113
 
106
- async getPayloadScript() { return bridgeScript(this.channel); }
107
- async getIframePayloadScript() { return ''; }
114
+ async getPayloadScript() { return bridgeScript(this.channel, this.identity); }
115
+ async getIframePayloadScript() { return siteVersionNavigatorScript(this.identity); }
108
116
  getAuthCredentials() { return null; }
109
117
  handleAttachment() {}
110
118
  handleFileDownload() {}
@@ -148,6 +156,7 @@ function publicLease(lease) {
148
156
  url: lease.url,
149
157
  originalUrl: lease.originalUrl,
150
158
  channel: lease.channel,
159
+ siteVersion: lease.siteVersion,
151
160
  };
152
161
  }
153
162
 
@@ -305,7 +314,9 @@ export async function createBrowserPreviewManager({
305
314
  if (lease.timer != null) clearTimer(lease.timer);
306
315
  lease.timer = setTimer(() => release(lease), leaseTtlMs);
307
316
  };
308
- const createLease = async ({ tabId, deviceId, url, origin, channel }) => {
317
+ const createLease = async ({
318
+ tabId, deviceId, url, origin, channel, siteVersion, sourceUserAgent,
319
+ }) => {
309
320
  if (closing) throw new Error('browser manager closing');
310
321
  const target = normalizedTarget(url);
311
322
  const publicOrigin = normalizedOrigin(origin);
@@ -316,7 +327,8 @@ export async function createBrowserPreviewManager({
316
327
  let detachCookies = null;
317
328
  try {
318
329
  if (closing) throw new Error('browser manager closing');
319
- session = new SessionClass(channel);
330
+ const identity = siteVersionIdentity(siteVersion, sourceUserAgent);
331
+ session = new SessionClass(channel, identity);
320
332
  session.id = `_browser-${randomId()}-${encodeURIComponent(tabId)}`;
321
333
  const policy = targetPolicyFactory({ topLevelUrl: target, handmuxOrigin });
322
334
  const hooks = session.requestHookEventProvider || session;
@@ -325,6 +337,7 @@ export async function createBrowserPreviewManager({
325
337
  if (await rehomeNavigation(event, session)) return;
326
338
  const result = await policy.check(event._requestInfo.url);
327
339
  if (result.allowed) {
340
+ applySiteVersionHeaders(event.requestOptions?.headers, identity);
328
341
  if (result.addresses?.length && event.requestOptions) {
329
342
  const approved = result.addresses.map(({ address, family }) => ({ address, family }));
330
343
  event.requestOptions.autoSelectFamily = true;
@@ -352,6 +365,8 @@ export async function createBrowserPreviewManager({
352
365
  url: publicUrl,
353
366
  publicOrigin,
354
367
  channel,
368
+ siteVersion,
369
+ sourceUserAgent,
355
370
  pool,
356
371
  session,
357
372
  policy,
@@ -367,14 +382,21 @@ export async function createBrowserPreviewManager({
367
382
  }
368
383
  };
369
384
 
370
- const putImpl = async ({ tabId, deviceId, url, origin }, requireExisting = false) => {
385
+ const putImpl = async ({
386
+ tabId, deviceId, url, origin, siteVersion, sourceUserAgent,
387
+ }, requireExisting = false) => {
371
388
  if (!deviceId || !tabId) throw new Error('browser lease identity required');
372
389
  const key = leaseKey(deviceId, tabId);
373
390
  const existing = leases.get(key);
374
391
  if (requireExisting && !existing) return null;
375
392
  const target = normalizedTarget(url);
376
393
  const publicOrigin = normalizedOrigin(origin);
377
- if (existing && existing.originalUrl === target && existing.publicOrigin === publicOrigin) {
394
+ const requestedVersion = normalizeSiteVersion(siteVersion);
395
+ if (!requestedVersion) throw new Error('invalid browser site version');
396
+ if (existing
397
+ && existing.originalUrl === target
398
+ && existing.publicOrigin === publicOrigin
399
+ && existing.siteVersion === requestedVersion) {
378
400
  touch(existing);
379
401
  return publicLease(existing);
380
402
  }
@@ -384,6 +406,8 @@ export async function createBrowserPreviewManager({
384
406
  url: target,
385
407
  origin: publicOrigin,
386
408
  channel: existing?.channel || randomChannel(),
409
+ siteVersion: requestedVersion,
410
+ sourceUserAgent,
387
411
  });
388
412
  leases.set(key, next);
389
413
  touch(next);
@@ -438,6 +462,8 @@ export async function createBrowserPreviewManager({
438
462
  url: target,
439
463
  origin,
440
464
  channel: current.channel,
465
+ siteVersion: current.siteVersion,
466
+ sourceUserAgent: current.sourceUserAgent,
441
467
  });
442
468
  try {
443
469
  const bootstrapUrl = browserBootstrap.issue({
@@ -471,8 +497,8 @@ export async function createBrowserPreviewManager({
471
497
 
472
498
  return {
473
499
  putLease: (options) => put(options),
474
- navigateLease(tabId, url, deviceId, origin) {
475
- return put({ tabId, url, deviceId, origin }, true);
500
+ navigateLease(tabId, url, deviceId, origin, siteVersion, sourceUserAgent) {
501
+ return put({ tabId, url, deviceId, origin, siteVersion, sourceUserAgent }, true);
476
502
  },
477
503
  getLease(tabId, deviceId) {
478
504
  return publicLease(leases.get(leaseKey(deviceId, tabId)));
@@ -13,6 +13,18 @@ const SERVICE_PATHS = new Set([
13
13
  ]);
14
14
  const DEVICE_COOKIE = 'tw_browser_device';
15
15
 
16
+ function sessionChannel(pathname) {
17
+ const descriptor = String(pathname || '').split('/')[1] || '';
18
+ return descriptor.match(/\*([A-Za-z0-9_-]{1,128})$/)?.[1] || null;
19
+ }
20
+
21
+ function sessionUnavailableHtml(pathname) {
22
+ const channel = JSON.stringify(sessionChannel(pathname));
23
+ return `<!doctype html><meta charset="utf-8">
24
+ <script>parent.postMessage({source:'handmux-browser',channel:${channel},type:'session-unavailable'},'*')</script>
25
+ <pre>{"error":"browser session unavailable"}</pre>`;
26
+ }
27
+
16
28
  export function isBrowserServicePath(pathname) {
17
29
  return SERVICE_PATHS.has(String(pathname || '').split('?')[0]);
18
30
  }
@@ -65,6 +77,12 @@ function filteredCookie(raw) {
65
77
  return values.join('; ');
66
78
  }
67
79
 
80
+ function expectsDocument(req) {
81
+ const destination = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
82
+ const accept = String(req.headers.accept || '').toLowerCase();
83
+ return destination === 'document' || destination === 'iframe' || accept.includes('text/html');
84
+ }
85
+
68
86
  function upstreamHeaders(headers, port, token) {
69
87
  const out = { ...headers, host: `127.0.0.1:${port}` };
70
88
  if (token && out.authorization === `Bearer ${token}`) delete out.authorization;
@@ -101,7 +119,11 @@ export function createBrowserPublicProxy({
101
119
  const deviceId = cookieValue(req.headers.cookie, DEVICE_COOKIE);
102
120
  const target = browserTarget(browser, req, deviceId);
103
121
  if (!target) {
104
- if (claimedBrowserRequest(req)) return res.status(403).json({ error: 'browser session unavailable' });
122
+ if (claimedBrowserRequest(req)) {
123
+ res.setHeader('Cache-Control', 'no-store');
124
+ if (expectsDocument(req)) return res.status(403).type('html').send(sessionUnavailableHtml(pathname));
125
+ return res.status(403).json({ error: 'browser session unavailable' });
126
+ }
105
127
  return next();
106
128
  }
107
129
  const { port } = target;
@@ -1,6 +1,7 @@
1
1
  import express from 'express';
2
2
  import { browserLabelForOrigin } from './originLabel.js';
3
3
  import { browserRequestOrigin } from './publicProxy.js';
4
+ import { normalizeSiteVersion } from './siteVersion.js';
4
5
 
5
6
  const RETENTION_DAYS = new Set([1, 7, 30, null]);
6
7
  const DEVICE_ID = /^[A-Za-z0-9_-]{32,128}$/;
@@ -71,6 +72,8 @@ export function browserRoutes({
71
72
  if (!TAB_ID.test(req.params.tabId)) return res.status(400).json({ error: 'bad browser tab id' });
72
73
  const url = normalizedTarget(req.body?.url);
73
74
  if (!url) return res.status(400).json({ error: 'browser URL must use http or https' });
75
+ const siteVersion = normalizeSiteVersion(req.body?.siteVersion);
76
+ if (!siteVersion) return res.status(400).json({ error: 'bad browser site version' });
74
77
  try {
75
78
  const origin = wildcardOrigin(publicBase, new URL(url).origin);
76
79
  const lease = await browser.putLease({
@@ -78,6 +81,8 @@ export function browserRoutes({
78
81
  url: req.body.url,
79
82
  origin,
80
83
  deviceId: req.browserDeviceId,
84
+ siteVersion,
85
+ sourceUserAgent: req.get('user-agent') || '',
81
86
  });
82
87
  return res.json(responseLease(lease, req.browserDeviceId));
83
88
  } catch (error) {
@@ -90,6 +95,8 @@ export function browserRoutes({
90
95
  if (!TAB_ID.test(req.params.tabId)) return res.status(400).json({ error: 'bad browser tab id' });
91
96
  const url = normalizedTarget(req.body?.url);
92
97
  if (!url) return res.status(400).json({ error: 'browser URL must use http or https' });
98
+ const siteVersion = normalizeSiteVersion(req.body?.siteVersion);
99
+ if (!siteVersion) return res.status(400).json({ error: 'bad browser site version' });
93
100
  try {
94
101
  const origin = wildcardOrigin(publicBase, new URL(url).origin);
95
102
  const lease = await browser.navigateLease(
@@ -97,6 +104,8 @@ export function browserRoutes({
97
104
  req.body.url,
98
105
  req.browserDeviceId,
99
106
  origin,
107
+ siteVersion,
108
+ req.get('user-agent') || '',
100
109
  );
101
110
  if (!lease) return res.status(404).json({ error: 'browser proxy lease not found' });
102
111
  return res.json(responseLease(lease, req.browserDeviceId));
@@ -0,0 +1,124 @@
1
+ export const DEFAULT_SITE_VERSION = 'mobile';
2
+ export const SITE_VERSIONS = new Set(['mobile', 'desktop']);
3
+
4
+ export function normalizeSiteVersion(value, fallback = DEFAULT_SITE_VERSION) {
5
+ if (value == null || value === '') return fallback;
6
+ return SITE_VERSIONS.has(value) ? value : null;
7
+ }
8
+
9
+ function versionFrom(ua, pattern, fallback = '120.0.0.0') {
10
+ return ua.match(pattern)?.[1] || fallback;
11
+ }
12
+
13
+ function identityPlatform(ua, mobile) {
14
+ if (/iPhone|iPad|iPod/i.test(ua)) return { navigator: 'iPhone', clientHint: 'iOS' };
15
+ if (/Android/i.test(ua)) return { navigator: 'Linux armv8l', clientHint: 'Android' };
16
+ if (/Windows/i.test(ua)) return { navigator: 'Win32', clientHint: 'Windows' };
17
+ if (/Macintosh|Mac OS X/i.test(ua)) return { navigator: 'MacIntel', clientHint: 'macOS' };
18
+ return mobile
19
+ ? { navigator: 'Linux armv8l', clientHint: 'Android' }
20
+ : { navigator: 'Linux x86_64', clientHint: 'Linux' };
21
+ }
22
+
23
+ function isMobileUserAgent(ua) {
24
+ return /Android|Mobile|iPhone|iPad|iPod/i.test(ua);
25
+ }
26
+
27
+ function transformedUserAgent(source, mobile) {
28
+ const ua = String(source || '');
29
+ if (ua && isMobileUserAgent(ua) === mobile) return ua;
30
+
31
+ const webkit = versionFrom(ua, /AppleWebKit\/([\d.]+)/i, '605.1.15');
32
+ const safari = versionFrom(ua, /Version\/([\d.]+)/i, '18.0');
33
+ if (/Safari\//i.test(ua) && !/(?:Chrome|CriOS|Chromium|Edg|OPR)\//i.test(ua)) {
34
+ return mobile
35
+ ? `Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/${webkit} (KHTML, like Gecko) Version/${safari} Mobile/15E148 Safari/${webkit}`
36
+ : `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/${webkit} (KHTML, like Gecko) Version/${safari} Safari/${webkit}`;
37
+ }
38
+
39
+ const firefox = ua.match(/Firefox\/([\d.]+)/i)?.[1];
40
+ if (firefox) {
41
+ return mobile
42
+ ? `Mozilla/5.0 (Android 10; Mobile; rv:${firefox}) Gecko/${firefox} Firefox/${firefox}`
43
+ : `Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}) Gecko/20100101 Firefox/${firefox}`;
44
+ }
45
+
46
+ const chrome = versionFrom(ua, /(?:Chrome|CriOS|Chromium|Edg|OPR)\/([\d.]+)/i);
47
+ return mobile
48
+ ? `Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome} Mobile Safari/537.36`
49
+ : `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome} Safari/537.36`;
50
+ }
51
+
52
+ export function siteVersionIdentity(siteVersion, sourceUserAgent = '') {
53
+ const version = normalizeSiteVersion(siteVersion);
54
+ if (!version) throw new Error('invalid browser site version');
55
+ const mobile = version === 'mobile';
56
+ const userAgent = transformedUserAgent(sourceUserAgent, mobile);
57
+ const platform = identityPlatform(userAgent, mobile);
58
+ return {
59
+ version,
60
+ mobile,
61
+ userAgent,
62
+ appVersion: userAgent.replace(/^Mozilla\//, ''),
63
+ platform: platform.navigator,
64
+ clientHintPlatform: platform.clientHint,
65
+ maxTouchPoints: mobile ? 5 : 0,
66
+ };
67
+ }
68
+
69
+ function headerKey(headers, name) {
70
+ const lower = name.toLowerCase();
71
+ return Object.keys(headers || {}).find((key) => key.toLowerCase() === lower) || lower;
72
+ }
73
+
74
+ function setHeader(headers, name, value) {
75
+ headers[headerKey(headers, name)] = value;
76
+ }
77
+
78
+ function deleteHeader(headers, name) {
79
+ const key = Object.keys(headers || {}).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
80
+ if (key) delete headers[key];
81
+ }
82
+
83
+ export function applySiteVersionHeaders(headers, identity) {
84
+ if (!headers || !identity) return headers;
85
+ setHeader(headers, 'user-agent', identity.userAgent);
86
+ setHeader(headers, 'sec-ch-ua-mobile', identity.mobile ? '?1' : '?0');
87
+ setHeader(headers, 'sec-ch-ua-platform', `"${identity.clientHintPlatform}"`);
88
+ for (const name of [
89
+ 'sec-ch-ua-platform-version', 'sec-ch-ua-arch', 'sec-ch-ua-bitness',
90
+ 'sec-ch-ua-model', 'sec-ch-ua-wow64', 'sec-ch-ua-full-version',
91
+ 'sec-ch-ua-full-version-list',
92
+ ]) deleteHeader(headers, name);
93
+ return headers;
94
+ }
95
+
96
+ export function siteVersionNavigatorScript(identity) {
97
+ const encoded = JSON.stringify(identity).replaceAll('<', '\\u003c');
98
+ return `(() => {
99
+ const profile = ${encoded};
100
+ const define = (name, value) => {
101
+ try { Object.defineProperty(navigator, name, { configurable: true, get: () => value }); }
102
+ catch { /* the native value remains available */ }
103
+ };
104
+ define('userAgent', profile.userAgent);
105
+ define('appVersion', profile.appVersion);
106
+ define('platform', profile.platform);
107
+ define('maxTouchPoints', profile.maxTouchPoints);
108
+ const nativeData = navigator.userAgentData;
109
+ if (nativeData) {
110
+ const data = {
111
+ brands: nativeData.brands || [],
112
+ mobile: profile.mobile,
113
+ platform: profile.clientHintPlatform,
114
+ toJSON() { return { brands: this.brands, mobile: this.mobile, platform: this.platform }; },
115
+ async getHighEntropyValues(hints) {
116
+ let values = {};
117
+ try { values = await nativeData.getHighEntropyValues.call(nativeData, hints); } catch { /* optional API */ }
118
+ return { ...values, mobile: profile.mobile, platform: profile.clientHintPlatform };
119
+ },
120
+ };
121
+ define('userAgentData', data);
122
+ }
123
+ })();`;
124
+ }