diff --git a/e2e-playwright/tailscale-preview.spec.ts b/e2e-playwright/tailscale-preview.spec.ts new file mode 100644 index 00000000..bd4d0519 --- /dev/null +++ b/e2e-playwright/tailscale-preview.spec.ts @@ -0,0 +1,512 @@ +import { test, expect } from '@playwright/test' +import { execFile, execSync } from 'node:child_process' +import { promisify } from 'node:util' +import { mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const execFileAsync = promisify(execFile) + +const SERVER_URL = process.env['OPENFOX_E2E_SERVER_URL'] ?? 'http://localhost:10669' +const STANDALONE_FIXTURE_PATH = '/tmp/openfox-fixtures/standalone-fixture.js' +const VITE_FIXTURE_DIR = '/tmp/openfox-fixtures/vite-fixture' + +/** + * The Tailscale-preview spec depends on a real Tailscale node reachable from + * the runner (it captures the actual HTTPS MagicDNS URL with the real cert). + * Gate the spec so the standard E2E/CI suite (which runs without Tailscale) + * does not fail. All three checks must pass: + * 1. OPENFOX_TS_NODE_HOST set + * 2. OPENFOX_TS_NODE_IP set + * 3. `tailscale` binary present AND backend state == "Running" + * If any fails, the spec is skipped with a precise reason. Verify locally + * with: OPENFOX_TS_NODE_HOST=… OPENFOX_TS_NODE_IP=… npx playwright test tailscale-preview.spec.ts + */ +function checkTailscaleHarness(): { ok: true } | { ok: false; reason: string } { + if (!process.env['OPENFOX_TS_NODE_HOST']) { + return { ok: false, reason: 'OPENFOX_TS_NODE_HOST not set' } + } + if (!process.env['OPENFOX_TS_NODE_IP']) { + return { ok: false, reason: 'OPENFOX_TS_NODE_IP not set' } + } + try { + execSync('which tailscale', { stdio: 'pipe', timeout: 1000 }) + } catch { + return { ok: false, reason: 'tailscale binary not found in PATH' } + } + try { + const stdout = execSync('tailscale status --json', { stdio: 'pipe', timeout: 4000 }).toString() + const parsed = JSON.parse(stdout) as { BackendState?: string } + if (parsed.BackendState !== 'Running') { + return { ok: false, reason: `tailscale backend not running (${parsed.BackendState ?? 'unknown'})` } + } + } catch (err) { + return { + ok: false, + reason: `tailscale status check failed (${err instanceof Error ? err.message : String(err)})`, + } + } + return { ok: true } +} + +const TAILSCALE_HARNESS = checkTailscaleHarness() +const SKIP_TAILSCALE = !TAILSCALE_HARNESS.ok +const SKIP_REASON = TAILSCALE_HARNESS.ok ? null : TAILSCALE_HARNESS.reason +const NODE_HOST = process.env['OPENFOX_TS_NODE_HOST'] ?? 'node.tailnet.ts.net' +const TS_NODE_IP = process.env['OPENFOX_TS_NODE_IP'] ?? '127.0.0.1' + +interface PreExistingEntry { + webKey: string + tcpKey?: string + proxy: string +} + +async function readServeStatus(): Promise<{ + Web: Record }> + TCP: Record + Foreground?: Record +}> { + const { stdout } = await execFileAsync('tailscale', ['serve', 'status', '--json'], { timeout: 4000 }) + return JSON.parse(stdout) +} + +async function fetchPreExistingEntries(): Promise { + const status = await readServeStatus() + const out: PreExistingEntry[] = [] + for (const [webKey, entry] of Object.entries(status.Web ?? {})) { + const handlers = (entry as { Handlers?: Record }).Handlers ?? {} + const root = handlers['/'] + if (root && root.Proxy) { + out.push({ webKey, proxy: root.Proxy }) + } + } + return out +} + +async function findEntryFor( + status: Awaited>, + host: string, +): Promise<{ webKey: string; proxy: string } | null> { + for (const [webKey, entry] of Object.entries(status.Web ?? {})) { + if (webKey.startsWith(host + ':')) { + const handlers = (entry as { Handlers?: Record }).Handlers ?? {} + const root = handlers['/'] + if (root && root.Proxy) { + return { webKey, proxy: root.Proxy } + } + } + } + return null +} + +interface TestContext { + workdir: string + fixtureCommand: string + fixtureUrl: string + authToken: string + projectId: string + sessionId: string + cleanup: () => Promise +} + +async function setupProject(name: string, fixtureCommand: string, fixtureUrl: string): Promise { + // Auth strategy is "local" in this dev environment — no token required. + // We pass the dummy header so the API surface mirrors a real session. + const authToken = 'local-no-auth' + const authHeaders = { 'Content-Type': 'application/json' } + + const timestamp = Date.now() + const workdir = join(tmpdir(), `openfox-tailscale-e2e-${timestamp}`) + await mkdir(join(workdir, '.openfox'), { recursive: true }) + + // dev.json — filename is the same regardless of OPENFOX_DEV. + // tailscaleExpose: true is the V1.1 way of opting in to the auto-preview. + await writeFile( + join(workdir, '.openfox', 'dev.json'), + JSON.stringify( + { + command: fixtureCommand, + url: fixtureUrl, + hotReload: false, + disableInspect: false, + tailscaleExpose: true, + }, + null, + 2, + ) + '\n', + ) + + // Create project + session + const projectData = await fetch(`${SERVER_URL}/api/projects`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ name, workdir }), + }).then((r) => r.json()) + const projectId: string = projectData.project.id + + const sessionData = await fetch(`${SERVER_URL}/api/sessions`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ projectId, title: `${name} session` }), + }).then((r) => r.json()) + const sessionId: string = sessionData.session.id + + const cleanup = async () => { + try { + await rm(workdir, { recursive: true, force: true }) + } catch { + /* ignore */ + } + } + + return { workdir, fixtureCommand, fixtureUrl, authToken, projectId, sessionId, cleanup } +} + +async function apiGet(path: string, _token: string): Promise { + const res = await fetch(`${SERVER_URL}${path}`) + if (!res.ok) throw new Error(`GET ${path} failed: ${await res.text()}`) + return res.json() +} + +async function apiPost(path: string, _token: string): Promise { + const res = await fetch(`${SERVER_URL}${path}`, { method: 'POST' }) + if (!res.ok) throw new Error(`POST ${path} failed: ${await res.text()}`) + return res.json() +} + +test.describe('Tailscale preview — Test A (standalone HTTP fixture)', () => { + test.skip(SKIP_TAILSCALE, SKIP_REASON ?? 'tailscale harness unavailable') + + let ctx: TestContext + let preExistingEntries: PreExistingEntry[] = [] + + test.beforeAll(async () => { + test.setTimeout(120_000) + preExistingEntries = await fetchPreExistingEntries() + console.log('[Test A] Pre-existing Serve entries:', JSON.stringify(preExistingEntries)) + + ctx = await setupProject( + 'Tailscale Preview Test A', + `node ${STANDALONE_FIXTURE_PATH} --port=\${PORT}`, + 'http://127.0.0.1:${PORT}', + ) + }) + + test.afterAll(async () => { + // Stop dev server to clean up + try { + await apiPost(`/api/dev-server/stop?workdir=${encodeURIComponent(ctx.workdir)}`, ctx.authToken) + } catch { + /* ignore */ + } + await ctx.cleanup() + }) + + test('UI: Expose via Tailscale (config-driven) auto-launches a Tailnet preview after Start', async ({ + page, + context, + }) => { + test.setTimeout(120_000) + + // 1. Navigate to the session so the sidebar with dev server controls is mounted. + await page.goto(`${SERVER_URL}/p/${ctx.projectId}/s/${ctx.sessionId}`) + await page.waitForLoadState('networkidle') + + // 1b. Sanity-check: open the Dev Server Config modal and verify the third + // checkbox "Expose via Tailscale" is present and reflects config.tailscaleExpose=true. + await page.getByTitle('Configure dev server').first().click() + await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.locator('label[for="tailscaleExpose"]')).toHaveText('Expose via Tailscale') + const checkbox = page.locator('input#tailscaleExpose') + await expect(checkbox).toBeChecked() + // Close modal without modifying anything. + await page.getByRole('button', { name: 'Cancel' }).click() + + // 2. Click Start in the Dev Server footer. Auto-expose fires after spawn. + await page.getByRole('button', { name: 'Start' }).first().click() + + // 3. Wait for the dev server state to reach "running" by polling the API. + let assignedUrl: string | null = null + await expect + .poll( + async () => { + const status = (await apiGet( + `/api/dev-server?workdir=${encodeURIComponent(ctx.workdir)}`, + ctx.authToken, + )) as { + state: string + url: string | null + } + assignedUrl = status.url + return status.state + }, + { timeout: 15_000, intervals: [500] }, + ) + .toBe('running') + + // 3b. Wait for the local dev server URL to actually respond (the spawned fixture needs time to bind). + if (assignedUrl) { + await expect + .poll( + async () => { + try { + const res = await fetch(assignedUrl!, { signal: AbortSignal.timeout(1000) }) + return res.status + } catch { + return 0 + } + }, + { timeout: 10_000, intervals: [300] }, + ) + .toBeGreaterThanOrEqual(200) + console.log('[Test A] Local dev server up at:', assignedUrl) + } + + // 4. The Tailnet preview should auto-launch — no manual click expected. + // The footer only shows the URL as secondary info (no Expose/Retry/Stop buttons). + await expect(page.getByRole('button', { name: 'Expose on Tailscale' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Retry' })).toHaveCount(0) + + // 5. Wait for the Tailnet URL to appear in the UI (compact + full sidebar both render it). + const urlLocator = page.locator('div.font-mono.text-xs.text-text-primary.break-all.select-all').first() + await expect(urlLocator).toBeVisible({ timeout: 15_000 }) + const tailnetUrl = (await urlLocator.textContent())?.trim() + expect(tailnetUrl).toBeTruthy() + expect(tailnetUrl).toMatch(/^https:\/\//) + console.log('[Test A] Tailnet URL:', tailnetUrl) + + // 6. Confirm the entry appears in tailscale serve status JSON. + const entry = await findEntryFor(await readServeStatus(), NODE_HOST) + expect(entry).not.toBeNull() + expect(entry?.proxy).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + + // 7. Open the Tailnet URL in a NEW Chromium instance that has Tailscale + // MagicDNS wired in. Playwright's default Chromium uses the system DNS which + // does not know about the tailnet, so we launch a dedicated browser with + // --host-resolver-rules to MAP the tailnet host to the node's Tailscale IP. + // The real URL (with the real SNI / Host header / HTTPS cert) is preserved. + const { chromium } = await import('@playwright/test') + const tsIp = TS_NODE_IP + const verifyBrowser = await chromium.launch({ + args: [`--host-resolver-rules=MAP ${NODE_HOST} ${tsIp}`], + }) + let verifyCtx = await verifyBrowser.newContext() + let verifyPage = await verifyCtx.newPage() + let verifyHttpStatus: number | null = null + let verifyBody = '' + verifyPage.on('response', async (response) => { + if (verifyHttpStatus === null) verifyHttpStatus = response.status() + }) + let response: Awaited> | null = null + try { + response = await verifyPage.goto(tailnetUrl!, { waitUntil: 'domcontentloaded', timeout: 15_000 }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('TLS') || msg.includes('certificate') || msg.includes('CERT')) { + console.log( + '[Test A] Real HTTPS cert rejected by headless Chromium — retrying with ignoreHTTPSErrors=true (reported as harness fallback)', + ) + await verifyPage.close() + await verifyCtx.close() + verifyCtx = await verifyBrowser.newContext({ ignoreHTTPSErrors: true }) + verifyPage = await verifyCtx.newPage() + verifyPage.on('response', async (r) => { + if (verifyHttpStatus === null) verifyHttpStatus = r.status() + }) + response = await verifyPage.goto(tailnetUrl!, { waitUntil: 'domcontentloaded', timeout: 15_000 }) + } else { + throw err + } + } + expect(response).not.toBeNull() + expect(response!.status()).toBe(200) + await verifyPage.waitForSelector('#marker', { timeout: 5_000 }) + const markerText = await verifyPage.locator('#marker').textContent() + expect(markerText).toContain('TAILSCALE_PREVIEW_TEST_A') + + // Capture the listen_port reported by the fixture (it must match the local assigned port) + const listenPortText = await verifyPage.locator('dt:has-text("listen_port") + dd').textContent() + const reportedPort = parseInt(listenPortText?.trim() ?? '0', 10) + expect(reportedPort).toBeGreaterThan(0) + + // Confirm the host header seen by the fixture is the tailnet host (HTTPS via Tailscale) + const hostHeaderText = await verifyPage.locator('dt:has-text("host_header") + dd').textContent() + console.log('[Test A] fixture saw host_header =', hostHeaderText) + + verifyBody = (await verifyPage.content()) ?? '' + await verifyPage.close() + await verifyCtx.close() + await verifyBrowser.close() + + // 8. Stop the dev server. The Tailnet preview is auto-torn-down via the + // dev-server lifecycle hook — no separate Stop button is exposed in the UI. + await page.getByRole('button', { name: 'Stop' }).first().click() + + // 9. Wait for the Tailnet URL block to disappear. + await expect(urlLocator).toBeHidden({ timeout: 10_000 }) + + // 10. Confirm the Tailnet URL is no longer reachable. + let stillReachable = true + try { + const probe = await fetch(tailnetUrl!, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(3000) }) + // Some Tailscale entries return non-2xx even when "present" (e.g. cert handshake fails); + // what we really want to assert is that the entry has been removed from serve status. + stillReachable = probe.status > 0 && probe.status < 500 + } catch { + stillReachable = false + } + console.log('[Test A] After stop, URL reachable (200-499)?', stillReachable) + + // 11. Confirm the entry is gone from serve status JSON. + await expect + .poll( + async () => { + const status = await readServeStatus() + const found = await findEntryFor(status, NODE_HOST) + // Look for an entry whose port matches the URL's port. + if (!found) return null + const port = new URL(tailnetUrl!).port + const entryPort = found.webKey.split(':').pop() + return entryPort === port ? found : null + }, + { timeout: 8_000, intervals: [500] }, + ) + .toBeNull() + + // 12. Confirm the PRE-EXISTING 443 entry is still intact. + const postEntries = await fetchPreExistingEntries() + console.log('[Test A] Post-test Serve entries:', JSON.stringify(postEntries)) + for (const pre of preExistingEntries) { + expect(postEntries.find((p) => p.webKey === pre.webKey && p.proxy === pre.proxy)).toBeTruthy() + } + }) +}) + +test.describe('Tailscale preview — Test B (Vite)', () => { + test.skip(SKIP_TAILSCALE, SKIP_REASON ?? 'tailscale harness unavailable') + + let ctx: TestContext + let preExistingEntries: PreExistingEntry[] = [] + + test.beforeAll(async () => { + test.setTimeout(180_000) + preExistingEntries = await fetchPreExistingEntries() + console.log('[Test B] Pre-existing Serve entries:', JSON.stringify(preExistingEntries)) + + ctx = await setupProject( + 'Tailscale Preview Test B Vite', + `cd ${VITE_FIXTURE_DIR} && node_modules/.bin/vite --port \${PORT} --host 127.0.0.1 --strictPort`, + 'http://127.0.0.1:${PORT}', + ) + }) + + test.afterAll(async () => { + try { + await apiPost(`/api/dev-server/stop?workdir=${encodeURIComponent(ctx.workdir)}`, ctx.authToken) + } catch { + /* ignore */ + } + await ctx.cleanup() + }) + + test('UI: Vite dev server reachable on tailnet preview if Host validation passes', async ({ page, context }) => { + test.setTimeout(120_000) + + await page.goto(`${SERVER_URL}/p/${ctx.projectId}/s/${ctx.sessionId}`) + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: 'Start' }).first().click() + + await expect + .poll( + async () => { + const status = (await apiGet( + `/api/dev-server?workdir=${encodeURIComponent(ctx.workdir)}`, + ctx.authToken, + )) as { + state: string + } + return status.state + }, + { timeout: 30_000, intervals: [500] }, + ) + .toBe('running') + + // No manual "Expose on Tailscale" click — the preview is auto-launched + // because dev.json has tailscaleExpose: true (Vite fixture already written + // by setupProject). Just wait for the Tailnet URL to appear. + + const urlLocator = page.locator('div.font-mono.text-xs.text-text-primary.break-all.select-all').first() + await expect(urlLocator).toBeVisible({ timeout: 30_000 }) + const tailnetUrl = (await urlLocator.textContent())?.trim() + expect(tailnetUrl).toBeTruthy() + console.log('[Test B] Tailnet URL:', tailnetUrl) + + const entry = await findEntryFor(await readServeStatus(), NODE_HOST) + expect(entry).not.toBeNull() + + // Open the Tailnet URL — Vite may reject the host header via its allowedHosts guard. + // Use a dedicated Chromium with --host-resolver-rules so the tailnet host resolves. + const { chromium: chromiumB } = await import('@playwright/test') + const tsIpB = TS_NODE_IP + const verifyBrowserB = await chromiumB.launch({ + args: [`--host-resolver-rules=MAP ${NODE_HOST} ${tsIpB}`], + }) + let verifyCtxB = await verifyBrowserB.newContext() + let verifyPageB = await verifyCtxB.newPage() + let response: Awaited> | null = null + let body = '' + let status = 0 + try { + response = await verifyPageB.goto(tailnetUrl!, { waitUntil: 'domcontentloaded', timeout: 15_000 }) + status = response?.status() ?? 0 + body = await verifyPageB.content() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('TLS') || msg.includes('certificate') || msg.includes('CERT')) { + console.log( + '[Test B] Real HTTPS cert rejected by headless Chromium — retrying with ignoreHTTPSErrors=true (reported as harness fallback)', + ) + await verifyPageB.close() + await verifyCtxB.close() + verifyCtxB = await verifyBrowserB.newContext({ ignoreHTTPSErrors: true }) + verifyPageB = await verifyCtxB.newPage() + response = await verifyPageB.goto(tailnetUrl!, { waitUntil: 'domcontentloaded', timeout: 15_000 }) + status = response?.status() ?? 0 + body = await verifyPageB.content() + } else { + await verifyPageB.close() + await verifyCtxB.close() + await verifyBrowserB.close() + throw err + } + } + expect(response).not.toBeNull() + await verifyPageB.close() + await verifyCtxB.close() + await verifyBrowserB.close() + + console.log('[Test B] HTTP status from Vite:', status) + console.log('[Test B] Body preview:', body.slice(0, 500)) + + if (status === 200 && body.includes('TAILSCALE_PREVIEW_TEST_B_VITE')) { + console.log('[Test B] RESULT: Vite served the fixture content via Tailscale — PASS') + expect(body).toContain('TAILSCALE_PREVIEW_TEST_B_VITE') + } else if (body.toLowerCase().includes('blocked request') || body.toLowerCase().includes('not allowed')) { + console.log('[Test B] RESULT: Vite rejected the Host header (allowedHosts) — INCOMPATIBILITY') + expect(body.toLowerCase()).toMatch(/blocked|allowed/) + } else { + throw new Error(`Unexpected response: status=${status} body=${body.slice(0, 300)}`) + } + + // Cleanup: stop the dev server. Preview is auto-torn-down via the dev-server lifecycle. + await page.getByRole('button', { name: 'Stop' }).first().click() + await expect(urlLocator).toBeHidden({ timeout: 10_000 }) + + // Pre-existing 443 entry must remain intact. + const postEntries = await fetchPreExistingEntries() + for (const pre of preExistingEntries) { + expect(postEntries.find((p) => p.webKey === pre.webKey && p.proxy === pre.proxy)).toBeTruthy() + } + }) +}) diff --git a/src/server/dev-server/__fixtures__/real-serve-status.json b/src/server/dev-server/__fixtures__/real-serve-status.json new file mode 100644 index 00000000..41c1d4d7 --- /dev/null +++ b/src/server/dev-server/__fixtures__/real-serve-status.json @@ -0,0 +1,20 @@ +{ + "TCP": { + "443": { "HTTPS": true } + }, + "Web": { + "node.tailnet.ts.net:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:10369" } } + } + }, + "Foreground": { + "f326d7c5f68a8f3f": { + "TCP": { "8443": { "HTTPS": true } }, + "Web": { + "node.tailnet.ts.net:8443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:10469" } } + } + } + } + } +} diff --git a/src/server/dev-server/manager.test.ts b/src/server/dev-server/manager.test.ts index 1c5a0cfd..fd081fb5 100644 --- a/src/server/dev-server/manager.test.ts +++ b/src/server/dev-server/manager.test.ts @@ -19,6 +19,17 @@ vi.mock('../utils/process-tree.js', () => ({ terminateProcessTree: vi.fn(), })) +vi.mock('./tailscale-preview.js', () => ({ + tailscalePreviewManager: { + start: vi.fn(), + stop: vi.fn(), + stopAll: vi.fn(), + isActive: vi.fn(), + getActiveUrl: vi.fn(), + }, + isTailscaleAvailable: vi.fn(), +})) + vi.mock('../runtime-config.js', () => ({ getRuntimeConfig: vi.fn(() => ({ mode: 'development' })), })) @@ -26,6 +37,7 @@ vi.mock('../runtime-config.js', () => ({ import { spawn } from 'node:child_process' import { readFile } from 'node:fs/promises' import { devServerManager } from './manager.js' +import { tailscalePreviewManager, isTailscaleAvailable } from './tailscale-preview.js' function makeMockProc(stdout = '', stderr = '', exitCode = 0) { const listeners: Record void> = {} @@ -154,6 +166,7 @@ describe('loadConfig with workspace fallback', () => { url: 'http://localhost:5173', hotReload: false, disableInspect: false, + tailscaleExpose: false, }) }) @@ -168,6 +181,7 @@ describe('loadConfig with workspace fallback', () => { url: 'http://localhost:5173', hotReload: false, disableInspect: false, + tailscaleExpose: false, }) expect(readFile).toHaveBeenCalledTimes(2) }) @@ -194,6 +208,48 @@ describe('loadConfig with workspace fallback', () => { expect(config).toBeNull() expect(readFile).toHaveBeenCalledTimes(1) }) + + it('reads tailscaleExpose=true when present in config', async () => { + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + command: 'npm run dev', + url: 'http://localhost:5173', + tailscaleExpose: true, + }), + ) + const config = await devServerManager.loadConfig('/some/project') + expect(config?.tailscaleExpose).toBe(true) + }) + + it('reads tailscaleExpose=false when explicitly set', async () => { + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + command: 'npm run dev', + url: 'http://localhost:5173', + tailscaleExpose: false, + }), + ) + const config = await devServerManager.loadConfig('/some/project') + expect(config?.tailscaleExpose).toBe(false) + }) + + it('defaults tailscaleExpose to false when absent', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:5173' })) + const config = await devServerManager.loadConfig('/some/project') + expect(config?.tailscaleExpose).toBe(false) + }) + + it('coerces non-boolean tailscaleExpose values to false (strict opt-in)', async () => { + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + command: 'npm run dev', + url: 'http://localhost:5173', + tailscaleExpose: 'yes', + }), + ) + const config = await devServerManager.loadConfig('/some/project') + expect(config?.tailscaleExpose).toBe(false) + }) }) describe('start with port probing and substitution', () => { @@ -242,6 +298,85 @@ describe('start with port probing and substitution', () => { expect(status.state).toBe('running') expect(status.url).toBe('http://localhost:3099') }) + + it('does NOT auto-launch preview when tailscaleExpose is absent (default OFF)', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3200' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('server started') as any) + + await devServerManager.start('/tmp/project-no-tailscale') + + expect(tailscalePreviewManager.start).not.toHaveBeenCalled() + }) + + it('does NOT auto-launch preview when tailscaleExpose is explicitly false', async () => { + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + command: 'npm run dev', + url: 'http://localhost:3201', + tailscaleExpose: false, + }), + ) + vi.mocked(spawn).mockReturnValue(makeMockProc('server started') as any) + + await devServerManager.start('/tmp/project-tailscale-off') + + expect(tailscalePreviewManager.start).not.toHaveBeenCalled() + }) + + it('auto-launches preview fire-and-forget when tailscaleExpose is true', async () => { + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + command: 'npm run dev', + url: 'http://localhost:3202', + tailscaleExpose: true, + }), + ) + vi.mocked(spawn).mockReturnValue(makeMockProc('server started') as any) + vi.mocked(isTailscaleAvailable).mockResolvedValue({ + available: true, + nodeName: 'laptop.tailnet.ts.net', + }) + vi.mocked(tailscalePreviewManager.start).mockResolvedValue({ + url: 'https://laptop.tailnet.ts.net:8443/', + remotePort: 8443, + }) + + const status = await devServerManager.start('/tmp/project-tailscale-on') + + // start() returns immediately at state=running; preview launches async + expect(status.state).toBe('running') + // The fire-and-forget promise resolves on the next microtask tick + await new Promise((resolve) => setImmediate(resolve)) + expect(tailscalePreviewManager.start).toHaveBeenCalledTimes(1) + expect(tailscalePreviewManager.start).toHaveBeenCalledWith('/tmp/project-tailscale-on', expect.any(Number)) + }) + + it('start() does not reject when preview auto-launch fails (caught internally)', async () => { + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + command: 'npm run dev', + url: 'http://localhost:3203', + tailscaleExpose: true, + }), + ) + vi.mocked(spawn).mockReturnValue(makeMockProc('server started') as any) + vi.mocked(isTailscaleAvailable).mockResolvedValue({ + available: true, + nodeName: 'laptop.tailnet.ts.net', + }) + // Preview throws after a microtask — start() must still resolve with state=running + vi.mocked(tailscalePreviewManager.start).mockImplementation(async () => { + await new Promise((resolve) => setImmediate(resolve)) + throw new Error('Access denied: serve config denied') + }) + + const status = await devServerManager.start('/tmp/project-tailscale-throws') + expect(status.state).toBe('running') + + // Let the fire-and-forget resolve and confirm no unhandled rejection + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + }) }) describe('instance keying by workdir', () => { @@ -340,3 +475,146 @@ describe('insertMarker', () => { expect(logsB).toHaveLength(0) }) }) + +describe('tailscalePreview integration', () => { + beforeEach(() => { + vi.mocked(readFile).mockReset() + vi.mocked(spawn).mockReset() + vi.mocked(tailscalePreviewManager.start).mockReset() + vi.mocked(tailscalePreviewManager.stop).mockReset() + vi.mocked(tailscalePreviewManager.stopAll).mockReset() + // Default: Tailscale is available. Individual tests can override. + vi.mocked(isTailscaleAvailable).mockResolvedValue({ available: true, nodeName: 'laptop.ts.net' }) + }) + + it('initial status includes a default tailscalePreview of status idle', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3110' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-init') + const status = devServerManager.getStatus('/tmp/preview-init') + expect(status.tailscalePreview).toEqual({ status: 'idle' }) + }) + + it('startTailscalePreview rejects when dev server is not running', async () => { + const status = await devServerManager.startTailscalePreview('/tmp/no-server') + expect(status.tailscalePreview.status).toBe('error') + expect(status.tailscalePreview.error).toMatch(/not running/i) + }) + + it('startTailscalePreview is idempotent when a preview is already active', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3111' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-already') + + vi.mocked(tailscalePreviewManager.start).mockResolvedValue({ + url: 'https://x.ts.net:8443/', + remotePort: 8443, + }) + + const first = await devServerManager.startTailscalePreview('/tmp/preview-already') + expect(first.tailscalePreview).toEqual({ status: 'active', url: 'https://x.ts.net:8443/' }) + + // Second call should be a no-op: returns the current active URL, no overwrite. + const second = await devServerManager.startTailscalePreview('/tmp/preview-already') + expect(second.tailscalePreview).toEqual({ status: 'active', url: 'https://x.ts.net:8443/' }) + // Only one underlying start should have happened. + expect(tailscalePreviewManager.start).toHaveBeenCalledTimes(1) + }) + + it('startTailscalePreview transitions to active when preview manager succeeds', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3112' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-success') + + vi.mocked(tailscalePreviewManager.start).mockResolvedValue({ + url: 'https://laptop.ts.net:8443/', + remotePort: 8443, + }) + + const status = await devServerManager.startTailscalePreview('/tmp/preview-success') + expect(status.tailscalePreview).toEqual({ status: 'active', url: 'https://laptop.ts.net:8443/' }) + }) + + it('startTailscalePreview records error when preview manager throws', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3113' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-fail') + + vi.mocked(tailscalePreviewManager.start).mockRejectedValue(new Error('Access denied: serve config denied')) + + const status = await devServerManager.startTailscalePreview('/tmp/preview-fail') + expect(status.tailscalePreview.status).toBe('error') + expect(status.tailscalePreview.error).toContain('Access denied') + }) + + it('startTailscalePreview surfaces a clear error when Tailscale is not available', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3113a' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-unavailable') + + vi.mocked(isTailscaleAvailable).mockResolvedValueOnce({ + available: false, + reason: 'spawn tailscale ENOENT', + }) + + const status = await devServerManager.startTailscalePreview('/tmp/preview-unavailable') + expect(status.tailscalePreview.status).toBe('error') + expect(status.tailscalePreview.error).toContain('ENOENT') + expect(tailscalePreviewManager.start).not.toHaveBeenCalled() + }) + + it('stopTailscalePreview returns to idle and calls preview manager stop', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3114' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-stop') + + vi.mocked(tailscalePreviewManager.start).mockResolvedValue({ + url: 'https://laptop.ts.net:8443/', + remotePort: 8443, + }) + vi.mocked(tailscalePreviewManager.stop).mockResolvedValue(undefined) + + await devServerManager.startTailscalePreview('/tmp/preview-stop') + const status = await devServerManager.stopTailscalePreview('/tmp/preview-stop') + expect(status.tailscalePreview).toEqual({ status: 'idle' }) + expect(tailscalePreviewManager.stop).toHaveBeenCalledWith('/tmp/preview-stop') + }) + + it('stop() on the dev server also tears down the active Tailscale preview', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3115' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-coupled') + + vi.mocked(tailscalePreviewManager.start).mockResolvedValue({ + url: 'https://laptop.ts.net:8443/', + remotePort: 8443, + }) + vi.mocked(tailscalePreviewManager.stop).mockResolvedValue(undefined) + + await devServerManager.startTailscalePreview('/tmp/preview-coupled') + await devServerManager.stop('/tmp/preview-coupled') + expect(tailscalePreviewManager.stop).toHaveBeenCalledWith('/tmp/preview-coupled') + const status = devServerManager.getStatus('/tmp/preview-coupled') + expect(status.tailscalePreview).toEqual({ status: 'idle' }) + }) + + it('stopAll() tears down every Tailscale preview', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ command: 'npm run dev', url: 'http://localhost:3116' })) + vi.mocked(spawn).mockReturnValue(makeMockProc('') as any) + await devServerManager.start('/tmp/preview-stopall-a') + await devServerManager.start('/tmp/preview-stopall-b') + + vi.mocked(tailscalePreviewManager.start).mockResolvedValue({ + url: 'https://x.ts.net:8443/', + remotePort: 8443, + }) + vi.mocked(tailscalePreviewManager.stopAll).mockResolvedValue(undefined) + vi.mocked(tailscalePreviewManager.stop).mockResolvedValue(undefined) + + await devServerManager.startTailscalePreview('/tmp/preview-stopall-a') + await devServerManager.startTailscalePreview('/tmp/preview-stopall-b') + + await devServerManager.stopAll() + expect(tailscalePreviewManager.stopAll).toHaveBeenCalled() + }) +}) diff --git a/src/server/dev-server/manager.ts b/src/server/dev-server/manager.ts index 19936f07..146bc402 100644 --- a/src/server/dev-server/manager.ts +++ b/src/server/dev-server/manager.ts @@ -5,9 +5,12 @@ import net from 'node:net' import { terminateProcessTree } from '../utils/process-tree.js' import { logger } from '../utils/logger.js' import { spawnShell } from '../utils/shell.js' -import type { DevServerConfig, DevServerState, DevServerStatus } from '../../shared/dev-server.js' +import type { DevServerConfig, DevServerState, DevServerStatus, TailscalePreview } from '../../shared/dev-server.js' +import { idlePreview } from '../../shared/dev-server.js' import { startInspectProxy } from './inspect-proxy.js' import type { SessionManager } from '../session/manager.js' +import { tailscalePreviewManager } from './tailscale-preview.js' +import { isTailscaleAvailable } from './tailscale-preview.js' const MAX_LOG_LINES = 2000 const MAX_LOG_BYTES = 100_000 @@ -44,6 +47,7 @@ export type StateListener = ( errorMessage: string | undefined, url: string | null, inspectProxyPort: number | null, + tailscalePreview: TailscalePreview, ) => void interface LogEntry { @@ -65,6 +69,7 @@ interface DevServerInstance { exited: boolean inspectProxyPort: number | null proxyCleanup: (() => void) | null + tailscalePreview: TailscalePreview } function createInstance(): DevServerInstance { @@ -81,6 +86,7 @@ function createInstance(): DevServerInstance { exited: true, inspectProxyPort: null, proxyCleanup: null, + tailscalePreview: idlePreview(), } } @@ -132,8 +138,9 @@ class DevServerManager { const instance = this.getInstance(workdir) const url = instance.resolvedUrl ?? instance.config?.url ?? null const inspectProxyPort = instance.inspectProxyPort + const tailscalePreview = instance.tailscalePreview for (const listener of this.stateListeners) { - listener(resolved, state, errorMessage, url, inspectProxyPort) + listener(resolved, state, errorMessage, url, inspectProxyPort, tailscalePreview) } } @@ -200,6 +207,7 @@ class DevServerManager { url: parsed.url, hotReload: parsed.hotReload ?? false, disableInspect: parsed.disableInspect ?? false, + tailscaleExpose: parsed.tailscaleExpose === true, } } catch { return null @@ -313,6 +321,11 @@ class DevServerManager { proc.on('close', (code) => { instance.exited = true instance.process = null + // Crash path: drop any active Tailscale preview (non-blocking). + if (instance.tailscalePreview.status !== 'idle') { + void tailscalePreviewManager.stop(workdir) + instance.tailscalePreview = idlePreview() + } if (code !== 0 && code !== null) { const recentLogs = instance.logs.slice(-10).join('') const errorMessage = `Process exited with code ${code}\n${recentLogs}`.trim() @@ -339,12 +352,26 @@ class DevServerManager { this.emitStateChange(workdir, 'running', undefined) logger.info('Dev server started', { workdir, command: resolvedCommand, port: assignedPort }) + // Auto-launch the Tailscale preview if the project's config requests it. + // Fire-and-forget: startTailscalePreview absorbs its own errors (catch → + // status='error'), so this cannot become an unhandled rejection. The dev + // server is fully usable regardless of the Tailscale outcome. + if (config.tailscaleExpose === true && instance.assignedPort !== null) { + void this.startTailscalePreview(workdir) + } + return this.getStatus(workdir) } async stop(workdir: string): Promise { const instance = this.getInstance(workdir) + // Drop any active Tailscale preview BEFORE killing the dev server. + if (instance.tailscalePreview.status !== 'idle') { + await tailscalePreviewManager.stop(workdir) + instance.tailscalePreview = idlePreview() + } + if (instance.process && !instance.exited) { await terminateProcessTree(instance.process, { exited: () => instance.exited }) instance.process = null @@ -396,6 +423,7 @@ class DevServerManager { config: instance.config, errorMessage: instance.errorMessage, inspectProxyPort: instance.inspectProxyPort, + tailscalePreview: instance.tailscalePreview, } } @@ -428,10 +456,76 @@ class DevServerManager { } async stopAll(): Promise { + await tailscalePreviewManager.stopAll() const stops = Array.from(this.instances.keys()).map((workdir) => this.stop(workdir)) await Promise.allSettled(stops) this.instances.clear() } + + async startTailscalePreview(workdir: string): Promise { + const instance = this.getInstance(workdir) + + if (instance.state !== 'running') { + instance.tailscalePreview = { + status: 'error', + error: 'Dev server is not running', + } + this.emitStateChange(workdir, instance.state, instance.errorMessage) + return this.getStatus(workdir) + } + + if (instance.assignedPort === null) { + instance.tailscalePreview = { + status: 'error', + error: 'Dev server has no assigned port', + } + this.emitStateChange(workdir, instance.state, instance.errorMessage) + return this.getStatus(workdir) + } + + if (instance.tailscalePreview.status === 'starting' || instance.tailscalePreview.status === 'active') { + // Idempotent: a preview is already in flight — return current state without overwriting it. + return this.getStatus(workdir) + } + + // Precheck: surface a clear "Tailscale not available" message before attempting to spawn. + const availability = await isTailscaleAvailable() + if (!availability.available) { + instance.tailscalePreview = { + status: 'error', + error: availability.reason ?? 'Tailscale is not available', + } + this.emitStateChange(workdir, instance.state, instance.errorMessage) + return this.getStatus(workdir) + } + + instance.tailscalePreview = { status: 'starting' } + this.emitStateChange(workdir, instance.state, instance.errorMessage) + + try { + const result = await tailscalePreviewManager.start(workdir, instance.assignedPort) + instance.tailscalePreview = { status: 'active', url: result.url } + this.emitStateChange(workdir, instance.state, instance.errorMessage) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + instance.tailscalePreview = { status: 'error', error: message } + this.emitStateChange(workdir, instance.state, instance.errorMessage) + logger.warn('Tailscale preview failed to start', { workdir, error: message }) + } + + return this.getStatus(workdir) + } + + async stopTailscalePreview(workdir: string): Promise { + const instance = this.getInstance(workdir) + if (instance.tailscalePreview.status === 'idle') { + return this.getStatus(workdir) + } + await tailscalePreviewManager.stop(workdir) + instance.tailscalePreview = idlePreview() + this.emitStateChange(workdir, instance.state, instance.errorMessage) + return this.getStatus(workdir) + } } export const devServerManager = new DevServerManager() diff --git a/src/server/dev-server/tailscale-preview.test.ts b/src/server/dev-server/tailscale-preview.test.ts new file mode 100644 index 00000000..5d7871c7 --- /dev/null +++ b/src/server/dev-server/tailscale-preview.test.ts @@ -0,0 +1,523 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), + execFile: vi.fn(), +})) + +vi.mock('../utils/logger.js', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})) + +import { spawn, execFile, type ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + TailscalePreviewManager, + pickFreeServePort, + isTailscaleAvailable, + listUsedServePorts, + findEntryForPort, + iterateStatusPorts, +} from './tailscale-preview.js' + +interface MockChild extends EventEmitter { + pid: number | null + exitCode: number | null + stdout: EventEmitter + stderr: EventEmitter +} + +function makeMockChild( + opts: { + pid?: number | null + stdout?: string + stderr?: string + exit?: { code: number | null; delay?: number } + } = {}, +): MockChild { + const child = new EventEmitter() as MockChild + child.pid = opts.pid ?? 9999 + child.exitCode = null + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + if (opts.stdout) { + const stdoutData = opts.stdout + setImmediate(() => child.stdout.emit('data', Buffer.from(stdoutData))) + } + if (opts.stderr) { + const stderrData = opts.stderr + setImmediate(() => child.stderr.emit('data', Buffer.from(stderrData))) + } + if (opts.exit) { + const { code, delay = 50 } = opts.exit + setTimeout(() => { + child.exitCode = code + child.emit('exit', code) + }, delay) + } + return child +} + +interface ServeStatusEntry { + host: string + port: number +} + +function statusJsonFor(entries: ServeStatusEntry[]): string { + const Web: Record = {} + const TCP: Record = {} + for (const { host, port } of entries) { + Web[`${host}:${port}`] = { Handlers: { '/': { Proxy: 'http://localhost:3000' } } } + TCP[String(port)] = {} + } + return JSON.stringify({ TCP, Web }) +} + +interface ExecFileResponse { + stdout?: string + stderr?: string + err?: Error +} + +function setupExecFileMock(responses: ExecFileResponse[]): void { + let idx = 0 + vi.mocked(execFile).mockImplementation((( + _cmd: string, + _args: string[] | undefined, + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + const slot = responses[Math.min(idx, responses.length - 1)] + idx++ + if (!slot) { + cb(new Error('no mock response configured'), '', '') + return undefined as never + } + if (slot.err) { + cb(slot.err, '', '') + } else { + cb(null, slot.stdout ?? '', slot.stderr ?? '') + } + return undefined as never + }) as never) +} + +function captureSpawnChild( + stdout: string, + opts: { stderr?: string; exit?: { code: number | null; delay?: number } } = {}, +): { + child: MockChild +} { + const child = makeMockChild({ stdout, ...opts }) + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ChildProcess) + return { child } +} + +function captureAllSpawnChildren(stdout: string): { children: MockChild[] } { + const children: MockChild[] = [] + vi.mocked(spawn).mockImplementation(() => { + const c = makeMockChild({ stdout }) + children.push(c) + return c as unknown as ChildProcess + }) + return { children } +} + +beforeEach(() => { + vi.mocked(spawn).mockReset() + vi.mocked(execFile).mockReset() +}) + +describe('pickFreeServePort', () => { + it('prefers 443 when free', () => { + expect(pickFreeServePort([])).toBe(443) + }) + + it('skips occupied ports in candidate list', () => { + expect(pickFreeServePort([443, 8443])).toBe(10000) + }) + + it('falls back to scanned ports when candidates all occupied', () => { + const used = new Set([443, 8443, 10000, 10443, 12345]) + const found = pickFreeServePort(Array.from(used)) + expect(used.has(found)).toBe(false) + expect(found).toBeGreaterThanOrEqual(443) + expect(found).toBeLessThan(65536) + }) +}) + +describe('findEntryForPort', () => { + it('finds a web entry by port suffix and tcp key', () => { + const status = { + TCP: { 443: { HTTPS: true } }, + Web: { + 'host.tailnet.ts.net:443': { Handlers: { '/': { Proxy: 'http://localhost:3000' } } }, + }, + } + const result = findEntryForPort(status, 443) + expect(result.webKey).toBe('host.tailnet.ts.net:443') + expect(result.tcpKey).toBe('443') + }) + + it('returns no entry when port not present', () => { + const status = { Web: { 'host:443': {} }, TCP: { 443: {} } } + expect(findEntryForPort(status, 8443)).toEqual({}) + }) +}) + +describe('isTailscaleAvailable', () => { + it('returns available when tailscale status reports Running', async () => { + setupExecFileMock([ + { + stdout: JSON.stringify({ BackendState: 'Running', Self: { DNSName: 'laptop.tailnet.ts.net.' } }), + }, + ]) + const result = await isTailscaleAvailable() + expect(result.available).toBe(true) + expect(result.nodeName).toBe('laptop.tailnet.ts.net') + expect(result.reason).toBeUndefined() + }) + + it('returns unavailable with reason when backend not Running', async () => { + setupExecFileMock([{ stdout: JSON.stringify({ BackendState: 'Stopped' }) }]) + const result = await isTailscaleAvailable() + expect(result.available).toBe(false) + expect(result.reason).toContain('Stopped') + }) + + it('returns unavailable when tailscale binary fails', async () => { + setupExecFileMock([{ err: new Error('spawn tailscale ENOENT') }]) + const result = await isTailscaleAvailable() + expect(result.available).toBe(false) + expect(result.reason).toContain('ENOENT') + }) +}) + +describe('listUsedServePorts', () => { + it('parses web and tcp keys', async () => { + setupExecFileMock([ + { + stdout: statusJsonFor([ + { host: 'host', port: 443 }, + { host: 'host', port: 8443 }, + ]), + }, + ]) + const ports = await listUsedServePorts() + expect(ports.sort()).toEqual([443, 8443]) + }) + + it('returns empty array on error', async () => { + setupExecFileMock([{ err: new Error('not found') }]) + const ports = await listUsedServePorts() + expect(ports).toEqual([]) + }) +}) + +describe('TailscalePreviewManager.start (foreground)', () => { + it('rejects when a preview is already active for the workdir', async () => { + const manager = new TailscalePreviewManager() + captureSpawnChild('https://laptop.ts.net:8443/') + setupExecFileMock([ + { stdout: statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) }, + { + stdout: statusJsonFor([ + { host: 'laptop.ts.net', port: 443 }, + { host: 'laptop.ts.net', port: 8443 }, + ]), + }, + ]) + + const first = await manager.start('/tmp/a', 3000) + expect(first.url).toBe('https://laptop.ts.net:8443/') + + await expect(manager.start('/tmp/a', 3000)).rejects.toThrow(/already active/) + }) + + it('starts foreground tailscale serve and extracts URL from stdout', async () => { + const manager = new TailscalePreviewManager() + captureSpawnChild('https://node.tailnet.ts.net:8443/\n') + setupExecFileMock([ + { stdout: statusJsonFor([{ host: 'node.tailnet.ts.net', port: 443 }]) }, + { + stdout: statusJsonFor([ + { host: 'node.tailnet.ts.net', port: 443 }, + { host: 'node.tailnet.ts.net', port: 8443 }, + ]), + }, + ]) + + const result = await manager.start('/tmp/b', 3000) + expect(result.url).toBe('https://node.tailnet.ts.net:8443/') + expect(result.remotePort).toBe(8443) + + const spawnArgs = vi.mocked(spawn).mock.calls[0] + expect(spawnArgs?.[0]).toBe('tailscale') + expect(spawnArgs?.[1]).toEqual(expect.arrayContaining(['serve', '--yes', '--https=8443', 'http://localhost:3000'])) + }) + + it('falls back to status JSON web key when stdout has no URL', async () => { + const manager = new TailscalePreviewManager() + captureSpawnChild('config sent, listening...\n') + setupExecFileMock([ + { stdout: statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) }, + { + stdout: statusJsonFor([ + { host: 'laptop.ts.net', port: 443 }, + { host: 'laptop.ts.net', port: 8443 }, + ]), + }, + ]) + + const result = await manager.start('/tmp/c', 3000) + expect(result.url).toBe('https://laptop.ts.net:8443/') + }) + + it('rejects with stderr error when tailscale exits before becoming active (Access denied)', async () => { + const manager = new TailscalePreviewManager() + captureSpawnChild('', { + stderr: 'sending serve config: Access denied: serve config denied\n', + exit: { code: 1, delay: 10 }, + }) + setupExecFileMock([{ stdout: statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) }]) + + await expect(manager.start('/tmp/d', 3000)).rejects.toThrow(/Access denied/) + }) + + it('rejects when the entry never appears in status JSON', async () => { + const manager = new TailscalePreviewManager() + captureSpawnChild('https://x.ts.net:8443/') + setupExecFileMock([ + { stdout: statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) }, + { stdout: statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) }, + ]) + + await expect(manager.start('/tmp/e', 3000)).rejects.toThrow(/did not register/) + }) +}) + +describe('TailscalePreviewManager.stop', () => { + it('kills foreground child and skips targeted inverse when entry disappears on its own', async () => { + const manager = new TailscalePreviewManager() + const { child } = captureSpawnChild('https://laptop.ts.net:8443/') + setupExecFileMock([ + { stdout: statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) }, + { + stdout: statusJsonFor([ + { host: 'laptop.ts.net', port: 443 }, + { host: 'laptop.ts.net', port: 8443 }, + ]), + }, + { stdout: statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) }, + ]) + + await manager.start('/tmp/f', 3000) + + const spawnCallsBefore = vi.mocked(spawn).mock.calls.length + + await manager.stop('/tmp/f') + + expect(vi.mocked(spawn).mock.calls.length).toBe(spawnCallsBefore) + child.exitCode = 0 + }) + + it('uses targeted tailscale serve --https= off when entry persists, never reset', async () => { + const manager = new TailscalePreviewManager() + const { child } = captureSpawnChild('https://laptop.ts.net:8443/') + const removeCalls: string[][] = [] + let inverseCalled = false + const initialJson = statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) + const presentJson = statusJsonFor([ + { host: 'laptop.ts.net', port: 443 }, + { host: 'laptop.ts.net', port: 8443 }, + ]) + const goneJson = statusJsonFor([{ host: 'laptop.ts.net', port: 443 }]) + let callIndex = 0 + + vi.mocked(spawn).mockImplementation(((cmd: string, args: string[] | undefined) => { + if (cmd === 'tailscale' && Array.isArray(args) && args[0] === 'serve' && args[args.length - 1] === 'off') { + inverseCalled = true + removeCalls.push(args) + return makeMockChild({ exit: { code: 0, delay: 5 } }) as ChildProcess + } + return child as ChildProcess + }) as never) + + vi.mocked(execFile).mockImplementation((( + _cmd: string, + args: string[] | undefined, + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + const joined = (args ?? []).join(' ') + if (joined.includes('status --json')) { + // first call: initial state (only 443). Subsequent: present or gone based on inverse + if (callIndex === 0) { + callIndex++ + cb(null, initialJson, '') + } else { + callIndex++ + cb(null, inverseCalled ? goneJson : presentJson, '') + } + } else { + cb(new Error(`unexpected execFile args: ${joined}`), '', '') + } + return undefined as never + }) as never) + + await manager.start('/tmp/g', 3000) + await manager.stop('/tmp/g') + + expect(removeCalls.length).toBeGreaterThan(0) + expect(removeCalls[0]).toEqual(['serve', '--yes', '--https=8443', 'off']) + expect(removeCalls.some((args) => args.includes('reset'))).toBe(false) + child.exitCode = 0 + }) +}) + +describe('TailscalePreviewManager.stopAll', () => { + it('stops every active preview', async () => { + const manager = new TailscalePreviewManager() + const { children } = captureAllSpawnChildren('https://x.ts.net:8443/') + // Track which ports are currently exposed in the simulated Tailscale node. + const exposedPorts = new Set([443]) + const presentJson = () => statusJsonFor(Array.from(exposedPorts).map((p) => ({ host: 'laptop.ts.net', port: p }))) + + vi.mocked(spawn).mockImplementation(((cmd: string, args: string[] | undefined) => { + if (cmd === 'tailscale' && Array.isArray(args) && args[0] === 'serve' && args[args.length - 1] === 'off') { + // Inverse call: remove the targeted port from the simulated node. + const httpsIdx = args.findIndex((a) => a.startsWith('--https=')) + if (httpsIdx >= 0) { + const port = parseInt(args[httpsIdx]!.slice('--https='.length), 10) + if (!isNaN(port)) exposedPorts.delete(port) + } + return makeMockChild({ exit: { code: 0, delay: 5 } }) as ChildProcess + } + // Foreground serve: figure out which port it will use, add to exposed set. + if (cmd === 'tailscale' && Array.isArray(args)) { + const httpsIdx = args.findIndex((a) => a.startsWith('--https=')) + if (httpsIdx >= 0) { + const port = parseInt(args[httpsIdx]!.slice('--https='.length), 10) + if (!isNaN(port)) { + setTimeout(() => exposedPorts.add(port), 50) + } + } + } + return makeMockChild({ stdout: 'https://x.ts.net:8443/' }) as ChildProcess + }) as never) + + vi.mocked(execFile).mockImplementation((( + _cmd: string, + args: string[] | undefined, + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + const joined = (args ?? []).join(' ') + if (joined.includes('status --json')) { + cb(null, presentJson(), '') + } else { + cb(new Error(`unexpected execFile args: ${joined}`), '', '') + } + return undefined as never + }) as never) + + await manager.start('/tmp/h1', 3001) + await manager.start('/tmp/h2', 3002) + await manager.stopAll() + expect(manager.isActive('/tmp/h1')).toBe(false) + expect(manager.isActive('/tmp/h2')).toBe(false) + expect(exposedPorts.has(8443)).toBe(false) + for (const c of children) c.exitCode = 0 + }) +}) + +/** + * Real Tailscale serve status JSON captured during Verify — exactly the shape + * produced by `tailscale serve --https=8443 ...` running in foreground on a + * node that already has a persistent 443 → 10369 entry. + */ +const REAL_SERVE_STATUS_FIXTURE = { + TCP: { + '443': { HTTPS: true }, + }, + Web: { + 'node.tailnet.ts.net:443': { + Handlers: { '/': { Proxy: 'http://127.0.0.1:10369' } }, + }, + }, + Foreground: { + f326d7c5f68a8f3f: { + TCP: { '8443': { HTTPS: true } }, + Web: { + 'node.tailnet.ts.net:8443': { + Handlers: { '/': { Proxy: 'http://127.0.0.1:10469' } }, + }, + }, + }, + }, +} + +describe('serve status JSON normalization (root + Foreground)', () => { + it('iterateStatusPorts returns entries from both root and Foreground', () => { + const ports = iterateStatusPorts(REAL_SERVE_STATUS_FIXTURE) + const uniquePorts = Array.from(new Set(ports.map((p) => p.port))).sort((a, b) => a - b) + expect(uniquePorts).toEqual([443, 8443]) + // Each port appears in both TCP and Web subtrees of its containing object + expect(ports.length).toBe(4) // root TCP 443 + root Web 443 + FG TCP 8443 + FG Web 8443 + }) + + it('iterateStatusPorts carries host for Web entries (root and Foreground)', () => { + const ports = iterateStatusPorts(REAL_SERVE_STATUS_FIXTURE) + const root443 = ports.find((p) => p.port === 443 && p.host === 'node.tailnet.ts.net') + const fg8443 = ports.find((p) => p.port === 8443 && p.host === 'node.tailnet.ts.net') + expect(root443).toBeDefined() + expect(fg8443).toBeDefined() + }) + + it('findEntryForPort returns the root Web entry for port 443 (pre-existing)', () => { + const result = findEntryForPort(REAL_SERVE_STATUS_FIXTURE, 443) + expect(result.webKey).toBe('node.tailnet.ts.net:443') + expect(result.tcpKey).toBe('443') + }) + + it('findEntryForPort returns the Foreground Web entry for port 8443 (the bug)', () => { + const result = findEntryForPort(REAL_SERVE_STATUS_FIXTURE, 8443) + expect(result.webKey).toBe('node.tailnet.ts.net:8443') + expect(result.tcpKey).toBe('8443') + }) + + it('listUsedServePorts reports both 443 and 8443 so port choice skips them', () => { + expect(listUsedServePortsFromFixture()).toEqual([443, 8443]) + }) + + it('pickFreeServePort avoids both 443 (root) and 8443 (Foreground)', () => { + const used = listUsedServePortsFromFixture() + const port = pickFreeServePort(used) + expect(used).not.toContain(port) + expect(port).toBe(10000) + }) + + it('on-disk fixture JSON matches the real observed shape', () => { + const fixturePath = join(__dirname, '__fixtures__', 'real-serve-status.json') + let onDisk: unknown = null + try { + onDisk = JSON.parse(readFileSync(fixturePath, 'utf-8')) + } catch { + // fixture file is optional; the inline constant above is the source of truth. + } + if (onDisk) { + const uniquePorts = Array.from(new Set(iterateStatusPorts(onDisk).map((p) => p.port))).sort((a, b) => a - b) + expect(uniquePorts).toEqual([443, 8443]) + } + }) +}) + +function listUsedServePortsFromFixture(): number[] { + const ports = new Set() + for (const entry of iterateStatusPorts(REAL_SERVE_STATUS_FIXTURE)) { + ports.add(entry.port) + } + return Array.from(ports).sort((a, b) => a - b) +} diff --git a/src/server/dev-server/tailscale-preview.ts b/src/server/dev-server/tailscale-preview.ts new file mode 100644 index 00000000..4881513d --- /dev/null +++ b/src/server/dev-server/tailscale-preview.ts @@ -0,0 +1,412 @@ +import { spawn, execFile, type ChildProcess } from 'node:child_process' +import { setTimeout as sleep } from 'node:timers/promises' +import { resolve } from 'node:path' +import { logger } from '../utils/logger.js' + +export interface PreviewStartResult { + url: string + remotePort: number +} + +interface ActivePreview { + child: ChildProcess + remotePort: number + url: string + workdir: string +} + +const STABILIZE_TIMEOUT_MS = 5000 +const STABILIZE_POLL_MS = 200 +const REMOVAL_VERIFY_TIMEOUT_MS = 3000 +const REMOVAL_VERIFY_POLL_MS = 200 + +const CANDIDATE_HTTPS_PORTS = [443, 8443, 10000, 10443, 12345] as const + +// eslint-disable-next-line no-control-regex +const URL_REGEX = /https?:\/\/[^\s\x1b]+/g +// eslint-disable-next-line no-control-regex +const ANSI_REGEX = /\x1b\[[0-9;]*[A-Za-z]/g + +function stripAnsi(s: string): string { + return s.replace(ANSI_REGEX, '') +} + +function trimTrailingPunctuation(url: string): string { + return url.replace(/[.,;]+$/, '') +} + +function extractFirstUrl(raw: string): string | null { + const cleaned = stripAnsi(raw) + URL_REGEX.lastIndex = 0 + const match = URL_REGEX.exec(cleaned) + return match ? trimTrailingPunctuation(match[0]) : null +} + +interface ServePortEntry { + port: number + host: string | null +} + +/** + * Collect { port, host } entries from a Tailscale serve status subtree. + * Both root and `Foreground[*]` entries follow the same `{ TCP, Web }` shape, + * so the same routine handles persistent and foreground-owned entries uniformly. + * + * Read-only: this never mutates the status. The targeted cleanup path is + * responsible for *only* acting on the port passed to it (see forceRemoveEntry). + */ +function collectPortEntries(parent: unknown, out: ServePortEntry[]): void { + if (!parent || typeof parent !== 'object') return + const obj = parent as Record + + const tcp = obj['TCP'] + if (tcp && typeof tcp === 'object') { + for (const key of Object.keys(tcp)) { + const n = parseInt(key, 10) + if (!isNaN(n)) out.push({ port: n, host: null }) + } + } + + const web = obj['Web'] + if (web && typeof web === 'object') { + for (const key of Object.keys(web)) { + const colonIdx = key.lastIndexOf(':') + if (colonIdx === -1) continue + const n = parseInt(key.slice(colonIdx + 1), 10) + if (!isNaN(n)) out.push({ port: n, host: key.slice(0, colonIdx) }) + } + } +} + +/** + * Normalized read of every port registered in the local Tailscale node — + * root Web/TCP (persistent, e.g. the existing 443 → 10369 entry) and + * `Foreground[*].Web` / `Foreground[*].TCP` (created by `tailscale serve` foreground). + * + * Consumed by: + * - port choice (`listUsedServePorts` / `pickFreeServePort`), + * - stabilization polling (`waitForEntry`), + * - removal verification (`waitForEntryGone`). + */ +export function iterateStatusPorts(status: unknown): ServePortEntry[] { + const out: ServePortEntry[] = [] + if (!status || typeof status !== 'object') return out + const root = status as Record + + // Root-level Web/TCP entries — persistent serve config on the node. + collectPortEntries(root, out) + + // Foreground entries — keyed by foreground-process PID. Each value carries its + // own TCP + Web subtree, structurally identical to the root. + const foreground = root['Foreground'] + if (foreground && typeof foreground === 'object') { + for (const fgEntry of Object.values(foreground)) { + collectPortEntries(fgEntry, out) + } + } + + return out +} + +function parseUsedPortsFromStatusJson(status: unknown): number[] { + return Array.from(new Set(iterateStatusPorts(status).map((p) => p.port))) +} + +export async function isTailscaleAvailable(): Promise<{ available: boolean; nodeName?: string; reason?: string }> { + try { + const stdout = await new Promise((resolve, reject) => { + execFile('tailscale', ['status', '--json'], { timeout: 4000, windowsHide: true }, (err, stdout) => { + if (err) { + const message = err.message || 'tailscale status failed' + reject(new Error(message)) + return + } + resolve(stdout) + }) + }) + const parsed = JSON.parse(stdout) as { BackendState?: string; Self?: { DNSName?: string } } + if (parsed.BackendState !== 'Running') { + return { available: false, reason: `Tailscale backend not running (${parsed.BackendState ?? 'unknown'})` } + } + const nodeName = parsed.Self?.DNSName?.replace(/\.$/, '') + if (!nodeName) { + return { available: false, reason: 'Tailscale node name not found' } + } + return { available: true, nodeName } + } catch (err) { + return { available: false, reason: err instanceof Error ? err.message : String(err) } + } +} + +async function readServeStatusJson(): Promise { + return new Promise((resolve, reject) => { + execFile('tailscale', ['serve', 'status', '--json'], { timeout: 4000, windowsHide: true }, (err, stdout) => { + if (err) { + reject(err) + return + } + try { + resolve(JSON.parse(stdout)) + } catch (parseErr) { + reject(parseErr) + } + }) + }) +} + +export async function listUsedServePorts(): Promise { + try { + const status = await readServeStatusJson() + return parseUsedPortsFromStatusJson(status) + } catch { + return [] + } +} + +export function pickFreeServePort(usedPorts: number[]): number { + const used = new Set(usedPorts) + for (const candidate of CANDIDATE_HTTPS_PORTS) { + if (!used.has(candidate)) return candidate + } + for (let p = 443; p <= 65535; p++) { + if (!used.has(p)) return p + } + throw new Error('No free HTTPS port available for Tailscale serve') +} + +export function findEntryForPort(status: unknown, port: number): { webKey?: string; tcpKey?: string } { + const result: { webKey?: string; tcpKey?: string } = {} + for (const entry of iterateStatusPorts(status)) { + if (entry.port !== port) continue + if (entry.host) { + result.webKey = `${entry.host}:${entry.port}` + } else { + result.tcpKey = String(entry.port) + } + } + return result +} + +async function waitForEntry(port: number, timeoutMs: number): Promise<{ webKey?: string; tcpKey?: string } | null> { + const deadline = Date.now() + timeoutMs + let lastSeen: { webKey?: string; tcpKey?: string } | null = null + while (Date.now() < deadline) { + try { + const status = await readServeStatusJson() + const found = findEntryForPort(status, port) + if (found.webKey || found.tcpKey) return found + lastSeen = found + } catch { + // ignore transient errors + } + await sleep(STABILIZE_POLL_MS) + } + return lastSeen +} + +async function waitForEntryGone(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + let stillPresent = false + try { + const status = await readServeStatusJson() + const found = findEntryForPort(status, port) + if (found.webKey || found.tcpKey) stillPresent = true + } catch { + // ignore — assume still present to be safe + stillPresent = true + } + if (!stillPresent) return true + await sleep(REMOVAL_VERIFY_POLL_MS) + } + return false +} + +async function forceRemoveEntry(workdir: string, handle: ActivePreview): Promise { + const removeArgs = ['serve', '--yes', `--https=${handle.remotePort}`, 'off'] + try { + await new Promise((resolve) => { + const proc = spawn('tailscale', removeArgs, { + stdio: 'ignore', + windowsHide: true, + }) + proc.once('exit', () => resolve()) + proc.once('error', () => resolve()) + }) + } catch (err) { + logger.warn('Failed to remove persistent Tailscale serve entry', { + workdir, + remotePort: handle.remotePort, + error: err instanceof Error ? err.message : String(err), + }) + } + await waitForEntryGone(handle.remotePort, REMOVAL_VERIFY_TIMEOUT_MS) +} + +function killChild(child: ChildProcess): Promise { + return new Promise((resolve) => { + if (!child.pid || child.exitCode !== null) { + resolve() + return + } + const pid = child.pid + let resolved = false + const finish = () => { + if (resolved) return + resolved = true + resolve() + } + child.once('exit', finish) + try { + process.kill(pid, 'SIGTERM') + } catch { + // may already be dead + } + setTimeout(() => { + if (resolved) return + try { + process.kill(pid, 'SIGKILL') + } catch { + // may already be dead + } + setTimeout(finish, 200) + }, 200) + }) +} + +export class TailscalePreviewManager { + private handles = new Map() + + isActive(workdir: string): boolean { + return this.handles.has(this.resolve(workdir)) + } + + getActiveUrl(workdir: string): string | null { + return this.handles.get(this.resolve(workdir))?.url ?? null + } + + private resolve(workdir: string): string { + return resolve(workdir) + } + + async start(workdir: string, targetPort: number): Promise { + const key = this.resolve(workdir) + if (this.handles.has(key)) { + throw new Error('A Tailscale preview is already active for this workdir') + } + + const usedPorts = await listUsedServePorts() + const remotePort = pickFreeServePort(usedPorts) + + const target = `http://localhost:${targetPort}` + const args = ['serve', '--yes', `--https=${remotePort}`, target] + + const child = spawn('tailscale', args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + + let stdoutBuf = '' + let stderrBuf = '' + let exited = false + let exitCode: number | null = null + const earlyExit = new Promise<{ code: number | null; stderr: string } | null>((resolve) => { + child.once('error', (err) => { + exited = true + resolve({ code: -1, stderr: err.message }) + }) + child.once('exit', (code) => { + exited = true + exitCode = code + resolve({ code, stderr: stderrBuf }) + }) + }) + + child.stdout?.on('data', (data: Buffer) => { + stdoutBuf += data.toString() + }) + child.stderr?.on('data', (data: Buffer) => { + stderrBuf += data.toString() + }) + + const entryCheck = waitForEntry(remotePort, STABILIZE_TIMEOUT_MS) + const result = await Promise.race([ + earlyExit.then(async (exitInfo) => { + if (exitInfo === null) return { ok: false as const, reason: 'spawn returned null child' } + return { + ok: false as const, + reason: `tailscale serve exited before becoming active (code=${exitInfo.code ?? 'n/a'})`, + stderr: exitInfo.stderr.trim(), + } + }), + entryCheck.then(async (found) => { + if (found && (found.webKey || found.tcpKey)) { + const urlFromStdout = extractFirstUrl(stdoutBuf) + const urlFromStatus = found.webKey ? `https://${found.webKey}/` : null + const url = urlFromStdout ?? urlFromStatus + if (url) { + return { ok: true as const, url } + } + return { + ok: false as const, + reason: 'tailscale serve did not print a usable URL on stdout', + stderr: stderrBuf.trim(), + } + } + if (exited) { + return { + ok: false as const, + reason: `tailscale serve exited (code=${exitCode ?? 'n/a'}) before registering the entry`, + stderr: stderrBuf.trim(), + } + } + return { + ok: false as const, + reason: 'tailscale serve did not register the entry in time', + stderr: stderrBuf.trim(), + } + }), + ]) + + if (!result.ok) { + await killChild(child) + throw new Error(`${result.reason}${result.stderr ? `: ${result.stderr}` : ''}`) + } + + const handle: ActivePreview = { + child, + remotePort, + url: result.url, + workdir: key, + } + this.handles.set(key, handle) + + logger.info('Tailscale preview started', { workdir: key, remotePort, url: result.url }) + + return { url: result.url, remotePort } + } + + async stop(workdir: string): Promise { + const key = this.resolve(workdir) + const handle = this.handles.get(key) + if (!handle) return + this.handles.delete(key) + try { + await killChild(handle.child) + } catch { + // ignore + } + const gone = await waitForEntryGone(handle.remotePort, REMOVAL_VERIFY_TIMEOUT_MS) + if (!gone) { + await forceRemoveEntry(key, handle) + } + logger.info('Tailscale preview stopped', { workdir: key, remotePort: handle.remotePort }) + } + + async stopAll(): Promise { + const keys = Array.from(this.handles.keys()) + await Promise.allSettled(keys.map((k) => this.stop(k))) + } +} + +export const tailscalePreviewManager = new TailscalePreviewManager() diff --git a/src/server/routes/dev-server.ts b/src/server/routes/dev-server.ts index 5dad20dc..4865b812 100644 --- a/src/server/routes/dev-server.ts +++ b/src/server/routes/dev-server.ts @@ -104,12 +104,18 @@ export function createDevServerRoutes(): Router { router.post('/config', async (req, res) => { const workdir = req.query['workdir'] as string if (!workdir) return res.status(400).json({ error: 'workdir required' }) - const { command, url, hotReload, disableInspect } = req.body + const { command, url, hotReload, disableInspect, tailscaleExpose } = req.body if (!command || !url) { return res.status(400).json({ error: 'command and url are required' }) } try { - const config = { command, url, hotReload: hotReload ?? false, disableInspect: disableInspect ?? false } + const config = { + command, + url, + hotReload: hotReload ?? false, + disableInspect: disableInspect ?? false, + tailscaleExpose: tailscaleExpose === true, + } await devServerManager.saveConfig(workdir, config) res.json({ config }) } catch (err) { diff --git a/src/server/tools/dev-server.ts b/src/server/tools/dev-server.ts index 355193f9..d5b1e196 100644 --- a/src/server/tools/dev-server.ts +++ b/src/server/tools/dev-server.ts @@ -17,8 +17,9 @@ export const devServerTool = createTool( 'Control the project dev server. Start, stop, restart, check status, or fetch logs with optional pagination. ' + 'Each workdir (project root or workspace) gets its own independent dev server instance with auto-assigned ports. ' + 'The dev server is configured via .openfox/dev.json — searched in the current workdir first, falling back to the project root. ' + - 'Fields: command (string, required), url (string, required), hotReload (boolean, optional, default false), disableInspect (boolean, optional, default false). ' + - 'You can use ${PORT} in command and url — it will be replaced with an available port at runtime (auto-assigned if the configured port is taken).', + 'Fields: command (string, required), url (string, required), hotReload (boolean, optional, default false), disableInspect (boolean, optional, default false), tailscaleExpose (boolean, optional, default false). ' + + 'You can use ${PORT} in command and url — it will be replaced with an available port at runtime (auto-assigned if the configured port is taken). ' + + 'When tailscaleExpose is true, a tailnet-only Tailscale preview is auto-launched on Start and auto-torn-down on Stop / crash / stopAll. The dev server lifecycle stays unchanged; Tailscale failures never block the dev server.', parameters: { type: 'object', properties: { @@ -94,22 +95,19 @@ export const devServerTool = createTool( if (!status.config) { return helpers.error( 'No .openfox/dev.json config found. Create one in the project root:\n\n' + - '{\n "command": "npm run dev",\n "url": "http://localhost:3000",\n "hotReload": true,\n "disableInspect": false\n}\n\n' + + '{\n "command": "npm run dev",\n "url": "http://localhost:3000",\n "hotReload": true,\n "disableInspect": false,\n "tailscaleExpose": false\n}\n\n' + '(Worktrees inherit the project root config automatically.)', ) } - return helpers.success( - JSON.stringify( - { - state: status.state, - url: status.url, - hotReload: status.hotReload, - ...(status.errorMessage ? { error: status.errorMessage } : {}), - }, - null, - 2, - ), - ) + const result: Record = { + state: status.state, + url: status.url, + hotReload: status.hotReload, + tailscalePreview: status.tailscalePreview, + ...(status.errorMessage ? { error: status.errorMessage } : {}), + } + + return helpers.success(JSON.stringify(result, null, 2)) }, ) diff --git a/src/server/ws/server.ts b/src/server/ws/server.ts index a5280e09..af01ac1e 100644 --- a/src/server/ws/server.ts +++ b/src/server/ws/server.ts @@ -613,7 +613,7 @@ export function createWebSocketServer( ) }) - devServerManager.onStateChange((workdir, state, errorMessage, url, inspectProxyPort) => { + devServerManager.onStateChange((workdir, state, errorMessage, url, inspectProxyPort, tailscalePreview) => { broadcastAll( createServerMessage('devServer.state', { workdir, @@ -621,6 +621,7 @@ export function createWebSocketServer( errorMessage, url, inspectProxyPort, + tailscalePreview, }), ) }) diff --git a/src/shared/dev-server.ts b/src/shared/dev-server.ts index 8956c799..591dccdd 100644 --- a/src/shared/dev-server.ts +++ b/src/shared/dev-server.ts @@ -3,10 +3,24 @@ export interface DevServerConfig { url: string hotReload: boolean disableInspect?: boolean + /** + * When true, the dev server lifecycle auto-launches a tailnet-only Tailscale + * preview after a successful start, and tears it down on stop / crash / + * stopAll. Changes apply on the next Start/Restart — no hot toggle. + */ + tailscaleExpose?: boolean } export type DevServerState = 'off' | 'running' | 'warning' | 'error' +export type TailscalePreviewStatus = 'idle' | 'starting' | 'active' | 'error' + +export interface TailscalePreview { + status: TailscalePreviewStatus + url?: string + error?: string +} + export interface DevServerStatus { state: DevServerState url: string | null @@ -14,4 +28,9 @@ export interface DevServerStatus { config: DevServerConfig | null errorMessage: string | undefined inspectProxyPort: number | null + tailscalePreview: TailscalePreview +} + +export function idlePreview(): TailscalePreview { + return { status: 'idle' } } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 882eb7b1..c2468725 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -435,6 +435,7 @@ export interface DevServerStatePayload { errorMessage?: string url?: string | null inspectProxyPort?: number | null + tailscalePreview?: import('./dev-server.js').TailscalePreview } // Background process payloads diff --git a/web/src/components/plan/DevServerConfigModal.tsx b/web/src/components/plan/DevServerConfigModal.tsx index 98e42f5b..6672c1c0 100644 --- a/web/src/components/plan/DevServerConfigModal.tsx +++ b/web/src/components/plan/DevServerConfigModal.tsx @@ -15,6 +15,7 @@ export function DevServerConfigModal({ isOpen, onClose }: DevServerConfigModalPr const [url, setUrl] = useState('') const [hotReload, setHotReload] = useState(false) const [disableInspect, setDisableInspect] = useState(false) + const [tailscaleExpose, setTailscaleExpose] = useState(false) const [saving, setSaving] = useState(false) useEffect(() => { @@ -23,13 +24,20 @@ export function DevServerConfigModal({ isOpen, onClose }: DevServerConfigModalPr setUrl(config?.url ?? '') setHotReload(config?.hotReload ?? false) setDisableInspect(config?.disableInspect ?? false) + setTailscaleExpose(config?.tailscaleExpose ?? false) } }, [isOpen, config]) const handleSave = async () => { if (!command.trim() || !url.trim()) return setSaving(true) - await saveConfig({ command: command.trim(), url: url.trim(), hotReload, disableInspect }) + await saveConfig({ + command: command.trim(), + url: url.trim(), + hotReload, + disableInspect, + tailscaleExpose, + }) setSaving(false) onClose() } @@ -85,6 +93,19 @@ export function DevServerConfigModal({ isOpen, onClose }: DevServerConfigModalPr +
+ setTailscaleExpose(e.target.checked)} + className="rounded border-border bg-bg-tertiary" + /> + +
+
)} + + {/* Tailscale preview — secondary information only. No controls. */} + {(state === 'running' || state === 'warning') && ( + + )} ) : ( +
+
{preview.url}
+ + ) + } + + if (status === 'error') { + const shortReason = (preview?.error ?? 'unavailable').split('\n')[0] ?? 'unavailable' + return ( +
+ Tailnet Preview unavailable — {shortReason} +
+ ) + } + + return null +}) diff --git a/web/src/components/shared/DevServerView.tsx b/web/src/components/shared/DevServerView.tsx index 854d8635..8f168cad 100644 --- a/web/src/components/shared/DevServerView.tsx +++ b/web/src/components/shared/DevServerView.tsx @@ -19,6 +19,11 @@ interface StatusData { state?: string url?: string error?: string + tailscalePreview?: { + status?: string + url?: string + error?: string + } } export const DevServerView = memo(function DevServerView({ result, action }: DevServerViewProps) { @@ -64,6 +69,7 @@ function renderStatus(data: StatusData) { const state = String(data.state ?? '') const url = String(data.url ?? '') const errorMsg = data.error ? String(data.error) : undefined + const preview = data.tailscalePreview const stateColor = state === 'running' @@ -88,6 +94,24 @@ function renderStatus(data: StatusData) { )} + {preview && preview.status === 'active' && preview.url && ( +
+ Tailnet: + + {preview.url} + +
+ )} + {preview && preview.status === 'error' && ( +
+ Tailscale: {preview.error ?? 'preview failed'} +
+ )} {errorMsg &&
{errorMsg}
} ) diff --git a/web/src/stores/dev-server.ts b/web/src/stores/dev-server.ts index 85bae3c3..0b267949 100644 --- a/web/src/stores/dev-server.ts +++ b/web/src/stores/dev-server.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' -import type { DevServerConfig, DevServerState, DevServerStatus } from '@shared/dev-server.js' +import type { DevServerConfig, DevServerState, DevServerStatus, TailscalePreview } from '@shared/dev-server.js' +import { idlePreview } from '@shared/dev-server.js' import type { ServerMessage, DevServerOutputPayload, DevServerStatePayload } from '@shared/protocol.js' import { authFetch } from '../lib/api' import { createLogBuffer } from './utils' @@ -196,6 +197,7 @@ export const useDevServerStore = create()((set, get) => { case 'devServer.state': { const payload = message.payload as DevServerStatePayload if (payload.workdir !== workdir) return + const preview: TailscalePreview = payload.tailscalePreview ?? idlePreview() set((state) => ({ status: state.status ? { @@ -204,6 +206,7 @@ export const useDevServerStore = create()((set, get) => { errorMessage: payload.errorMessage, ...(payload.url !== undefined ? { url: payload.url } : {}), ...(payload.inspectProxyPort !== undefined ? { inspectProxyPort: payload.inspectProxyPort } : {}), + tailscalePreview: preview, } : { state: payload.state as DevServerState, @@ -212,6 +215,7 @@ export const useDevServerStore = create()((set, get) => { config: null, errorMessage: payload.errorMessage, inspectProxyPort: payload.inspectProxyPort ?? null, + tailscalePreview: preview, }, })) break