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
package/src/cli/setupModel.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
131
|
-
//
|
|
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;
|
package/src/cli/setupWizard.js
CHANGED
|
@@ -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: '
|
|
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 === '
|
|
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
|
|
144
|
-
note(t('setup.
|
|
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.
|
|
146
|
+
message: withBack(t('setup.askBrowserDomain')),
|
|
151
147
|
placeholder: 'preview.example.com',
|
|
152
148
|
initialValue: a.previewDomain || '',
|
|
153
149
|
validate: validatePreviewDomain,
|
package/src/cli/supervisor.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
package/src/previewServer.js
CHANGED
|
@@ -1,95 +1,141 @@
|
|
|
1
1
|
// server/src/previewServer.js
|
|
2
|
-
// Serves registered static-preview directories under
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
10
|
+
function credentialOk(provided, token) {
|
|
11
|
+
if (!provided) return false;
|
|
12
|
+
try { return tokenEquals(provided, token); } catch { return false; }
|
|
13
|
+
}
|
|
13
14
|
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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.
|
|
81
|
-
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
router.get('/:name', (
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 =
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
73
|
+
return resultFor(entry);
|
|
70
74
|
};
|
|
71
75
|
|
|
72
|
-
async function register({ name, dir, port
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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) {
|