(opts: CreateLiveServerOptions)
| 98 | } |
| 99 | |
| 100 | export async function createLiveServer(opts: CreateLiveServerOptions): Promise<LiveServerHandle> { |
| 101 | const { cwd, id } = opts; |
| 102 | const channelPath = opts.channelPath ?? LIVE_CHANNEL_PATH; |
| 103 | const debounceMs = opts.debounceMs ?? 120; |
| 104 | const host = opts.host ?? '127.0.0.1'; |
| 105 | const artifactDir = path.join(artifactsRoot(cwd), id); |
| 106 | |
| 107 | const clients = new Set<ServerResponse>(); |
| 108 | let lastHash: string | null = null; |
| 109 | let closed = false; |
| 110 | |
| 111 | const pushReload = (hash: string) => { |
| 112 | const frame = `event: reload\ndata: ${hash}\n\n`; |
| 113 | for (const res of clients) { |
| 114 | try { res.write(frame); } catch { /* dropped client; pruned on its 'close' */ } |
| 115 | } |
| 116 | }; |
| 117 | |
| 118 | const flush = () => { |
| 119 | void renderCurrent(cwd, id, channelPath).then(html => { |
| 120 | const h = hashHtml(html); |
| 121 | if (!shouldPush(lastHash, h)) return; // dedupe rename double-fire / no-op rewrites |
| 122 | lastHash = h; |
| 123 | pushReload(h); |
| 124 | }).catch(err => logger.warn('Live artifact flush failed', { id, err: err?.message ?? String(err) })); |
| 125 | }; |
| 126 | const debouncedFlush = makeDebouncer(flush, debounceMs); |
| 127 | |
| 128 | // ── HTTP server ────────────────────────────────────────────────────────── |
| 129 | const token = opts.token; |
| 130 | const server: Server = createServer((req, res) => { |
| 131 | const url = req.url ?? '/'; |
| 132 | // Token gate: a network-/tunnel-exposed server rejects anyone without the link's token. |
| 133 | const auth = checkLiveAuth(token, url, req.headers.cookie); |
| 134 | if (!auth.ok) { |
| 135 | res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); |
| 136 | res.end('403 — invalid or missing access token. Open the full private link (it ends with ?k=…).'); |
| 137 | return; |
| 138 | } |
| 139 | const cookieHeader: Record<string, string> = auth.setCookie |
| 140 | ? { 'Set-Cookie': `qx_live=${token}; Path=/; SameSite=Lax; HttpOnly; Max-Age=86400` } |
| 141 | : {}; |
| 142 | if (url === channelPath || url.startsWith(channelPath + '?')) { |
| 143 | // SSE channel: keep open, push reload events. |
| 144 | res.writeHead(200, { |
| 145 | 'Content-Type': 'text/event-stream', |
| 146 | 'Cache-Control': 'no-cache, no-transform', |
| 147 | 'Connection': 'keep-alive', |
| 148 | ...cookieHeader, |
| 149 | }); |
| 150 | res.flushHeaders?.(); |
| 151 | res.write('retry: 1000\n\n'); |
| 152 | res.write(': connected\n\n'); |
| 153 | clients.add(res); |
| 154 | const heartbeat = setInterval(() => { |
| 155 | try { res.write(': ping\n\n'); } catch { /* ignore */ } |
| 156 | }, 25_000); |
| 157 | const cleanup = () => { clearInterval(heartbeat); clients.delete(res); }; |
no test coverage detected