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,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes as nodeRandomBytes,
|
|
6
|
+
} from 'node:crypto';
|
|
7
|
+
import defaultFs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const AUTHENTICATION_ERROR = 'browser profile authentication failed';
|
|
11
|
+
|
|
12
|
+
function profileHash(deviceId) {
|
|
13
|
+
return createHash('sha256').update(deviceId).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function authenticationError() {
|
|
17
|
+
return new Error(AUTHENTICATION_ERROR);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createBrowserProfilePersistence({
|
|
21
|
+
dir,
|
|
22
|
+
keyFile,
|
|
23
|
+
fs = defaultFs,
|
|
24
|
+
randomBytes = nodeRandomBytes,
|
|
25
|
+
}) {
|
|
26
|
+
let keyPromise = null;
|
|
27
|
+
const pending = new Set();
|
|
28
|
+
|
|
29
|
+
const ensureDir = async () => {
|
|
30
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
31
|
+
await fs.chmod(dir, 0o700);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const loadKey = async () => {
|
|
35
|
+
await ensureDir();
|
|
36
|
+
try {
|
|
37
|
+
await fs.chmod(keyFile, 0o600);
|
|
38
|
+
const key = await fs.readFile(keyFile);
|
|
39
|
+
if (key.length !== 32) throw authenticationError();
|
|
40
|
+
return key;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const key = randomBytes(32);
|
|
46
|
+
let handle;
|
|
47
|
+
try {
|
|
48
|
+
handle = await fs.open(keyFile, 'wx', 0o600);
|
|
49
|
+
await handle.writeFile(key);
|
|
50
|
+
await handle.sync();
|
|
51
|
+
await handle.close();
|
|
52
|
+
handle = null;
|
|
53
|
+
return key;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
await handle?.close().catch(() => {});
|
|
56
|
+
if (error?.code === 'EEXIST') {
|
|
57
|
+
await fs.chmod(keyFile, 0o600);
|
|
58
|
+
const existing = await fs.readFile(keyFile);
|
|
59
|
+
if (existing.length !== 32) throw authenticationError();
|
|
60
|
+
return existing;
|
|
61
|
+
}
|
|
62
|
+
await fs.unlink(keyFile).catch(() => {});
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const key = () => {
|
|
68
|
+
if (!keyPromise) keyPromise = loadKey();
|
|
69
|
+
return keyPromise;
|
|
70
|
+
};
|
|
71
|
+
const profilePath = (deviceId) => path.join(dir, `${profileHash(deviceId)}.profile`);
|
|
72
|
+
const metadataPath = (deviceId) => path.join(dir, `${profileHash(deviceId)}.meta`);
|
|
73
|
+
|
|
74
|
+
const track = (operation) => {
|
|
75
|
+
pending.add(operation);
|
|
76
|
+
operation.then(
|
|
77
|
+
() => pending.delete(operation),
|
|
78
|
+
() => pending.delete(operation),
|
|
79
|
+
);
|
|
80
|
+
return operation;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const read = async (deviceId) => {
|
|
84
|
+
let raw;
|
|
85
|
+
const target = profilePath(deviceId);
|
|
86
|
+
await ensureDir();
|
|
87
|
+
try {
|
|
88
|
+
await fs.chmod(target, 0o600);
|
|
89
|
+
raw = await fs.readFile(target, 'utf8');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code === 'ENOENT') return null;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const deviceHash = profileHash(deviceId);
|
|
97
|
+
const envelope = JSON.parse(raw);
|
|
98
|
+
if (envelope?.v !== 1
|
|
99
|
+
|| typeof envelope.iv !== 'string'
|
|
100
|
+
|| typeof envelope.tag !== 'string'
|
|
101
|
+
|| typeof envelope.data !== 'string') {
|
|
102
|
+
throw authenticationError();
|
|
103
|
+
}
|
|
104
|
+
const iv = Buffer.from(envelope.iv, 'base64');
|
|
105
|
+
const tag = Buffer.from(envelope.tag, 'base64');
|
|
106
|
+
if (iv.length !== 12 || tag.length !== 16) throw authenticationError();
|
|
107
|
+
const decipher = createDecipheriv('aes-256-gcm', await key(), iv);
|
|
108
|
+
decipher.setAAD(Buffer.from(`handmux-browser-profile:v1:${deviceHash}`));
|
|
109
|
+
decipher.setAuthTag(tag);
|
|
110
|
+
return Buffer.concat([
|
|
111
|
+
decipher.update(Buffer.from(envelope.data, 'base64')),
|
|
112
|
+
decipher.final(),
|
|
113
|
+
]).toString('utf8');
|
|
114
|
+
} catch {
|
|
115
|
+
throw authenticationError();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const write = (deviceId, serializedJar) => track((async () => {
|
|
120
|
+
const deviceHash = profileHash(deviceId);
|
|
121
|
+
const iv = randomBytes(12);
|
|
122
|
+
const cipher = createCipheriv('aes-256-gcm', await key(), iv);
|
|
123
|
+
cipher.setAAD(Buffer.from(`handmux-browser-profile:v1:${deviceHash}`));
|
|
124
|
+
const encrypted = Buffer.concat([
|
|
125
|
+
cipher.update(serializedJar, 'utf8'),
|
|
126
|
+
cipher.final(),
|
|
127
|
+
]);
|
|
128
|
+
const envelope = {
|
|
129
|
+
v: 1,
|
|
130
|
+
iv: iv.toString('base64'),
|
|
131
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
132
|
+
data: encrypted.toString('base64'),
|
|
133
|
+
};
|
|
134
|
+
const target = profilePath(deviceId);
|
|
135
|
+
const temp = `${target}.tmp-${process.pid}-${randomBytes(12).toString('hex')}`;
|
|
136
|
+
let handle;
|
|
137
|
+
try {
|
|
138
|
+
handle = await fs.open(temp, 'wx', 0o600);
|
|
139
|
+
await handle.writeFile(JSON.stringify(envelope), 'utf8');
|
|
140
|
+
await handle.sync();
|
|
141
|
+
await handle.close();
|
|
142
|
+
handle = null;
|
|
143
|
+
await fs.rename(temp, target);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
await handle?.close().catch(() => {});
|
|
146
|
+
await fs.unlink(temp).catch(() => {});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
})());
|
|
150
|
+
|
|
151
|
+
const remove = (deviceId) => track(
|
|
152
|
+
fs.unlink(profilePath(deviceId)).catch((error) => {
|
|
153
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const readMetadata = async (deviceId) => {
|
|
158
|
+
const target = metadataPath(deviceId);
|
|
159
|
+
await ensureDir();
|
|
160
|
+
try {
|
|
161
|
+
await fs.chmod(target, 0o600);
|
|
162
|
+
const value = JSON.parse(await fs.readFile(target, 'utf8'));
|
|
163
|
+
if (typeof value?.persist !== 'boolean'
|
|
164
|
+
|| ![1, 7, 30, null].includes(value?.retentionDays)
|
|
165
|
+
|| (value?.noLeaseSince !== null && !Number.isFinite(value?.noLeaseSince))) {
|
|
166
|
+
throw new Error('invalid browser profile metadata');
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error?.code === 'ENOENT') return null;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const writeMetadata = (deviceId, metadata) => track((async () => {
|
|
176
|
+
await ensureDir();
|
|
177
|
+
const target = metadataPath(deviceId);
|
|
178
|
+
const temp = `${target}.tmp-${process.pid}-${randomBytes(12).toString('hex')}`;
|
|
179
|
+
let handle;
|
|
180
|
+
try {
|
|
181
|
+
handle = await fs.open(temp, 'wx', 0o600);
|
|
182
|
+
await handle.writeFile(JSON.stringify(metadata), 'utf8');
|
|
183
|
+
await handle.sync();
|
|
184
|
+
await handle.close();
|
|
185
|
+
handle = null;
|
|
186
|
+
await fs.rename(temp, target);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
await handle?.close().catch(() => {});
|
|
189
|
+
await fs.unlink(temp).catch(() => {});
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
})());
|
|
193
|
+
|
|
194
|
+
const removeMetadata = (deviceId) => track(
|
|
195
|
+
fs.unlink(metadataPath(deviceId)).catch((error) => {
|
|
196
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const pruneExpiredProfiles = (currentTime = Date.now()) => track((async () => {
|
|
201
|
+
await ensureDir();
|
|
202
|
+
const names = await fs.readdir(dir);
|
|
203
|
+
let removed = 0;
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
const match = name.match(/^([0-9a-f]{64})\.meta$/);
|
|
206
|
+
if (!match) continue;
|
|
207
|
+
const metadataFile = path.join(dir, name);
|
|
208
|
+
let metadata;
|
|
209
|
+
try {
|
|
210
|
+
metadata = JSON.parse(await fs.readFile(metadataFile, 'utf8'));
|
|
211
|
+
} catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!metadata?.persist
|
|
215
|
+
|| metadata.retentionDays === null
|
|
216
|
+
|| ![1, 7, 30].includes(metadata.retentionDays)
|
|
217
|
+
|| !Number.isFinite(metadata.noLeaseSince)) continue;
|
|
218
|
+
if (metadata.noLeaseSince + metadata.retentionDays * 24 * 60 * 60 * 1000 > currentTime) continue;
|
|
219
|
+
await Promise.all([
|
|
220
|
+
fs.unlink(path.join(dir, `${match[1]}.profile`)).catch((error) => {
|
|
221
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
222
|
+
}),
|
|
223
|
+
fs.unlink(metadataFile).catch((error) => {
|
|
224
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
225
|
+
}),
|
|
226
|
+
]);
|
|
227
|
+
removed += 1;
|
|
228
|
+
}
|
|
229
|
+
return removed;
|
|
230
|
+
})());
|
|
231
|
+
|
|
232
|
+
const close = async () => {
|
|
233
|
+
const results = await Promise.allSettled([...pending]);
|
|
234
|
+
const failed = results.find((result) => result.status === 'rejected');
|
|
235
|
+
if (failed) throw failed.reason;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
read, write, remove, readMetadata, writeMetadata, removeMetadata, pruneExpiredProfiles, close,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const BROWSER_INTERNAL_HEADER = 'x-handmux-browser-internal';
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { isBrowserBootstrapPath } from './bootstrap.js';
|
|
4
|
+
import { classifyIp } from './targetPolicy.js';
|
|
5
|
+
|
|
6
|
+
const SERVICE_PATHS = new Set([
|
|
7
|
+
'/hammerhead.js',
|
|
8
|
+
'/task.js',
|
|
9
|
+
'/iframe-task.js',
|
|
10
|
+
'/messaging',
|
|
11
|
+
'/transport-worker.js',
|
|
12
|
+
'/worker-hammerhead.js',
|
|
13
|
+
]);
|
|
14
|
+
const DEVICE_COOKIE = 'tw_browser_device';
|
|
15
|
+
|
|
16
|
+
export function isBrowserServicePath(pathname) {
|
|
17
|
+
return SERVICE_PATHS.has(String(pathname || '').split('?')[0]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cookieValue(raw, name) {
|
|
21
|
+
for (const part of String(raw || '').split(';')) {
|
|
22
|
+
const [key, ...rest] = part.trim().split('=');
|
|
23
|
+
if (key === name) return rest.join('=');
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function browserTarget(browser, req, deviceId) {
|
|
29
|
+
const pathname = String(req.url || '').split('?')[0];
|
|
30
|
+
if (!claimedBrowserRequest(req)) return null;
|
|
31
|
+
if (typeof browser.resolvePublicRequest === 'function') {
|
|
32
|
+
return browser.resolvePublicRequest(pathname, deviceId, browserRequestOrigin(req));
|
|
33
|
+
}
|
|
34
|
+
const allowed = isBrowserServicePath(pathname) ? browser.hasDevice(deviceId) : browser.ownsPublicPath(pathname, deviceId);
|
|
35
|
+
return allowed ? { port: browser.internalPorts[0] } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isLoopback(address) {
|
|
39
|
+
return classifyIp(address) === 'loopback';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function browserRequestOrigin(req) {
|
|
43
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim();
|
|
44
|
+
const protocol = isLoopback(req.socket?.remoteAddress) && (forwardedProto === 'http' || forwardedProto === 'https')
|
|
45
|
+
? forwardedProto
|
|
46
|
+
: (req.socket?.encrypted ? 'https' : 'http');
|
|
47
|
+
const host = req.headers.host;
|
|
48
|
+
if (!host) return null;
|
|
49
|
+
try { return new URL(`${protocol}://${host}`).origin; } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function claimedBrowserRequest(req) {
|
|
53
|
+
const pathname = String(req.url || '').split('?')[0];
|
|
54
|
+
return isBrowserBootstrapPath(pathname)
|
|
55
|
+
|| isBrowserServicePath(pathname)
|
|
56
|
+
|| String(pathname).split('/')[1]?.startsWith('_browser-');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function filteredCookie(raw) {
|
|
60
|
+
const values = String(raw || '')
|
|
61
|
+
.split(';')
|
|
62
|
+
.map((value) => value.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.filter((value) => !value.startsWith('tw_preview=') && !value.startsWith(`${DEVICE_COOKIE}=`));
|
|
65
|
+
return values.join('; ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function upstreamHeaders(headers, port, token) {
|
|
69
|
+
const out = { ...headers, host: `127.0.0.1:${port}` };
|
|
70
|
+
if (token && out.authorization === `Bearer ${token}`) delete out.authorization;
|
|
71
|
+
delete out['proxy-authorization'];
|
|
72
|
+
if (out.cookie) {
|
|
73
|
+
out.cookie = filteredCookie(out.cookie);
|
|
74
|
+
if (!out.cookie) delete out.cookie;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createBrowserPublicProxy({
|
|
80
|
+
browser,
|
|
81
|
+
browserBootstrap,
|
|
82
|
+
token,
|
|
83
|
+
request = http.request,
|
|
84
|
+
connect = net.connect,
|
|
85
|
+
} = {}) {
|
|
86
|
+
const handler = (req, res, next) => {
|
|
87
|
+
if (!browser) return next();
|
|
88
|
+
const pathname = String(req.url || '').split('?')[0];
|
|
89
|
+
if (isBrowserBootstrapPath(pathname)) {
|
|
90
|
+
const origin = browserRequestOrigin(req);
|
|
91
|
+
const bootstrap = browserBootstrap?.consume(pathname, origin);
|
|
92
|
+
if (!bootstrap) return res.status(403).json({ error: 'browser bootstrap unavailable' });
|
|
93
|
+
const secure = origin?.startsWith('https://') ? '; Secure' : '';
|
|
94
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
95
|
+
res.setHeader('Set-Cookie', `${DEVICE_COOKIE}=${bootstrap.deviceId}; Path=/; HttpOnly; SameSite=Strict${secure}`);
|
|
96
|
+
return res.redirect(
|
|
97
|
+
bootstrap.preserveMethod ? (bootstrap.redirectStatus || 307) : 302,
|
|
98
|
+
bootstrap.url,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const deviceId = cookieValue(req.headers.cookie, DEVICE_COOKIE);
|
|
102
|
+
const target = browserTarget(browser, req, deviceId);
|
|
103
|
+
if (!target) {
|
|
104
|
+
if (claimedBrowserRequest(req)) return res.status(403).json({ error: 'browser session unavailable' });
|
|
105
|
+
return next();
|
|
106
|
+
}
|
|
107
|
+
const { port } = target;
|
|
108
|
+
const upstream = request({
|
|
109
|
+
hostname: '127.0.0.1',
|
|
110
|
+
port,
|
|
111
|
+
method: req.method,
|
|
112
|
+
path: req.originalUrl || req.url,
|
|
113
|
+
headers: upstreamHeaders(req.headers, port, token),
|
|
114
|
+
}, (incoming) => {
|
|
115
|
+
res.writeHead(incoming.statusCode || 502, incoming.headers);
|
|
116
|
+
incoming.pipe(res);
|
|
117
|
+
res.once('close', () => { if (!res.writableEnded) incoming.destroy(); });
|
|
118
|
+
});
|
|
119
|
+
const abort = () => upstream.destroy();
|
|
120
|
+
req.once('aborted', abort);
|
|
121
|
+
res.once('close', () => { if (!res.writableEnded) abort(); });
|
|
122
|
+
upstream.on('error', () => {
|
|
123
|
+
if (res.destroyed) return;
|
|
124
|
+
if (!res.headersSent) res.status(502).json({ error: 'browser proxy unavailable' });
|
|
125
|
+
else res.destroy();
|
|
126
|
+
});
|
|
127
|
+
req.pipe(upstream);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const onUpgrade = (req, socket, head) => {
|
|
131
|
+
const deviceId = cookieValue(req.headers.cookie, DEVICE_COOKIE);
|
|
132
|
+
const target = browser && browserTarget(browser, req, deviceId);
|
|
133
|
+
if (!target) return false;
|
|
134
|
+
const { port } = target;
|
|
135
|
+
const upstream = connect({ host: '127.0.0.1', port });
|
|
136
|
+
upstream.once('connect', () => {
|
|
137
|
+
const headers = upstreamHeaders(req.headers, port, token);
|
|
138
|
+
const lines = [`${req.method || 'GET'} ${req.url} HTTP/${req.httpVersion || '1.1'}`];
|
|
139
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
140
|
+
if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`);
|
|
141
|
+
else if (value != null) lines.push(`${name}: ${value}`);
|
|
142
|
+
}
|
|
143
|
+
upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
144
|
+
if (head?.length) upstream.write(head);
|
|
145
|
+
socket.pipe(upstream).pipe(socket);
|
|
146
|
+
});
|
|
147
|
+
upstream.once('error', () => socket.destroy());
|
|
148
|
+
socket.once('error', () => upstream.destroy());
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return { handler, onUpgrade };
|
|
153
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { browserLabelForOrigin } from './originLabel.js';
|
|
3
|
+
import { browserRequestOrigin } from './publicProxy.js';
|
|
4
|
+
|
|
5
|
+
const RETENTION_DAYS = new Set([1, 7, 30, null]);
|
|
6
|
+
const DEVICE_ID = /^[A-Za-z0-9_-]{32,128}$/;
|
|
7
|
+
const TAB_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
8
|
+
const DEVICE_COOKIE = 'tw_browser_device';
|
|
9
|
+
|
|
10
|
+
function previewBase(raw) {
|
|
11
|
+
if (!raw) return null;
|
|
12
|
+
const value = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
13
|
+
return new URL(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function wildcardOrigin(base, targetOrigin) {
|
|
17
|
+
const url = new URL(base.origin);
|
|
18
|
+
url.hostname = `${browserLabelForOrigin(targetOrigin)}.${base.hostname}`;
|
|
19
|
+
return url.origin;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizedTarget(value) {
|
|
23
|
+
if (typeof value !== 'string' || !value) return null;
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(value);
|
|
26
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizedHttpOrigin(value) {
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(value);
|
|
35
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function browserRoutes({
|
|
42
|
+
browser,
|
|
43
|
+
previewDomain = null,
|
|
44
|
+
browserBootstrap = null,
|
|
45
|
+
}) {
|
|
46
|
+
const router = express.Router();
|
|
47
|
+
const publicBase = previewBase(previewDomain);
|
|
48
|
+
|
|
49
|
+
router.use((req, res, next) => {
|
|
50
|
+
const deviceId = req.get('x-handmux-browser-device');
|
|
51
|
+
if (!DEVICE_ID.test(deviceId || '')) return res.status(400).json({ error: 'browser device id required' });
|
|
52
|
+
req.browserDeviceId = deviceId;
|
|
53
|
+
const origin = browserRequestOrigin(req);
|
|
54
|
+
const secure = origin?.startsWith('https://') ? '; Secure' : '';
|
|
55
|
+
res.append('Set-Cookie', `${DEVICE_COOKIE}=${deviceId}; Path=/; HttpOnly; SameSite=Strict${secure}`);
|
|
56
|
+
next();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const responseLease = (lease, deviceId) => {
|
|
60
|
+
if (!lease) return null;
|
|
61
|
+
const url = browserBootstrap.issue({
|
|
62
|
+
url: lease.url,
|
|
63
|
+
origin: new URL(lease.url).origin,
|
|
64
|
+
deviceId,
|
|
65
|
+
});
|
|
66
|
+
return { ...lease, url };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
router.put('/leases/:tabId', async (req, res, next) => {
|
|
70
|
+
if (!browser || !publicBase) return res.status(503).json({ error: 'browser proxy unavailable' });
|
|
71
|
+
if (!TAB_ID.test(req.params.tabId)) return res.status(400).json({ error: 'bad browser tab id' });
|
|
72
|
+
const url = normalizedTarget(req.body?.url);
|
|
73
|
+
if (!url) return res.status(400).json({ error: 'browser URL must use http or https' });
|
|
74
|
+
try {
|
|
75
|
+
const origin = wildcardOrigin(publicBase, new URL(url).origin);
|
|
76
|
+
const lease = await browser.putLease({
|
|
77
|
+
tabId: req.params.tabId,
|
|
78
|
+
url: req.body.url,
|
|
79
|
+
origin,
|
|
80
|
+
deviceId: req.browserDeviceId,
|
|
81
|
+
});
|
|
82
|
+
return res.json(responseLease(lease, req.browserDeviceId));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return next(error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
router.post('/leases/:tabId/navigate', async (req, res, next) => {
|
|
89
|
+
if (!browser || !publicBase) return res.status(503).json({ error: 'browser proxy unavailable' });
|
|
90
|
+
if (!TAB_ID.test(req.params.tabId)) return res.status(400).json({ error: 'bad browser tab id' });
|
|
91
|
+
const url = normalizedTarget(req.body?.url);
|
|
92
|
+
if (!url) return res.status(400).json({ error: 'browser URL must use http or https' });
|
|
93
|
+
try {
|
|
94
|
+
const origin = wildcardOrigin(publicBase, new URL(url).origin);
|
|
95
|
+
const lease = await browser.navigateLease(
|
|
96
|
+
req.params.tabId,
|
|
97
|
+
req.body.url,
|
|
98
|
+
req.browserDeviceId,
|
|
99
|
+
origin,
|
|
100
|
+
);
|
|
101
|
+
if (!lease) return res.status(404).json({ error: 'browser proxy lease not found' });
|
|
102
|
+
return res.json(responseLease(lease, req.browserDeviceId));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return next(error);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
router.delete('/leases/:tabId', (req, res) => {
|
|
109
|
+
if (!browser) return res.status(503).json({ error: 'browser proxy unavailable' });
|
|
110
|
+
if (!browser.deleteLease(req.params.tabId, req.browserDeviceId)) {
|
|
111
|
+
return res.status(404).json({ error: 'browser proxy lease not found' });
|
|
112
|
+
}
|
|
113
|
+
return res.status(204).end();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
router.put('/profile', async (req, res, next) => {
|
|
117
|
+
const { persist, retentionDays } = req.body || {};
|
|
118
|
+
if (typeof persist !== 'boolean' || !RETENTION_DAYS.has(retentionDays)) {
|
|
119
|
+
return res.status(400).json({ error: 'bad browser profile preferences' });
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return res.json(await browser.configureDeviceProfile(
|
|
123
|
+
req.browserDeviceId,
|
|
124
|
+
{ persist, retentionDays },
|
|
125
|
+
));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return next(error);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
router.post('/profile/clear', async (req, res, next) => {
|
|
132
|
+
const rawOrigin = req.body?.origin;
|
|
133
|
+
const origin = rawOrigin === null ? null : normalizedHttpOrigin(rawOrigin);
|
|
134
|
+
if (origin === null && rawOrigin !== null) {
|
|
135
|
+
return res.status(400).json({ error: 'bad browser profile clear request' });
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return res.json(await browser.clearDeviceProfile(req.browserDeviceId, { origin }));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return next(error);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return router;
|
|
145
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function createBrowserSessionStore({
|
|
2
|
+
now = Date.now,
|
|
3
|
+
setTimer = setTimeout,
|
|
4
|
+
clearTimer = clearTimeout,
|
|
5
|
+
onExpire = () => {},
|
|
6
|
+
} = {}) {
|
|
7
|
+
const tabs = new Map();
|
|
8
|
+
|
|
9
|
+
const cancel = (tab) => {
|
|
10
|
+
if (tab.timer != null) clearTimer(tab.timer);
|
|
11
|
+
tab.timer = null;
|
|
12
|
+
tab.hiddenAt = null;
|
|
13
|
+
tab.expiresAt = null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const remove = (id) => {
|
|
17
|
+
const tab = tabs.get(id);
|
|
18
|
+
if (!tab) return null;
|
|
19
|
+
if (tab.timer != null) clearTimer(tab.timer);
|
|
20
|
+
tabs.delete(id);
|
|
21
|
+
const { timer: _timer, ...out } = tab;
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const view = (tab) => {
|
|
26
|
+
if (!tab) return null;
|
|
27
|
+
const { timer: _timer, ...out } = tab;
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
add(input) {
|
|
33
|
+
if (tabs.has(input.id)) throw new Error(`browser tab already exists: ${input.id}`);
|
|
34
|
+
const mode = input.mode === 'direct' ? 'direct' : 'proxy';
|
|
35
|
+
const tab = { ...input, mode, visible: true, hiddenAt: null, expiresAt: null, timer: null };
|
|
36
|
+
tabs.set(tab.id, tab);
|
|
37
|
+
return view(tab);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
get(id) { return view(tabs.get(id)); },
|
|
41
|
+
|
|
42
|
+
list() { return [...tabs.values()].map(view); },
|
|
43
|
+
|
|
44
|
+
update(id, patch) {
|
|
45
|
+
const tab = tabs.get(id);
|
|
46
|
+
if (!tab) return null;
|
|
47
|
+
Object.assign(tab, patch);
|
|
48
|
+
return view(tab);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
setVisible(id, visible, closeAfterMinutes) {
|
|
52
|
+
const tab = tabs.get(id);
|
|
53
|
+
if (!tab) return null;
|
|
54
|
+
cancel(tab);
|
|
55
|
+
tab.visible = !!visible;
|
|
56
|
+
if (!tab.visible) {
|
|
57
|
+
tab.hiddenAt = now();
|
|
58
|
+
if (closeAfterMinutes != null) {
|
|
59
|
+
const delay = closeAfterMinutes * 60_000;
|
|
60
|
+
tab.expiresAt = tab.hiddenAt + delay;
|
|
61
|
+
tab.timer = setTimer(() => {
|
|
62
|
+
const expired = remove(id);
|
|
63
|
+
if (expired) onExpire(expired, 'expired');
|
|
64
|
+
}, delay);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return view(tab);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
remove,
|
|
71
|
+
|
|
72
|
+
close() {
|
|
73
|
+
const removed = [];
|
|
74
|
+
for (const id of [...tabs.keys()]) removed.push(remove(id));
|
|
75
|
+
return removed;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|