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
@@ -39,7 +39,7 @@ export function findTunnelId(listJsonOut, name) {
39
39
 
40
40
  // The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
41
41
  // before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
42
- // old value instead of leaving a stale field behind. Anything NOT here (staticDir, previewTtl…) is
42
+ // old value instead of leaving a stale field behind. Anything NOT here (staticDir, uploadExts…) is
43
43
  // preserved untouched. `token` IS owned so the Token row can pin one AND clear it back to auto — but it
44
44
  // round-trips through answersFromConfig, so a re-run that never touches the row still writes it back.
45
45
  const WIZARD_KEYS = [
@@ -127,8 +127,8 @@ export function validateHost(v) {
127
127
  const s = String(v || '').trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
128
128
  return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valHost');
129
129
  }
130
- // Dynamic previews use `https://<name>.<previewDomain>/`; only the base domain belongs in config. Blank
131
- // deliberately disables the optional feature, while a URL / wildcard would produce a broken host name.
130
+ // The built-in browser maps proxy origins below this domain. Blank deliberately leaves proxy mode disabled;
131
+ // a URL, wildcard, or port would produce an invalid host when Handmux adds its own stable subdomain.
132
132
  export function validatePreviewDomain(v) {
133
133
  const s = String(v || '').trim();
134
134
  if (!s) return undefined;
@@ -73,7 +73,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
73
73
  { value: 'name', label: t('setup.secName'), hint: a.name || t('setup.default') },
74
74
  { value: 'port', label: t('setup.secPort'), hint: String(a.port) },
75
75
  { value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
76
- { value: 'preview', label: t('setup.secPreview'), hint: a.previewDomain || t('setup.previewOff') },
76
+ { value: 'browser', label: t('setup.secBrowser'), hint: a.previewDomain || t('setup.browserOff') },
77
77
  { value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
78
78
  { value: 'voice', label: t('setup.secVoice'), hint: a.xfyun ? (a.xfyun.appId || t('setup.on')) : t('setup.off') },
79
79
  // A CLI-tool preference (language of handmux's own terminal output), not an app setting — so it
@@ -101,7 +101,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
101
101
  else if (choice === 'name') a.name = await editName(a);
102
102
  else if (choice === 'port') a.port = await editPort(a);
103
103
  else if (choice === 'token') a.token = await editToken(a);
104
- else if (choice === 'preview') a.previewDomain = await editPreviewDomain(a);
104
+ else if (choice === 'browser') a.previewDomain = await editBrowserDomain(a);
105
105
  else if (choice === 'language') a.lang = await editLanguage(a);
106
106
  else if (choice === 'push') a.vapid = await editPush(a);
107
107
  else if (choice === 'voice') a.xfyun = await editVoice(a);
@@ -140,14 +140,10 @@ async function editPort(a) {
140
140
  return Number(v);
141
141
  }
142
142
 
143
- async function editPreviewDomain(a) {
144
- note(t('setup.previewAbout'));
145
- note(t('setup.previewRoute'));
146
- note(t('setup.previewHttps'));
147
- if (a.tunnel === 'cloudflare-named') note(t('setup.previewTlsCf'));
148
- else if (a.tunnel === 'ssh') note(t('setup.previewTlsEdge'));
143
+ async function editBrowserDomain(a) {
144
+ note(t('setup.browserAbout'));
149
145
  const value = await ask(text({
150
- message: withBack(t('setup.askPreviewDomain')),
146
+ message: withBack(t('setup.askBrowserDomain')),
151
147
  placeholder: 'preview.example.com',
152
148
  initialValue: a.previewDomain || '',
153
149
  validate: validatePreviewDomain,
@@ -39,6 +39,13 @@ export function bareUrl(base) {
39
39
  return `${base.replace(/\/$/, '')}/`;
40
40
  }
41
41
 
42
+ export function browserPublicOriginEnv(cfg) {
43
+ return {
44
+ ...(cfg.previewDomain ? { HANDMUX_PREVIEW_DOMAIN: cfg.previewDomain } : {}),
45
+ ...(cfg.publicUrl ? { HANDMUX_PUBLIC_URL: cfg.publicUrl } : {}),
46
+ };
47
+ }
48
+
42
49
  export function supervise(cfg, { home, log = console } = {}) {
43
50
  const driver = getDriver(cfg.tunnel);
44
51
  const children = {};
@@ -54,7 +61,6 @@ export function supervise(cfg, { home, log = console } = {}) {
54
61
  port: cfg.port,
55
62
  host: cfg.host,
56
63
  token: cfg.token,
57
- previewDomain: cfg.previewDomain || null,
58
64
  localUrl: `http://localhost:${cfg.port}`,
59
65
  lanUrl: lanUrl(cfg.port),
60
66
  publicUrl: null,
@@ -89,12 +95,11 @@ export function supervise(cfg, { home, log = console } = {}) {
89
95
  PUSH_STORE: pushStorePath(home),
90
96
  PREVIEW_STORE: previewStorePath(home),
91
97
  NOTIF_DIR: notificationsDirPath(home),
98
+ ...browserPublicOriginEnv(cfg),
92
99
  };
93
- if (cfg.previewDomain) env.HANDMUX_PREVIEW_DOMAIN = cfg.previewDomain;
94
100
  if (cfg.name) env.HANDMUX_APP_NAME = cfg.name;
95
101
  if (cfg.staticDir) env.HANDMUX_STATIC_DIR = cfg.staticDir;
96
102
  if (cfg.uploadExts) env.HANDMUX_UPLOAD_EXTS = cfg.uploadExts;
97
- if (cfg.previewTtl) env.HANDMUX_PREVIEW_TTL = String(cfg.previewTtl);
98
103
  env.HANDMUX_SHORTCUTS = JSON.stringify(cfg.shortcuts);
99
104
  if (cfg.vapid) {
100
105
  if (cfg.vapid.public) env.VAPID_PUBLIC = cfg.vapid.public;
package/src/httpApi.js CHANGED
@@ -24,6 +24,7 @@ import { notificationRoutes } from './routes/notifications.js';
24
24
  import { transcriptRoutes } from './routes/transcript.js';
25
25
  import { DEFAULT_SHORTCUTS } from './shortcutConfig.js';
26
26
  import { workspaceRoutes } from './routes/workspace.js';
27
+ import { browserRoutes } from './browser/routes.js';
27
28
 
28
29
  // Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
29
30
  export { isAllowedKey } from './routes/terminal.js';
@@ -31,8 +32,11 @@ export { isAllowedKey } from './routes/terminal.js';
31
32
  export function createApiRouter({
32
33
  token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events,
33
34
  uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES,
34
- asrEnv = process.env, previews, previewDomain = null,
35
+ asrEnv = process.env, previews,
35
36
  shortcuts = DEFAULT_SHORTCUTS,
37
+ browser,
38
+ browserBootstrap,
39
+ previewDomain,
36
40
  workspace,
37
41
  home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()),
38
42
  } = {}) {
@@ -43,7 +47,7 @@ export function createApiRouter({
43
47
 
44
48
  const deps = {
45
49
  token, commands, docs, git, push, notifications, claudeEvents,
46
- uploadExts, maxUploadBytes, asrEnv, previews, previewDomain, shortcuts, home, stateFile, workspace,
50
+ uploadExts, maxUploadBytes, asrEnv, previews, shortcuts, home, stateFile, workspace, browser, browserBootstrap, previewDomain,
47
51
  };
48
52
 
49
53
  r.use(sessionRoutes(deps));
@@ -54,6 +58,10 @@ export function createApiRouter({
54
58
  r.use(notificationRoutes(deps));
55
59
  r.use(systemRoutes(deps));
56
60
  r.use(previewRoutes(deps));
61
+ // In the main process browser APIs are forwarded to the isolated worker before this router. Keep
62
+ // preview-domain wiring out of this unused fallback route so capability reporting does not require
63
+ // the worker-owned bootstrap ticket store.
64
+ r.use('/browser-proxy', browserRoutes(browser ? deps : { ...deps, previewDomain: null }));
57
65
  r.use(transcriptRoutes(deps));
58
66
  if (workspace) r.use(workspaceRoutes(deps));
59
67
 
@@ -1,95 +1,141 @@
1
1
  // server/src/previewServer.js
2
- // Serves registered static-preview directories under /preview, reusing the system token via a cookie
3
- // (a browser opening a URL can't send a Bearer header). Absolute-rooted assets (/assets/...) are
4
- // served from the right preview dir via a Referer fallback (design's "方案 A").
5
- // Also handles dynamic preview: Host-based dispatch to loopback ports (HTTP + WS upgrade).
6
- import net from 'node:net';
7
- import http from 'node:http';
8
- import https from 'node:https';
9
- import tls from 'node:tls';
2
+ // Serves registered static-preview directories under a per-registration capability path. The main
3
+ // Handmux token is never a preview credential. Absolute-rooted assets (/assets/...) are served from the
4
+ // right preview dir via an authenticated Referer fallback (design's "方案 A").
5
+ // Dynamic ports and arbitrary websites are handled by the built-in browser instead.
10
6
  import express from 'express';
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
11
9
  import { tokenEquals } from './auth.js';
12
- import { safePreviewName } from './previews.js';
10
+ function credentialOk(provided, token) {
11
+ if (!provided) return false;
12
+ try { return tokenEquals(provided, token); } catch { return false; }
13
+ }
13
14
 
14
- const COOKIE = 'tw_preview';
15
+ // Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
16
+ function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
15
17
 
16
- // Read one cookie value from a raw Cookie header, URL-decoded. No cookie-parser dep (zero-dep house style).
17
- export function parseCookie(header, name) {
18
- if (!header) return null;
19
- const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(header);
20
- return m ? decodeURIComponent(m[1]) : null;
18
+ function previewHeaders(res) {
19
+ res.setHeader('Cache-Control', 'no-store');
20
+ res.setHeader('Referrer-Policy', 'same-origin');
21
+ res.setHeader('X-Content-Type-Options', 'nosniff');
22
+ // Keep preview code isolated even if its capability URL is opened outside the in-app iframe.
23
+ res.setHeader('Content-Security-Policy', 'sandbox allow-scripts allow-forms allow-downloads allow-modals allow-popups');
24
+ // A sandboxed static document intentionally has an opaque origin. Its capability path needs to remain
25
+ // readable by relative fetch(), module scripts, and fonts without restoring same-origin parent access.
26
+ res.setHeader('Access-Control-Allow-Origin', 'null');
21
27
  }
22
28
 
23
- // Credential check shared by the /preview gate AND the referer fallback. Accepts the token via
24
- // ?token= (first visit) or the tw_preview cookie (subsequent). timing-safe via tokenEquals.
25
- export function credOk(req, token) {
26
- const q = req.query?.token;
27
- const provided = (typeof q === 'string' && q) ? q : parseCookie(req.headers?.cookie, COOKIE);
28
- if (!provided) return false;
29
- try { return tokenEquals(provided, token); } catch { return false; }
29
+ function prefixedRoot(url, prefix) {
30
+ return url.startsWith('/') && !url.startsWith('//') ? `${prefix}${url.slice(1)}` : url;
30
31
  }
31
32
 
32
- export { COOKIE };
33
+ function rewriteCssRoots(source, prefix) {
34
+ return source
35
+ .replace(/(url\(\s*["']?)\/(?!\/)/gi, `$1${prefix}`)
36
+ .replace(/(@import\s+["'])\/(?!\/)/gi, `$1${prefix}`);
37
+ }
33
38
 
34
- // A preview host is exactly `<name>.<domain>` where <name> is one safe label. Anything deeper, the
35
- // base domain itself, or a foreign domain → not ours (null). domain unset → dynamic disabled → null.
36
- // The configured domain may carry a :port (the edge runs on a non-standard port, e.g. :39999) — that
37
- // port belongs in the browser URL, but Host matching is hostname-only, so strip it here. The incoming
38
- // `host` already has its port stripped by the caller.
39
- export function isPreviewHost(host, domain) {
40
- if (!domain || !host) return null;
41
- const base = domain.split(':')[0];
42
- const suffix = `.${base}`;
43
- if (!host.endsWith(suffix)) return null;
44
- const label = host.slice(0, -suffix.length);
45
- if (!label || label.includes('.')) return null; // single label only
46
- return safePreviewName(label);
39
+ function rewriteModuleRoots(source, prefix) {
40
+ return source.replace(
41
+ /(\b(?:from|import)\s*(?:\(\s*)?)(["'])\/(?!\/)/g,
42
+ `$1$2${prefix}`,
43
+ );
47
44
  }
48
45
 
49
- // Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
50
- function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
46
+ export function rewritePreviewText(source, extension, prefix) {
47
+ if (extension === '.css') return rewriteCssRoots(source, prefix);
48
+ if (extension === '.js' || extension === '.mjs') return rewriteModuleRoots(source, prefix);
49
+ if (extension !== '.html' && extension !== '.htm') return source;
50
+ let output = source.replace(
51
+ /(\b(?:src|href|action|poster|data|formaction)\s*=\s*)(["'])(\/[^"']*)\2/gi,
52
+ (_match, start, quote, url) => `${start}${quote}${prefixedRoot(url, prefix)}${quote}`,
53
+ );
54
+ output = output.replace(
55
+ /(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi,
56
+ (_match, start, quote, value) => {
57
+ const rewritten = value.split(',').map((candidate) => {
58
+ const match = /^(\s*)(\S+)(.*)$/.exec(candidate);
59
+ return match ? `${match[1]}${prefixedRoot(match[2], prefix)}${match[3]}` : candidate;
60
+ }).join(',');
61
+ return `${start}${quote}${rewritten}${quote}`;
62
+ },
63
+ );
64
+ output = output.replace(
65
+ /(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi,
66
+ (_match, open, css, close) => `${open}${rewriteCssRoots(css, prefix)}${close}`,
67
+ );
68
+ output = output.replace(
69
+ /(\bstyle\s*=\s*)(["'])([^"']*)\2/gi,
70
+ (_match, start, quote, css) => `${start}${quote}${rewriteCssRoots(css, prefix)}${quote}`,
71
+ );
72
+ return output.replace(
73
+ /(<script\b[^>]*\btype\s*=\s*["']module["'][^>]*>)([\s\S]*?)(<\/script>)/gi,
74
+ (_match, open, script, close) => `${open}${rewriteModuleRoots(script, prefix)}${close}`,
75
+ );
76
+ }
51
77
 
52
78
  export function createPreview({
53
79
  previews,
54
- token,
55
- domain = null,
56
- httpRequest = http.request,
57
- httpsRequest = https.request,
58
- netConnect = net.connect,
59
- tlsConnect = tls.connect,
60
80
  }) {
61
81
  const router = express.Router();
62
82
 
63
- // Gate: ?token= (set cookie + 302 strip) OR a valid cookie; else 401.
64
- router.use((req, res, next) => {
65
- const q = req.query?.token;
66
- if (typeof q === 'string' && q && credOk(req, token)) {
67
- res.setHeader('Set-Cookie', `${COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`);
68
- const u = new URL(req.originalUrl, 'http://x');
69
- u.searchParams.delete('token');
70
- return res.redirect(302, u.pathname + u.search);
71
- }
72
- if (!credOk(req, token)) return res.status(401).send('unauthorized');
73
- next();
74
- });
75
-
76
- function serve(name, rest, res) {
83
+ function resolveEntry(name, accessToken, res) {
77
84
  const { state, entry } = previews.get(name);
78
85
  if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
79
86
  if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
80
- res.setHeader('Cache-Control', 'no-store');
81
- res.sendFile(fileFor(rest), { root: entry.dir, dotfiles: 'deny' }, (err) => {
87
+ if (!credentialOk(accessToken, entry.accessToken)) return res.status(401).send('unauthorized');
88
+ return entry;
89
+ }
90
+
91
+ async function serve(entry, rest, prefix, res) {
92
+ previewHeaders(res);
93
+ const requested = fileFor(rest);
94
+ const segments = requested.split('/');
95
+ const target = path.resolve(entry.dir, requested);
96
+ const relative = path.relative(entry.dir, target);
97
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)
98
+ || segments.some((segment) => segment.startsWith('.'))) {
99
+ if (!relative && requested === 'index.html') {
100
+ // The root index is the normal preview entry point.
101
+ } else {
102
+ res.status(404).end();
103
+ return;
104
+ }
105
+ }
106
+ const extension = path.extname(target).toLowerCase();
107
+ if (['.html', '.htm', '.css', '.js', '.mjs'].includes(extension)) {
108
+ try {
109
+ const source = await fs.readFile(target, 'utf8');
110
+ res.type(extension).send(rewritePreviewText(source, extension, prefix));
111
+ } catch (error) {
112
+ if (!res.headersSent) res.status(error?.code === 'ENOENT' ? 404 : 500).end();
113
+ }
114
+ return;
115
+ }
116
+ res.sendFile(requested, { root: entry.dir, dotfiles: 'deny' }, (err) => {
82
117
  if (err && !res.headersSent) res.status(err.statusCode || 404).end();
83
118
  });
84
119
  }
85
120
 
86
- // /:name catches both '/live' (no trailing slash → redirect) and '/live/' (trailing slash → serve root).
87
- // /:name/* catches '/live/index.html', '/live/assets/x.js', etc.
88
- router.get('/:name', (req, res, next) => {
89
- if (req.url.endsWith('/')) return serve(req.params.name, '', res);
90
- res.redirect(301, `/preview/${encodeURIComponent(req.params.name)}/`);
121
+ // A name without its independent capability is never accepted, including the old ?token=<app-token>
122
+ // shape. Keeping this explicit makes the failure an authentication result instead of an SPA fallback.
123
+ router.get('/:name', (_req, res) => res.status(401).send('unauthorized'));
124
+ router.get('/:name/:accessToken', (req, res) => {
125
+ const entry = resolveEntry(req.params.name, req.params.accessToken, res);
126
+ if (!entry || res.headersSent) return;
127
+ const prefix = `/preview/${encodeURIComponent(req.params.name)}/${encodeURIComponent(req.params.accessToken)}/`;
128
+ if (!req.url.split('?')[0].endsWith('/')) {
129
+ return res.redirect(301, prefix);
130
+ }
131
+ return void serve(entry, '', prefix, res);
132
+ });
133
+ router.get('/:name/:accessToken/*', (req, res) => {
134
+ const entry = resolveEntry(req.params.name, req.params.accessToken, res);
135
+ if (!entry || res.headersSent) return;
136
+ const prefix = `/preview/${encodeURIComponent(req.params.name)}/${encodeURIComponent(req.params.accessToken)}/`;
137
+ void serve(entry, req.params[0], prefix, res);
91
138
  });
92
- router.get('/:name/*', (req, res) => serve(req.params.name, req.params[0], res));
93
139
 
94
140
  // Referer fallback (mount AFTER /preview, BEFORE express.static): an absolute /assets/... request
95
141
  // whose Referer is a preview page is served from that preview's dir. Reuses credOk so it can never
@@ -101,96 +147,22 @@ export function createPreview({
101
147
  if (!ref) return next();
102
148
  let refPath;
103
149
  try { refPath = new URL(ref).pathname; } catch { return next(); }
104
- const m = /^\/preview\/([^/]+)\//.exec(refPath);
150
+ const m = /^\/preview\/([^/]+)\/([^/]+)\//.exec(refPath);
105
151
  if (!m) return next();
106
- if (!credOk(req, token)) return next();
107
- const { state, entry } = previews.get(decodeURIComponent(m[1]));
152
+ let name;
153
+ let accessToken;
154
+ try {
155
+ name = decodeURIComponent(m[1]);
156
+ accessToken = decodeURIComponent(m[2]);
157
+ } catch { return next(); }
158
+ const { state, entry } = previews.get(name);
108
159
  if (state !== 'active') return next();
109
- res.setHeader('Cache-Control', 'no-store');
160
+ if (!credentialOk(accessToken, entry.accessToken)) return next();
161
+ previewHeaders(res);
110
162
  res.sendFile(req.path, { root: entry.dir, dotfiles: 'deny' }, (err) => {
111
163
  if (err && !res.headersSent) next();
112
164
  });
113
165
  }
114
166
 
115
- // Reverse-proxy one request to the dynamic preview's loopback port. No prefix strip — the app owns
116
- // its own root, so path/method/headers/body forward as-is. `host` is the loopback family the app was
117
- // found on at register time ('127.0.0.1' or '::1'); the Host header stays 127.0.0.1:<port> so a dev
118
- // server's host check (e.g. Vite, whose allowedHosts include localhost/127.0.0.1) is satisfied.
119
- function proxyHttp(port, host, protocol, req, res) {
120
- const headers = { ...req.headers, host: `127.0.0.1:${port}` };
121
- const secure = protocol === 'https';
122
- const request = secure ? httpsRequest : httpRequest;
123
- const options = { host: host || '127.0.0.1', port, method: req.method, path: req.url, headers };
124
- // Local HTTPS dev servers commonly use a self-signed certificate. The connection target is constrained
125
- // to loopback by registration/probing, so accepting that certificate does not expand trust to a network
126
- // host. SNI stays localhost because that is what development certificates are normally issued for.
127
- if (secure) Object.assign(options, { servername: 'localhost', rejectUnauthorized: false });
128
- const up = request(options, (upRes) => {
129
- res.writeHead(upRes.statusCode, upRes.headers);
130
- upRes.on('error', () => res.destroy()); // mid-stream upstream reset (after headers) → tear down the client socket
131
- upRes.pipe(res);
132
- });
133
- up.on('error', () => { if (!res.headersSent) res.status(502).type('text').end('preview upstream error'); });
134
- req.pipe(up);
135
- }
136
-
137
- // Host-based dispatch middleware. Mount FIRST (before /api). A non-preview Host just calls next() →
138
- // the request falls through to the existing app, zero impact.
139
- function dynamicProxy(req, res, next) {
140
- const host = (req.headers.host || '').split(':')[0];
141
- const name = isPreviewHost(host, domain);
142
- if (!name) return next();
143
- const q = req.query?.token;
144
- if (typeof q === 'string' && q && credOk(req, token)) {
145
- // Host-only by design: the token URL is opened on THIS preview host, and its page + HMR/WS stay on
146
- // that host. Sharing the credential with the parent/main app or sibling previews is unnecessary,
147
- // expands exposure, and produces invalid Domain=com for a configured two-label domain.
148
- res.setHeader('Set-Cookie', `${COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`);
149
- const u = new URL(req.originalUrl, 'http://x');
150
- u.searchParams.delete('token');
151
- return res.redirect(302, u.pathname + u.search);
152
- }
153
- if (!credOk(req, token)) return res.status(401).send('unauthorized');
154
- const { state, entry } = previews.get(name);
155
- if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
156
- if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
157
- if (entry.kind !== 'dynamic') return res.status(404).end(); // a static name reached via subdomain — not served here
158
- proxyHttp(entry.port, entry.host, entry.protocol, req, res);
159
- }
160
-
161
- // WebSocket (and any raw Upgrade) for a dynamic preview: same cookie auth, then a bare TCP pipe to
162
- // the loopback port — covers HMR, SSE-over-ws, and an app's own websockets. Wired via
163
- // server.on('upgrade'). A non-preview host or failed auth just destroys the socket (the app has no
164
- // other ws endpoints).
165
- function onUpgrade(req, socket, head) {
166
- const host = (req.headers.host || '').split(':')[0];
167
- const name = isPreviewHost(host, domain);
168
- if (!name) return socket.destroy();
169
- // ws handshakes rarely carry ?token=; the host-only cookie set on the first HTTP load is what
170
- // authorizes them. Build a minimal req-shape for credOk (query parsed from the URL for parity).
171
- let query = {};
172
- try { query = Object.fromEntries(new URL(req.url, 'http://x').searchParams); } catch { /* none */ }
173
- if (!credOk({ query, headers: req.headers }, token)) return socket.destroy();
174
- const { state, entry } = previews.get(name);
175
- if (state !== 'active' || entry.kind !== 'dynamic') return socket.destroy();
176
- const secure = entry.protocol === 'https';
177
- const options = { port: entry.port, host: entry.host || '127.0.0.1' };
178
- if (secure) Object.assign(options, {
179
- servername: 'localhost', rejectUnauthorized: false, ALPNProtocols: ['http/1.1'],
180
- });
181
- const connect = secure ? tlsConnect : netConnect;
182
- const up = connect(options, () => {
183
- const fwd = { ...req.headers, host: `127.0.0.1:${entry.port}` };
184
- up.write(`GET ${req.url} HTTP/1.1\r\n`);
185
- for (const [k, v] of Object.entries(fwd)) up.write(`${k}: ${v}\r\n`);
186
- up.write('\r\n');
187
- if (head && head.length) up.write(head);
188
- up.pipe(socket);
189
- socket.pipe(up);
190
- });
191
- up.on('error', () => socket.destroy());
192
- socket.on('error', () => up.destroy());
193
- }
194
-
195
- return { router, refererFallback, dynamicProxy, onUpgrade };
167
+ return { router, refererFallback };
196
168
  }
package/src/previews.js CHANGED
@@ -1,14 +1,12 @@
1
1
  // server/src/previews.js
2
- // Preview registry. Maps a safe single-segment name → either an on-disk directory under $HOME
3
- // (kind:'static') or a local port (kind:'dynamic'), with a TTL. Like push.js it's a single-writer
2
+ // Preview registry. Maps a safe single-segment name to an on-disk directory under $HOME with a lease.
4
3
  // in-memory registry (loaded once at construction, flushed atomically on each mutation) — the previous
5
4
  // reload-and-write-back on every op was an unguarded read-modify-write that could lose an entry when a
6
- // GET's expiry-prune raced a concurrent register(). Pure-ish: home/now/store/ttl plus the dynamic switch
7
- // and port probe are injected so it unit-tests on its own.
5
+ // GET's lease update raced a concurrent register(). Pure-ish: home/now/store/ttl are injected for tests.
8
6
  import fs from 'node:fs';
9
- import net from 'node:net';
10
7
  import path from 'node:path';
11
8
  import { homedir } from 'node:os';
9
+ import { randomBytes } from 'node:crypto';
12
10
  import { fileURLToPath } from 'node:url';
13
11
  import { isUnder } from './docPath.js';
14
12
  import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
@@ -17,70 +15,68 @@ export function safePreviewName(raw) {
17
15
  if (typeof raw !== 'string') return null;
18
16
  if (!/^[A-Za-z0-9._-]+$/.test(raw)) return null;
19
17
  if (raw === '.' || raw === '..' || raw[0] === '.') return null;
20
- // Normalize to lowercase: a dynamic preview is reached via a subdomain, and browsers lowercase the
21
- // hostname — so a stored name with uppercase (from a tmux window name) could never be matched. Keep
22
- // register/get/subdomain all on the same lowercased key.
18
+ // Keep lookups stable across clients that may normalize user-provided names differently.
23
19
  return raw.toLowerCase();
24
20
  }
25
21
 
26
- // Is something listening on a loopback `host:port`? A quick TCP connect with a short timeout.
27
- function probeHost(port, host, timeout) {
28
- return new Promise((resolve) => {
29
- const s = net.connect({ port, host });
30
- const finish = (ok) => { s.destroy(); resolve(ok); };
31
- s.setTimeout(timeout, () => finish(false));
32
- s.once('connect', () => finish(true));
33
- s.once('error', () => finish(false));
34
- });
35
- }
36
-
37
- // Which loopback host answers on `port` — '127.0.0.1', '::1', or null if neither. macOS dev servers
38
- // often bind ONLY IPv6 localhost (::1), so a 127.0.0.1-only probe wrongly reports "not listening".
39
- // The answering host is stored on the entry so the proxy connects to the same family the app is on.
40
- async function probeListening(port, timeout = 300) {
41
- if (await probeHost(port, '127.0.0.1', timeout)) return '127.0.0.1';
42
- if (await probeHost(port, '::1', timeout)) return '::1';
43
- return null;
44
- }
45
-
46
22
  export function createPreviews({
47
23
  home = homedir(),
48
24
  store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'),
49
25
  now = () => Date.now(),
50
- ttlMs = Number(process.env.HANDMUX_PREVIEW_TTL) || 3_600_000,
51
- dynamicEnabled = false,
52
- probePort = probeListening,
26
+ ttlMs = 2 * 60 * 60_000,
27
+ randomToken = () => randomBytes(24).toString('base64url'),
53
28
  } = {}) {
54
29
  let realHome;
55
30
  try { realHome = fs.realpathSync(home); } catch { realHome = home; }
56
31
 
57
32
  // Loaded ONCE — this in-memory array is the source of truth; every op mutates it and flushes atomically.
58
33
  let entries = readJsonArray(store);
59
- const flush = () => writeJsonAtomic(store, entries);
34
+ let flushedExpiries = new Map(entries.map((entry) => [entry?.name, entry?.expiresAt]));
35
+ const flush = () => {
36
+ // Access tokens are runtime capabilities. Open device tabs re-register after a restart and receive
37
+ // a fresh token, so a stale registry file can never resurrect an old preview URL.
38
+ writeJsonAtomic(store, entries.map(({ accessToken: _accessToken, ...entry }) => entry));
39
+ flushedExpiries = new Map(entries.map((entry) => [entry?.name, entry?.expiresAt]));
40
+ };
60
41
 
61
- // Common upsert: drop any prior entry with this name (so static↔dynamic switching just replaces),
62
- // stamp a single now() into createdAt/expiresAt, persist.
42
+ const resultFor = (entry) => ({
43
+ name: entry.name,
44
+ kind: entry.kind,
45
+ accessToken: entry.accessToken,
46
+ expiresAt: entry.expiresAt,
47
+ });
48
+
49
+ // Re-registering the same active directory is a lease renewal. Preserve its capability so a
50
+ // foreground check does not change the iframe URL and reload an already-mounted page. A changed
51
+ // directory, expired row, or process-restored row without a runtime token receives a fresh one.
63
52
  const upsert = (fields) => {
64
- entries = entries.filter((e) => e && e.name !== fields.name);
65
53
  const ts = now();
66
- const entry = { ...fields, createdAt: ts, expiresAt: ts + ttlMs };
54
+ const current = entries.find((entry) => entry && entry.name === fields.name);
55
+ if (current?.kind === fields.kind
56
+ && current.dir === fields.dir
57
+ && current.expiresAt > ts
58
+ && typeof current.accessToken === 'string'
59
+ && current.accessToken) {
60
+ current.expiresAt = ts + ttlMs;
61
+ flush();
62
+ return resultFor(current);
63
+ }
64
+ entries = entries.filter((e) => e && e.name !== fields.name);
65
+ const entry = {
66
+ ...fields,
67
+ accessToken: randomToken(),
68
+ createdAt: ts,
69
+ expiresAt: ts + ttlMs,
70
+ };
67
71
  entries.push(entry);
68
72
  flush();
69
- return { name: entry.name, kind: entry.kind, expiresAt: entry.expiresAt };
73
+ return resultFor(entry);
70
74
  };
71
75
 
72
- async function register({ name, dir, port, protocol = 'http' }) {
76
+ async function register({ name, dir, port }) {
73
77
  const nm = safePreviewName(name);
74
78
  if (!nm) return { error: 'bad name', status: 400 };
75
- if (port !== undefined && port !== null && port !== '') {
76
- if (!dynamicEnabled) return { error: 'dynamic disabled', status: 400 };
77
- if (protocol !== 'http' && protocol !== 'https') return { error: 'bad protocol', status: 400 };
78
- const p = Number(port);
79
- if (!Number.isInteger(p) || p < 1 || p > 65535) return { error: 'bad port', status: 400 };
80
- const host = await probePort(p); // '127.0.0.1' | '::1' | null
81
- if (!host) return { error: 'port not listening', status: 400 };
82
- return upsert({ name: nm, kind: 'dynamic', port: p, host, protocol });
83
- }
79
+ if (port !== undefined && port !== null && port !== '') return { error: 'bad request', status: 400 };
84
80
  if (typeof dir !== 'string' || dir[0] !== '/') return { error: 'not absolute', status: 400 };
85
81
  let real;
86
82
  try { real = fs.realpathSync(dir); } catch { return { error: 'not found', status: 404 }; }
@@ -94,19 +90,21 @@ export function createPreviews({
94
90
  function get(name) {
95
91
  const entry = entries.find((e) => e && e.name === name);
96
92
  if (!entry) return { state: 'missing' };
97
- if (entry.expiresAt <= now()) { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'expired' }; }
98
- const normalized = entry.kind === 'dynamic'
99
- ? { ...entry, kind: 'dynamic', protocol: entry.protocol === 'https' ? 'https' : 'http' }
100
- : { kind: 'static', ...entry }; // legacy rows (no kind) → static
101
- return { state: 'active', entry: normalized };
93
+ const ts = now();
94
+ if (entry.expiresAt <= ts) { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'expired' }; }
95
+ if (entry.kind === 'dynamic') { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'missing' }; }
96
+ // Match proxy leases: actual page/resource traffic renews the lease. Throttle persistence so a page
97
+ // with many assets does not rewrite the registry once per request.
98
+ const nextExpiry = ts + ttlMs;
99
+ entry.expiresAt = nextExpiry;
100
+ if (nextExpiry - (flushedExpiries.get(entry.name) || 0) >= 60_000) flush();
101
+ return { state: 'active', entry: { kind: 'static', ...entry } }; // legacy rows (no kind) → static
102
102
  }
103
103
 
104
104
  function list() {
105
- const active = entries.filter((e) => e && e.expiresAt > now());
105
+ const active = entries.filter((e) => e && e.kind !== 'dynamic' && e.expiresAt > now());
106
106
  if (active.length !== entries.length) { entries = active; flush(); }
107
- return active.map((e) => (e.kind === 'dynamic'
108
- ? { name: e.name, kind: 'dynamic', port: e.port, protocol: e.protocol === 'https' ? 'https' : 'http', expiresAt: e.expiresAt }
109
- : { name: e.name, kind: 'static', dir: e.dir, expiresAt: e.expiresAt }));
107
+ return active.map((e) => ({ name: e.name, kind: 'static', dir: e.dir, expiresAt: e.expiresAt }));
110
108
  }
111
109
 
112
110
  function remove(name) {