import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { spawn } from 'node:child_process'; import { DEV_PORT } from '../port'; const cwd = process.cwd(); const envPath = resolve(cwd, '.env'); const vitePath = resolve(cwd, 'vite.config.ts'); const didDocPath = resolve(cwd, 'static/.well-known/did.json'); const generatedServicePath = resolve(cwd, 'src/lib/spaces/tunnel-service.generated.ts'); /** Service fragment identifier used by our DID doc and service DID. */ const SERVICE_FRAGMENT = 'event_space'; const SERVICE_TYPE = 'AtmoSpaceService'; let tunnelUrl: string | null = null; let statusBarActive = false; // ── ANSI status bar ────────────────────────────────────────────── // Reserves the bottom row of the terminal for a persistent status line. // Logs scroll in the region above it. function getColumns(): number { return process.stdout.columns || 80; } function getRows(): number { return process.stdout.rows || 24; } function setupScrollRegion(): void { if (!process.stdout.isTTY) return; statusBarActive = true; const rows = getRows(); // Set scroll region to all rows except the last process.stdout.write(`\x1b[1;${rows - 1}r`); // Move cursor into scroll region process.stdout.write(`\x1b[${rows - 1};1H`); } function drawStatusBar(text: string): void { if (!process.stdout.isTTY) { process.stdout.write(text + '\n'); return; } const rows = getRows(); const cols = getColumns(); // Save cursor, move to bottom row, clear it, write status, restore cursor process.stdout.write('\x1b7'); process.stdout.write(`\x1b[${rows};1H`); process.stdout.write('\x1b[2K'); // Inverse video for the bar process.stdout.write(`\x1b[7m ${text.padEnd(cols - 1)}\x1b[0m`); process.stdout.write('\x1b8'); } function clearStatusBar(): void { if (!process.stdout.isTTY || !statusBarActive) return; const rows = getRows(); // Reset scroll region to full terminal process.stdout.write(`\x1b[1;${rows}r`); // Clear the bottom row process.stdout.write(`\x1b[${rows};1H\x1b[2K`); // Move cursor up process.stdout.write(`\x1b[${rows - 1};1H`); statusBarActive = false; } function writeLog(text: string): void { if (statusBarActive && process.stdout.isTTY) { // Write inside the scroll region, which auto-scrolls above the bar process.stdout.write(text); } else { process.stdout.write(text); } } // Redraw on terminal resize process.stdout.on('resize', () => { if (!statusBarActive || !tunnelUrl) return; setupScrollRegion(); drawStatusBar(`Tunnel: ${tunnelUrl} | Ctrl+C to stop`); }); // ── .env helpers ───────────────────────────────────────────────── function readEnv(): string { return readFileSync(envPath, 'utf8'); } function writeEnv(content: string): void { writeFileSync(envPath, content); } function setEnvVar(key: string, value: string): void { let env = readEnv(); const re = new RegExp(`^(#\\s*)?${key}=.*$`, 'm'); const line = `${key}=${value}`; if (re.test(env)) { env = env.replace(re, line); } else { env = env.trimEnd() + '\n' + line + '\n'; } writeEnv(env); } function clearEnvVar(key: string): void { let env = readEnv(); const re = new RegExp(`^${key}=.*$`, 'm'); if (re.test(env)) { env = env.replace(re, `# ${key}=`); writeEnv(env); } } // ── vite config helpers ────────────────────────────────────────── function setViteAllowedHosts(hostname: string): void { let vite = readFileSync(vitePath, 'utf8'); if (/allowedHosts\s*:/.test(vite)) { vite = vite.replace(/allowedHosts\s*:\s*\[.*?\]/s, `allowedHosts: ['${hostname}']`); } else if (/server\s*:\s*\{/.test(vite)) { vite = vite.replace(/server\s*:\s*\{/, `server: {\n\t\tallowedHosts: ['${hostname}'],`); } writeFileSync(vitePath, vite); } function clearViteAllowedHosts(): void { let vite = readFileSync(vitePath, 'utf8'); if (/allowedHosts\s*:/.test(vite)) { vite = vite.replace(/allowedHosts\s*:\s*\[.*?\]/s, 'allowedHosts: []'); } writeFileSync(vitePath, vite); } // ── did doc + generated service file ───────────────────────────── function writeDidDoc(hostname: string, tunnelUrl: string): void { const did = `did:web:${hostname}`; const doc = { '@context': ['https://www.w3.org/ns/did/v1'], id: did, service: [ { id: `#${SERVICE_FRAGMENT}`, type: SERVICE_TYPE, serviceEndpoint: tunnelUrl } ] }; mkdirSync(dirname(didDocPath), { recursive: true }); writeFileSync(didDocPath, JSON.stringify(doc, null, 2) + '\n'); } function writeGeneratedService(hostname: string, tunnelUrl: string): void { // NOTE: plain DID, no fragment. PDSes reject fragments in getServiceAuth's `aud` param, // and the library's middleware does strict string equality. The fragment is only used // by PDSes to look up service entries in the DID doc for Atproto-Proxy routing — that // concern is separate from JWT audience validation. const did = `did:web:${hostname}`; const body = `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` + ` * When the tunnel is running, this file is rewritten with the tunnel's\n` + ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` + `export const SERVICE_DID: string | null = ${JSON.stringify(did)};\n` + `export const SERVICE_URL: string | null = ${JSON.stringify(tunnelUrl)};\n`; mkdirSync(dirname(generatedServicePath), { recursive: true }); writeFileSync(generatedServicePath, body); } function resetGeneratedService(): void { const body = `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` + ` * When the tunnel is running, this file is rewritten with the tunnel's\n` + ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` + `export const SERVICE_DID: string | null = null;\n` + `export const SERVICE_URL: string | null = null;\n`; writeFileSync(generatedServicePath, body); } // ── cleanup ────────────────────────────────────────────────────── function cleanup(): void { clearStatusBar(); console.log('\nCleaning up...'); if (tunnelUrl) { clearEnvVar('OAUTH_PUBLIC_URL'); console.log(' Cleared OAUTH_PUBLIC_URL from .env'); clearViteAllowedHosts(); console.log(' Cleared allowedHosts from vite.config.ts'); resetGeneratedService(); console.log(' Reset src/lib/spaces/tunnel-service.generated.ts'); } } // ── main ───────────────────────────────────────────────────────── const child = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${DEV_PORT}`], { stdio: ['ignore', 'pipe', 'pipe'] }); child.stderr.on('data', (data: Buffer) => { const output = data.toString(); if (!tunnelUrl) { const match = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); if (match) { tunnelUrl = match[0]; const hostname = new URL(tunnelUrl).hostname; setEnvVar('OAUTH_PUBLIC_URL', tunnelUrl); setViteAllowedHosts(hostname); writeDidDoc(hostname, tunnelUrl); writeGeneratedService(hostname, tunnelUrl); writeLog(`\n Set OAUTH_PUBLIC_URL=${tunnelUrl}\n`); writeLog(` Set vite allowedHosts to [${hostname}]\n`); writeLog(` Wrote static/.well-known/did.json (did:web:${hostname}#${SERVICE_FRAGMENT})\n`); writeLog(` Wrote src/lib/spaces/tunnel-service.generated.ts\n`); writeLog(` Tunnel is ready! Restart your dev server to pick up the new URL.\n\n`); setupScrollRegion(); drawStatusBar(`Tunnel: ${tunnelUrl} | Ctrl+C to stop`); return; } } writeLog(output); }); child.stdout.on('data', (data: Buffer) => { writeLog(data.toString()); }); child.on('close', (code) => { cleanup(); process.exit(code ?? 0); }); process.on('SIGINT', () => { child.kill('SIGINT'); }); process.on('SIGTERM', () => { child.kill('SIGTERM'); });