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
@@ -1,487 +0,0 @@
1
- import { Buffer } from 'node:buffer';
2
- import http from 'node:http';
3
- import https from 'node:https';
4
- import net from 'node:net';
5
- import tls from 'node:tls';
6
- import dns from 'node:dns';
7
-
8
- const PREFIX = '/__handmux_proxy__/';
9
- const WS_PREFIX = '/__handmux_ws__/';
10
- const HTTP_PROTOCOLS = new Set(['http:', 'https:']);
11
- const WS_PROTOCOLS = new Set(['ws:', 'wss:']);
12
-
13
- function encodeOrigin(origin) {
14
- return Buffer.from(origin).toString('base64url');
15
- }
16
-
17
- function decodeOrigin(token) {
18
- try {
19
- const value = Buffer.from(token, 'base64url').toString();
20
- const url = new URL(value);
21
- return HTTP_PROTOCOLS.has(url.protocol) && url.href === `${url.origin}/` ? url.origin : null;
22
- } catch {
23
- return null;
24
- }
25
- }
26
-
27
- export function proxyPathFor(input) {
28
- const url = input instanceof URL ? input : new URL(input);
29
- if (!HTTP_PROTOCOLS.has(url.protocol)) return url.href;
30
- return `${PREFIX}${encodeOrigin(url.origin)}${url.pathname}${url.search}${url.hash}`;
31
- }
32
-
33
- export function decodeProxyPath(path) {
34
- if (typeof path !== 'string' || !path.startsWith(PREFIX)) return null;
35
- const rest = path.slice(PREFIX.length);
36
- const slash = rest.indexOf('/');
37
- if (slash < 0) return null;
38
- const origin = decodeOrigin(rest.slice(0, slash));
39
- if (!origin) return null;
40
- try {
41
- return new URL(rest.slice(slash) || '/', `${origin}/`);
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- export function proxyWebSocketPathFor(input, source) {
48
- const target = input instanceof URL ? input : new URL(input);
49
- const page = source instanceof URL ? source : new URL(source);
50
- if (!WS_PROTOCOLS.has(target.protocol) || !HTTP_PROTOCOLS.has(page.protocol)) throw new Error('bad websocket proxy URL');
51
- return `${WS_PREFIX}${encodeOrigin(page.origin)}/${Buffer.from(target.href).toString('base64url')}`;
52
- }
53
-
54
- export function decodeWebSocketPath(path) {
55
- if (typeof path !== 'string' || !path.startsWith(WS_PREFIX)) return null;
56
- const [sourceToken, targetToken] = path.slice(WS_PREFIX.length).split('/');
57
- const sourceOrigin = decodeOrigin(sourceToken);
58
- if (!sourceOrigin || !targetToken) return null;
59
- try {
60
- const target = new URL(Buffer.from(targetToken, 'base64url').toString());
61
- return WS_PROTOCOLS.has(target.protocol) ? { sourceOrigin, target } : null;
62
- } catch {
63
- return null;
64
- }
65
- }
66
-
67
- function rewrittenUrl(raw, baseUrl) {
68
- const value = String(raw).trim();
69
- if (!value || value.startsWith('#') || /^(?:data|blob|javascript|mailto|tel|about):/i.test(value)) return raw;
70
- try {
71
- const url = new URL(value, baseUrl);
72
- return HTTP_PROTOCOLS.has(url.protocol) ? proxyPathFor(url) : raw;
73
- } catch {
74
- return raw;
75
- }
76
- }
77
-
78
- function runtimeFor(pageUrl) {
79
- const virtual = JSON.stringify(pageUrl.href);
80
- return `<script data-handmux-internal-preview>(function(){\n`
81
- + `const V=${virtual},P=${JSON.stringify(PREFIX)};\n`
82
- + `const e=s=>btoa(unescape(encodeURIComponent(s))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');\n`
83
- + `const p=(v,b=V)=>{try{const u=new URL(typeof v==='string'?v:v.url,b);if(u.origin===location.origin&&u.pathname.startsWith(P))return u.href;return /^https?:$/.test(u.protocol)?P+e(u.origin)+u.pathname+u.search+u.hash:v}catch{return v}};\n`
84
- + `const f=window.fetch;window.fetch=(v,o)=>v instanceof Request?f.call(window,new Request(p(v),v),o):f.call(window,p(v),o);\n`
85
- + `const xo=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u,...a){return xo.call(this,m,p(u),...a)};\n`
86
- + `const W=window.WebSocket;window.WebSocket=function(u,pv){const x=new URL(u,V);if(/^https?:$/.test(x.protocol))x.protocol=x.protocol==='https:'?'wss:':'ws:';const s=(location.protocol==='https:'?'wss://':'ws://')+location.host+${JSON.stringify(WS_PREFIX)}+e(new URL(V).origin)+'/'+e(x.href);return pv===undefined?new W(s):new W(s,pv)};window.WebSocket.prototype=W.prototype;['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(k=>Object.defineProperty(window.WebSocket,k,{value:W[k]}));\n`
87
- + `document.addEventListener('click',e=>{const a=e.target.closest&&e.target.closest('a[href]');if(a)a.href=p(a.href)},true);\n`
88
- + `document.addEventListener('submit',e=>{if(e.target&&e.target.action)e.target.action=p(e.target.action)},true);\n`
89
- + `})();</script>`;
90
- }
91
-
92
- export function rewriteHtml(html, pageUrl) {
93
- const base = pageUrl instanceof URL ? pageUrl : new URL(pageUrl);
94
- let out = String(html).replace(
95
- /\bsrcset\s*=\s*(["'])(.*?)\1/gi,
96
- (all, quote, value) => `srcset=${quote}${value.split(',').map((candidate) => {
97
- const trimmed = candidate.trim();
98
- const space = trimmed.search(/\s/);
99
- const url = space < 0 ? trimmed : trimmed.slice(0, space);
100
- const descriptor = space < 0 ? '' : trimmed.slice(space);
101
- return `${rewrittenUrl(url, base)}${descriptor}`;
102
- }).join(', ')}${quote}`,
103
- ).replace(
104
- /\b(href|src|action|poster)\s*=\s*(["'])(.*?)\2/gi,
105
- (all, attr, quote, value) => `${attr}=${quote}${rewrittenUrl(value, base)}${quote}`,
106
- );
107
- const runtime = runtimeFor(base);
108
- if (/<head(?:\s[^>]*)?>/i.test(out)) return out.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}${runtime}`);
109
- return runtime + out;
110
- }
111
-
112
- export function rewriteCss(css, stylesheetUrl) {
113
- const base = stylesheetUrl instanceof URL ? stylesheetUrl : new URL(stylesheetUrl);
114
- return String(css)
115
- .replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi, (all, quote, value) => `url(${quote}${rewrittenUrl(value, base)}${quote})`)
116
- .replace(/@import\s+(["'])(.*?)\1/gi, (all, quote, value) => `@import ${quote}${rewrittenUrl(value, base)}${quote}`);
117
- }
118
-
119
- function rewriteLoginUrlJson(json, target) {
120
- const source = String(json);
121
- if (!/\/auth\/login-url\/?$/i.test(target.pathname)) return source;
122
- try {
123
- const body = JSON.parse(source);
124
- if (!body || typeof body !== 'object' || typeof body.data !== 'string' || !/^https?:\/\//i.test(body.data)) return source;
125
- body.data = proxyPathFor(new URL(body.data));
126
- return JSON.stringify(body);
127
- } catch {
128
- return source;
129
- }
130
- }
131
-
132
- function defaultPath(url) {
133
- const i = url.pathname.lastIndexOf('/');
134
- return i <= 0 ? '/' : url.pathname.slice(0, i + 1);
135
- }
136
-
137
- function domainMatches(host, domain) {
138
- return host === domain || host.endsWith(`.${domain}`);
139
- }
140
-
141
- function pathMatches(pathname, cookiePath) {
142
- if (pathname === cookiePath) return true;
143
- if (!pathname.startsWith(cookiePath)) return false;
144
- return cookiePath.endsWith('/') || pathname[cookiePath.length] === '/';
145
- }
146
-
147
- export function isBlockedGatewayAddress(address) {
148
- const normalized = String(address).toLowerCase().replace(/^\[|\]$/g, '').split('%')[0];
149
- if (net.isIP(normalized) === 4) {
150
- const octets = normalized.split('.').map(Number);
151
- return octets[0] === 0 || octets[0] === 127
152
- || (octets[0] === 169 && octets[1] === 254)
153
- || octets[0] >= 224;
154
- }
155
- if (net.isIP(normalized) === 6) {
156
- if (normalized === '::' || normalized === '::1') return true;
157
- if (/^fe[89ab]/.test(normalized)) return true;
158
- if (normalized.startsWith('ff')) return true;
159
- if (normalized === 'fd00:ec2::254') return true;
160
- const mappedDotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized);
161
- if (mappedDotted) return isBlockedGatewayAddress(mappedDotted[1]);
162
- const mappedHex = /^(?:::ffff|(?:0+:){5}ffff):([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
163
- if (mappedHex) {
164
- const high = Number.parseInt(mappedHex[1], 16);
165
- const low = Number.parseInt(mappedHex[2], 16);
166
- return isBlockedGatewayAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
167
- }
168
- return false;
169
- }
170
- return false;
171
- }
172
-
173
- function restrictedLookup(dnsLookup, { allowLoopback = false } = {}) {
174
- return (hostname, options, callback) => {
175
- dnsLookup(hostname, { family: options?.family || 0, hints: options?.hints || 0, all: true }, (error, addresses) => {
176
- if (error) return callback(error);
177
- const safe = addresses.find((item) => allowLoopback || !isBlockedGatewayAddress(item.address));
178
- if (!safe) {
179
- const denied = new Error(`blocked internal preview address for ${hostname}`);
180
- denied.code = 'EACCES';
181
- return callback(denied);
182
- }
183
- if (options?.all) callback(null, [{ address: safe.address, family: safe.family }]);
184
- else callback(null, safe.address, safe.family);
185
- });
186
- };
187
- }
188
-
189
- export class MemoryCookieJar {
190
- constructor({ now = () => Date.now(), trustedDomains = [] } = {}) {
191
- this.now = now;
192
- this.trustedDomains = new Set(trustedDomains.map((domain) => domain.replace(/^\./, '').toLowerCase()));
193
- this.cookies = [];
194
- }
195
-
196
- store(header, source) {
197
- if (!header) return;
198
- const url = source instanceof URL ? source : new URL(source);
199
- const parts = String(header).split(';').map((part) => part.trim());
200
- const first = parts.shift();
201
- const equals = first.indexOf('=');
202
- if (equals <= 0) return;
203
- const cookie = {
204
- name: first.slice(0, equals), value: first.slice(equals + 1),
205
- domain: url.hostname.toLowerCase(), hostOnly: true, path: defaultPath(url),
206
- secure: false, expiresAt: null,
207
- };
208
- for (const part of parts) {
209
- const [rawName, ...valueParts] = part.split('=');
210
- const name = rawName.toLowerCase();
211
- const value = valueParts.join('=').trim();
212
- if (name === 'domain') {
213
- const domain = value.replace(/^\./, '').toLowerCase();
214
- const trusted = [...this.trustedDomains].some((root) => domain === root || domain.endsWith(`.${root}`));
215
- if (!trusted || !domainMatches(url.hostname.toLowerCase(), domain)) return;
216
- cookie.domain = domain;
217
- cookie.hostOnly = false;
218
- } else if (name === 'path' && value.startsWith('/')) cookie.path = value;
219
- else if (name === 'secure') cookie.secure = true;
220
- else if (name === 'max-age' && /^-?\d+$/.test(value)) cookie.expiresAt = this.now() + Number(value) * 1000;
221
- else if (name === 'expires') {
222
- const parsed = Date.parse(value);
223
- if (!Number.isNaN(parsed)) cookie.expiresAt = parsed;
224
- }
225
- }
226
- this.cookies = this.cookies.filter((item) => !(item.name === cookie.name && item.domain === cookie.domain && item.path === cookie.path));
227
- if (cookie.expiresAt == null || cookie.expiresAt > this.now()) this.cookies.push(cookie);
228
- }
229
-
230
- header(input) {
231
- const url = input instanceof URL ? input : new URL(input);
232
- const now = this.now();
233
- this.cookies = this.cookies.filter((cookie) => cookie.expiresAt == null || cookie.expiresAt > now);
234
- return this.cookies
235
- .filter((cookie) => (!cookie.secure || url.protocol === 'https:')
236
- && (cookie.hostOnly ? url.hostname === cookie.domain : domainMatches(url.hostname, cookie.domain))
237
- && pathMatches(url.pathname, cookie.path))
238
- .sort((a, b) => b.path.length - a.path.length)
239
- .map((cookie) => `${cookie.name}=${cookie.value}`)
240
- .join('; ');
241
- }
242
- }
243
-
244
- const HOP_BY_HOP = new Set([
245
- 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
246
- 'te', 'trailer', 'transfer-encoding', 'upgrade',
247
- ]);
248
-
249
- const REWRITTEN_REPRESENTATION_HEADERS = new Set([
250
- 'accept-ranges', 'content-md5', 'content-range', 'content-digest', 'digest', 'etag',
251
- 'repr-digest', 'signature', 'signature-input',
252
- ]);
253
-
254
- function targetFromRequest(req) {
255
- const direct = decodeProxyPath(req.url);
256
- if (direct) return direct;
257
- const referer = req.headers.referer;
258
- if (!referer) return null;
259
- try {
260
- const source = decodeProxyPath(new URL(referer).pathname + new URL(referer).search);
261
- return source ? new URL(req.url, source.origin) : null;
262
- } catch {
263
- return null;
264
- }
265
- }
266
-
267
- function upstreamHeaders(req, target, jar) {
268
- const headers = {};
269
- for (const [name, value] of Object.entries(req.headers)) {
270
- const lower = name.toLowerCase();
271
- if (HOP_BY_HOP.has(lower) || lower === 'host' || lower === 'cookie' || lower === 'referer'
272
- || lower === 'origin' || lower === 'content-length' || lower === 'accept-encoding') continue;
273
- headers[name] = value;
274
- }
275
- headers.host = target.host;
276
- headers['accept-encoding'] = 'identity';
277
- const cookie = jar.header(target);
278
- if (cookie) headers.cookie = cookie;
279
- if (req.headers.referer) {
280
- try {
281
- const ref = new URL(req.headers.referer);
282
- const virtualRef = decodeProxyPath(ref.pathname + ref.search);
283
- if (virtualRef) {
284
- headers.referer = virtualRef.href;
285
- if (req.headers.origin) headers.origin = virtualRef.origin;
286
- }
287
- } catch { /* omit malformed preview referer */ }
288
- }
289
- return headers;
290
- }
291
-
292
- function responseHeaders(headers, target, jar) {
293
- const out = {};
294
- for (const [name, value] of Object.entries(headers)) {
295
- const lower = name.toLowerCase();
296
- if (HOP_BY_HOP.has(lower) || lower === 'set-cookie' || lower === 'content-length'
297
- || lower === 'content-encoding' || lower === 'content-security-policy'
298
- || lower === 'content-security-policy-report-only' || lower === 'x-frame-options') continue;
299
- out[name] = value;
300
- }
301
- const setCookies = headers['set-cookie'];
302
- for (const cookie of Array.isArray(setCookies) ? setCookies : setCookies ? [setCookies] : []) jar.store(cookie, target);
303
- if (headers.location) {
304
- try { out.location = proxyPathFor(new URL(headers.location, target)); } catch { delete out.location; }
305
- }
306
- out['cache-control'] = 'no-store';
307
- out['referrer-policy'] = 'same-origin';
308
- return out;
309
- }
310
-
311
- function isHtml(contentType) { return /(?:text\/html|application\/xhtml\+xml)/i.test(contentType || ''); }
312
- function isCss(contentType) { return /text\/css/i.test(contentType || ''); }
313
- function isJson(contentType) { return /^(?:application|text)\/(?:[\w.-]+\+)?json(?:\s*;|$)/i.test(contentType || ''); }
314
- function isLoginUrlJson(contentType, target) {
315
- return isJson(contentType) && /\/auth\/login-url\/?$/i.test(target.pathname);
316
- }
317
-
318
- export function createInternalPreviewGateway({
319
- entryUrl,
320
- insecure = false,
321
- httpRequest = http.request,
322
- httpsRequest = https.request,
323
- netConnect = net.connect,
324
- tlsConnect = tls.connect,
325
- dnsLookup = dns.lookup,
326
- allowLoopback = false,
327
- cookieDomains = [],
328
- maxRewriteBytes = 10 * 1024 * 1024,
329
- timeoutMs = 20_000,
330
- } = {}) {
331
- const entry = new URL(entryUrl);
332
- if (!HTTP_PROTOCOLS.has(entry.protocol)) throw new Error('entry URL must use http or https');
333
- const jar = new MemoryCookieJar({ trustedDomains: cookieDomains });
334
- const lookup = restrictedLookup(dnsLookup, { allowLoopback });
335
-
336
- function handler(req, res) {
337
- const target = targetFromRequest(req);
338
- if (!target && (req.url === '/' || req.url.startsWith('/?'))) {
339
- res.writeHead(302, { location: proxyPathFor(entry), 'cache-control': 'no-store' });
340
- return res.end();
341
- }
342
- if (!target) {
343
- res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
344
- return res.end('Invalid internal preview URL');
345
- }
346
- const secure = target.protocol === 'https:';
347
- const targetHostname = target.hostname.replace(/^\[|\]$/g, '');
348
- if (!allowLoopback && net.isIP(targetHostname) && isBlockedGatewayAddress(targetHostname)) {
349
- res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
350
- return res.end('Internal preview blocked a loopback, link-local, or metadata target');
351
- }
352
- const transport = secure ? httpsRequest : httpRequest;
353
- const headers = upstreamHeaders(req, target, jar);
354
- const up = transport({
355
- protocol: target.protocol,
356
- hostname: targetHostname,
357
- port: target.port || undefined,
358
- method: req.method,
359
- path: target.pathname + target.search,
360
- headers,
361
- servername: targetHostname,
362
- rejectUnauthorized: !insecure,
363
- lookup,
364
- }, (upRes) => {
365
- const contentType = upRes.headers['content-type'] || '';
366
- const rewrite = !upRes.headers['content-encoding']
367
- && (isHtml(contentType) || isCss(contentType) || isLoginUrlJson(contentType, target));
368
- const headersOut = responseHeaders(upRes.headers, target, jar);
369
- if (rewrite) {
370
- for (const name of Object.keys(headersOut)) {
371
- if (REWRITTEN_REPRESENTATION_HEADERS.has(name.toLowerCase())) delete headersOut[name];
372
- }
373
- }
374
- if (!rewrite) {
375
- if (upRes.headers['content-encoding']) headersOut['content-encoding'] = upRes.headers['content-encoding'];
376
- if (upRes.headers['content-length']) headersOut['content-length'] = upRes.headers['content-length'];
377
- res.writeHead(upRes.statusCode || 502, headersOut);
378
- upRes.on('error', () => res.destroy());
379
- return upRes.pipe(res);
380
- }
381
- const chunks = [];
382
- let size = 0;
383
- upRes.on('data', (chunk) => {
384
- size += chunk.length;
385
- if (size > maxRewriteBytes) upRes.destroy(new Error('response too large to rewrite'));
386
- else chunks.push(chunk);
387
- });
388
- upRes.on('end', () => {
389
- const source = Buffer.concat(chunks).toString('utf8');
390
- const body = isHtml(contentType)
391
- ? rewriteHtml(source, target)
392
- : isCss(contentType) ? rewriteCss(source, target) : rewriteLoginUrlJson(source, target);
393
- res.writeHead(upRes.statusCode || 502, headersOut);
394
- res.end(body);
395
- });
396
- upRes.on('error', (error) => {
397
- if (!res.headersSent) {
398
- res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
399
- res.end(`Internal preview response error: ${error.message}`);
400
- } else res.destroy();
401
- });
402
- });
403
- up.setTimeout(timeoutMs, () => up.destroy(new Error('upstream timeout')));
404
- up.on('error', (error) => {
405
- if (!res.headersSent) {
406
- res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
407
- res.end(`Internal preview upstream error: ${error.message}`);
408
- } else res.destroy();
409
- });
410
- req.on('aborted', () => up.destroy());
411
- req.pipe(up);
412
- }
413
-
414
- function onUpgrade(req, socket, head) {
415
- const decoded = decodeWebSocketPath(req.url);
416
- if (!decoded) return socket.destroy();
417
- const { sourceOrigin, target } = decoded;
418
- const secure = target.protocol === 'wss:';
419
- const connect = secure ? tlsConnect : netConnect;
420
- const targetHostname = target.hostname.replace(/^\[|\]$/g, '');
421
- const options = {
422
- host: targetHostname,
423
- port: Number(target.port) || (secure ? 443 : 80),
424
- lookup,
425
- };
426
- if (!allowLoopback && net.isIP(targetHostname) && isBlockedGatewayAddress(targetHostname)) return socket.destroy();
427
- if (secure) Object.assign(options, {
428
- servername: targetHostname,
429
- rejectUnauthorized: !insecure,
430
- ALPNProtocols: ['http/1.1'],
431
- });
432
- let connectTimer;
433
- const upstream = connect(options, () => {
434
- clearTimeout(connectTimer);
435
- upstream.write(`GET ${target.pathname}${target.search} HTTP/1.1\r\n`);
436
- upstream.write(`host: ${target.host}\r\n`);
437
- upstream.write('upgrade: websocket\r\nconnection: Upgrade\r\n');
438
- upstream.write(`origin: ${sourceOrigin}\r\n`);
439
- for (const [name, value] of Object.entries(req.headers)) {
440
- const lower = name.toLowerCase();
441
- if (HOP_BY_HOP.has(lower) || lower === 'host' || lower === 'cookie' || lower === 'origin') continue;
442
- upstream.write(`${lower}: ${value}\r\n`);
443
- }
444
- const cookie = jar.header(new URL(target.href.replace(/^ws/, 'http')));
445
- if (cookie) upstream.write(`cookie: ${cookie}\r\n`);
446
- upstream.write('\r\n');
447
- if (head?.length) upstream.write(head);
448
- upstream.pipe(socket);
449
- socket.pipe(upstream);
450
- });
451
- connectTimer = setTimeout(() => upstream.destroy(new Error('upstream connect timeout')), timeoutMs);
452
- connectTimer.unref?.();
453
- upstream.on('error', () => socket.destroy());
454
- socket.on('error', () => upstream.destroy());
455
- socket.on('close', () => {
456
- clearTimeout(connectTimer);
457
- upstream.destroy();
458
- });
459
- }
460
-
461
- return { handler, onUpgrade, entryPath: proxyPathFor(entry), cookieJar: jar };
462
- }
463
-
464
- export function parseInternalPreviewArgs(argv) {
465
- const args = [...argv];
466
- const entryUrl = args.shift();
467
- if (!entryUrl) throw new Error('an entry URL is required');
468
- const entry = new URL(entryUrl);
469
- if (!HTTP_PROTOCOLS.has(entry.protocol)) throw new Error('entry URL must use http or https');
470
- let port = 4319;
471
- let insecure = false;
472
- const cookieDomains = [];
473
- while (args.length) {
474
- const arg = args.shift();
475
- if (arg === '--insecure') insecure = true;
476
- else if (arg === '--port') port = Number(args.shift());
477
- else if (arg.startsWith('--port=')) port = Number(arg.slice('--port='.length));
478
- else if (arg === '--cookie-domain') cookieDomains.push(String(args.shift() || '').replace(/^\./, '').toLowerCase());
479
- else if (arg.startsWith('--cookie-domain=')) cookieDomains.push(arg.slice('--cookie-domain='.length).replace(/^\./, '').toLowerCase());
480
- else throw new Error(`unknown option: ${arg}`);
481
- }
482
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('port must be between 1 and 65535');
483
- if (cookieDomains.some((domain) => !domain || !domain.includes('.'))) throw new Error('cookie domain must contain at least two labels');
484
- return { entryUrl, port, insecure, cookieDomains };
485
- }
486
-
487
- export { PREFIX as INTERNAL_PREVIEW_PREFIX };