#!/usr/bin/env node /* * Start a tiny local static server and expose it through Tailscale Serve. * Node is used for the local server so the Tailscale route only needs to * proxy a localhost port. */ 'use strict'; const fs = require('node:fs'); const http = require('node:http'); const net = require('node:net'); const path = require('node:path'); const { spawn, spawnSync } = require('node:child_process'); class PreviewError extends Error {} function usage() { return `Usage: node serve_file.js FILE [--mount-path PATH] [--port PORT] [--ttl SECONDS] Expose FILE through Tailscale Serve. Node serves FILE's parent directory on localhost; Tailscale proxies that local server to the tailnet. `; } function parseArgs(argv) { const args = { file: null, root: null, mountPath: null, port: 80, localPort: 0, ttl: 3600, tailscalePort: null, server: false, }; for (let i = 0; i < argv.length; i += 1) { const value = argv[i]; if (value === '--help' || value === '-h') { console.log(usage()); process.exit(0); } if (value === '--server') { args.server = true; continue; } if (value === '--root') { args.root = argv[++i]; continue; } if (value === '--mount-path') { args.mountPath = argv[++i]; continue; } if (value === '--port') { args.port = Number(argv[++i]); continue; } if (value === '--local-port') { args.localPort = Number(argv[++i]); continue; } if (value === '--ttl') { args.ttl = Number(argv[++i]); continue; } if (value === '--tailscale-port') { args.tailscalePort = Number(argv[++i]); continue; } if (value.startsWith('-')) { throw new PreviewError(`未知参数:${value}`); } if (args.file === null) { args.file = value; } else { throw new PreviewError(`多余参数:${value}`); } } return args; } function validPort(port, name) { if (!Number.isInteger(port) || port < 0 || port > 65535 || (name === 'port' && port === 0)) { throw new PreviewError(`${name} 必须是有效端口`); } } function validTtl(ttl) { if (!Number.isInteger(ttl) || ttl <= 0) { throw new PreviewError('ttl 必须是正整数秒数'); } } function normalizeMountPath(value) { const mountPath = value.trim(); if (!mountPath.startsWith('/') || mountPath === '/') { throw new PreviewError('mount path 必须以 / 开头且不能是根路径'); } if (mountPath.includes('?') || mountPath.includes('#') || mountPath.includes('//')) { throw new PreviewError('mount path 不能包含 query、fragment 或空路径段'); } const parts = mountPath.split('/'); if (parts.some((part) => part === '.' || part === '..')) { throw new PreviewError('mount path 不能包含 . 或 ..'); } return mountPath.replace(/\/$/, ''); } function defaultMountPath(directory) { const slug = path.basename(directory).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); if (!slug) { throw new PreviewError('无法从文件目录生成 mount path,请显式提供 --mount-path'); } return `/preview/${slug}`; } function run(command, commandArgs) { const result = spawnSync(command, commandArgs, { encoding: 'utf8' }); if (result.error) { throw new PreviewError(`无法运行 ${command}:${result.error.message}`); } return result; } function tailscaleIPv4() { const result = run('tailscale', ['ip', '-4']); if (result.status !== 0) { throw new PreviewError(`Tailscale 节点不可用:${(result.stderr || result.stdout).trim() || 'tailscale ip -4 失败'}`); } const address = result.stdout.split(/\s+/).find((candidate) => net.isIPv4(candidate)); if (!address) { throw new PreviewError('tailscale 没有返回 IPv4 地址;请确认节点已连接 tailnet'); } return address; } function checkTailscaleServe() { const result = run('tailscale', ['serve', 'status', '--json']); if (result.status !== 0) { throw new PreviewError(`无法读取 Tailscale Serve 状态:${(result.stderr || result.stdout).trim() || 'status 失败'}`); } } function serveStatic(root, mountPath, port, ttl, tailscalePort) { const contentTypes = { '.css': 'text/css; charset=utf-8', '.gif': 'image/gif', '.html': 'text/html; charset=utf-8', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.txt': 'text/plain; charset=utf-8', '.webp': 'image/webp', }; const server = http.createServer((request, response) => { let pathname; try { pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); } catch { response.writeHead(400).end('Bad request'); return; } if (pathname === mountPath || pathname.startsWith(`${mountPath}/`)) { pathname = pathname.slice(mountPath.length) || '/'; } const relativePath = pathname.replace(/^\/+/, ''); const candidate = path.resolve(root, relativePath); if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) { response.writeHead(403).end('Forbidden'); return; } fs.stat(candidate, (statError, info) => { if (statError || !info.isFile()) { response.writeHead(404).end('Not found'); return; } response.setHeader('Content-Type', contentTypes[path.extname(candidate).toLowerCase()] || 'application/octet-stream'); fs.createReadStream(candidate).on('error', () => response.destroy()).pipe(response); }); }); server.listen(port, '127.0.0.1', () => { const address = server.address(); console.log(`READY ${address.port}`); }); setTimeout(() => { spawnSync('tailscale', [ 'serve', `--http=${tailscalePort}`, `--set-path=${mountPath}`, 'off', ], { stdio: 'ignore' }); server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 1000); }, ttl * 1000); } function startDetachedServer(root, mountPath, localPort, ttl, tailscalePort) { const child = spawn(process.execPath, [ __filename, '--server', '--root', root, '--mount-path', mountPath, '--port', String(localPort), '--ttl', String(ttl), '--tailscale-port', String(tailscalePort), ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'], }); return new Promise((resolve, reject) => { let output = ''; const timeout = setTimeout(() => { child.kill(); reject(new PreviewError('Node 静态服务启动超时')); }, 5000); child.stdout.on('data', (chunk) => { output += chunk.toString(); const match = output.match(/READY (\d+)/); if (!match) return; clearTimeout(timeout); child.stdout.destroy(); child.stderr.destroy(); child.unref(); resolve({ pid: child.pid, port: Number(match[1]) }); }); child.on('error', (error) => { clearTimeout(timeout); reject(new PreviewError(`Node 静态服务启动失败:${error.message}`)); }); child.on('exit', (code) => { if (code !== null && code !== 0) { clearTimeout(timeout); reject(new PreviewError(`Node 静态服务退出,code=${code}`)); } }); }); } function stopChild(pid) { if (!pid) return; try { process.kill(pid, 'SIGTERM'); } catch { // The child may have exited while Tailscale was being configured. } } async function launch(args) { if (!args.file) throw new PreviewError('缺少要展示的文件'); validPort(args.port, 'port'); validPort(args.localPort, 'local-port'); validTtl(args.ttl); const filePath = path.resolve(args.file); let info; try { info = fs.statSync(filePath); } catch { throw new PreviewError(`输入文件不存在:${filePath}`); } if (!info.isFile()) throw new PreviewError(`输入不是普通文件:${filePath}`); const root = path.dirname(filePath); const mountPath = args.mountPath ? normalizeMountPath(args.mountPath) : defaultMountPath(root); const ip = tailscaleIPv4(); checkTailscaleServe(); const child = await startDetachedServer(root, mountPath, args.localPort, args.ttl, args.port); try { const result = run('tailscale', [ 'serve', '--bg', `--http=${args.port}`, `--set-path=${mountPath}`, '--yes', `http://127.0.0.1:${child.port}`, ]); if (result.status !== 0) { throw new PreviewError(`Tailscale Serve 配置失败:${(result.stderr || result.stdout).trim() || 'serve 命令失败'}`); } } catch (error) { stopChild(child.pid); throw error; } const relative = path.relative(root, filePath).split(path.sep).map(encodeURIComponent).join('/'); const suffix = args.port === 80 ? '' : `:${args.port}`; const url = `http://${ip}${suffix}${mountPath}/${relative}`; console.log(JSON.stringify({ file: filePath, directory: root, mountPath, ip, port: args.port, localPort: child.port, pid: child.pid, ttl: args.ttl, expiresAt: new Date(Date.now() + args.ttl * 1000).toISOString(), url, stopCommand: `kill ${child.pid} 2>/dev/null || true; tailscale serve --http=${args.port} --set-path=${mountPath} off`, })); } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.server) { if (!args.mountPath || !args.root || !args.tailscalePort) throw new PreviewError('内部参数不完整'); validPort(args.port, 'local-port'); validPort(args.tailscalePort, 'port'); validTtl(args.ttl); serveStatic(path.resolve(args.root), args.mountPath, args.port, args.ttl, args.tailscalePort); return; } await launch(args); } main().catch((error) => { console.error(`previewer: ${error.message}`); process.exitCode = 2; });