@useswarm/cli
Advanced tools
@@ -66,3 +66,4 @@ import { Command } from "commander"; | ||
| .option("--audience <desc>", "Audience description for persona generation") | ||
| .option("--no-tunnel", "Skip ngrok tunnel (URL must be publicly accessible)") | ||
| .option("--no-tunnel", "Skip tunnel (URL must be publicly accessible)") | ||
| .option("--tunnel-provider <provider>", "Tunnel provider: cloudflare, ngrok, or auto (default: auto)") | ||
| .option("--backend <url>", "Local backend URL to proxy (e.g. localhost:8080)") | ||
@@ -237,3 +238,4 @@ .option("--backend-paths <paths>", "Additional path prefixes for backend routing (comma-separated)") | ||
| if (isLocal && !skipTunnel) { | ||
| console.log(` Tunnel: ${chalk.green("enabled")} ${chalk.dim("(ngrok → localhost)")}`); | ||
| const tp = opts.tunnelProvider ?? "auto"; | ||
| console.log(` Tunnel: ${chalk.green("enabled")} ${chalk.dim(`(${tp} → localhost)`)}`); | ||
| if (backendUrl) { | ||
@@ -279,2 +281,11 @@ console.log(` Backend: ${chalk.cyan(backendUrl)} ${chalk.dim("→ /api/*, /auth/*, /graphql, /trpc/*")}`); | ||
| }; | ||
| // Synchronous best-effort kill for hard exits where async cleanup can't run | ||
| const cleanupSync = () => { | ||
| if (tunnel) { | ||
| tunnel.close().catch(() => { }); | ||
| } | ||
| if (proxy) { | ||
| proxy.close().catch(() => { }); | ||
| } | ||
| }; | ||
| process.on("SIGINT", async () => { | ||
@@ -289,2 +300,17 @@ console.log(chalk.dim("\nShutting down...")); | ||
| }); | ||
| process.on("SIGHUP", async () => { | ||
| await cleanup(); | ||
| process.exit(0); | ||
| }); | ||
| process.on("uncaughtException", async (err) => { | ||
| console.error(chalk.red(`\nFatal error: ${err.message}`)); | ||
| await cleanup(); | ||
| process.exit(1); | ||
| }); | ||
| process.on("unhandledRejection", async (reason) => { | ||
| console.error(chalk.red(`\nUnhandled rejection: ${reason}`)); | ||
| await cleanup(); | ||
| process.exit(1); | ||
| }); | ||
| process.on("exit", cleanupSync); | ||
| if (isLocal && !skipTunnel) { | ||
@@ -343,6 +369,21 @@ const frontendPort = parsedUrl?.port ? parseInt(parsedUrl.port, 10) : 3000; | ||
| } | ||
| const tunnelSpinner = ora(`Opening tunnel to localhost:${tunnelPort}...`).start(); | ||
| const tunnelProvider = opts.tunnelProvider ?? "auto"; | ||
| const providerLabel = tunnelProvider === "auto" ? "" : ` (${tunnelProvider})`; | ||
| const tunnelSpinner = ora(`Opening tunnel${providerLabel} to localhost:${tunnelPort}...`).start(); | ||
| try { | ||
| const tokenRes = await client.getTunnelToken(); | ||
| tunnel = await openTunnel(tunnelPort, tokenRes.ngrokAuthToken); | ||
| // Only fetch ngrok token if provider is ngrok or auto (as fallback) | ||
| let ngrokAuthToken; | ||
| if (tunnelProvider !== "cloudflare") { | ||
| try { | ||
| const tokenRes = await client.getTunnelToken(); | ||
| ngrokAuthToken = tokenRes.ngrokAuthToken; | ||
| } | ||
| catch { | ||
| // If we can't get the ngrok token and provider is explicitly ngrok, fail | ||
| if (tunnelProvider === "ngrok") | ||
| throw new Error("Failed to get ngrok auth token from server"); | ||
| // Otherwise (auto mode), we'll try cloudflare without a fallback token | ||
| } | ||
| } | ||
| tunnel = await openTunnel(tunnelPort, { provider: tunnelProvider, ngrokAuthToken }); | ||
| const originalPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : ""; | ||
@@ -353,3 +394,4 @@ targetUrl = `${tunnel.url}${originalPath === "/" ? "" : originalPath}`; | ||
| } | ||
| tunnelSpinner.succeed(`Tunnel open: ${chalk.underline(tunnel.url)}`); | ||
| const providerTag = tunnel.provider === "cloudflare" ? chalk.dim(" (cloudflare)") : chalk.dim(" (ngrok)"); | ||
| tunnelSpinner.succeed(`Tunnel open${providerTag}: ${chalk.underline(tunnel.url)}`); | ||
| } | ||
@@ -423,2 +465,4 @@ catch (err) { | ||
| console.log(); | ||
| console.log(` ${chalk.bold("Live dashboard:")} ${chalk.underline.cyan(createRes.dashboardUrl)}`); | ||
| console.log(); | ||
| // ── 11. Stream results ─────────────────────────────────────────── | ||
@@ -425,0 +469,0 @@ try { |
+1
-1
@@ -13,3 +13,3 @@ #!/usr/bin/env node | ||
| .description("Swarm CLI — run AI-powered UX simulations from the terminal") | ||
| .version("0.1.2"); | ||
| .version("0.3.0"); | ||
| program.addCommand(loginCommand); | ||
@@ -16,0 +16,0 @@ program.addCommand(logoutCommand); |
+257
-59
@@ -5,2 +5,27 @@ import http from "node:http"; | ||
| const DEFAULT_BACKEND_PATH_PREFIXES = ["/api", "/auth", "/graphql", "/trpc"]; | ||
| // ─── HMR / dev-only path prefixes ──────────────────────────────────────────── | ||
| // These are WebSocket upgrade targets and polling endpoints used exclusively | ||
| // by the browser's hot-reload client. The testing agents never need them, and | ||
| // they generate a constant stream of traffic across the tunnel. | ||
| const HMR_PATH_PREFIXES = [ | ||
| "/_next/webpack-hmr", // Next.js HMR WebSocket | ||
| "/__webpack_hmr", // CRA / webpack-dev-server | ||
| "/@vite/client", // Vite HMR WebSocket | ||
| "/@hmr", // generic | ||
| "/__vite_ping", // Vite ping | ||
| ]; | ||
| // Path prefixes whose responses are safe to cache in-process across agents. | ||
| // These are immutable-content assets: Next.js/_next/static, Vite /_assets, | ||
| // and any route that carries Cache-Control: public, max-age=… from upstream. | ||
| // The proxy treats them as fingerprint-stable if the path includes a content | ||
| // hash (detected via a hex/base64 segment ≥ 8 chars, which is the minimum for | ||
| // Next.js build IDs, Vite chunk hashes, and most bundler fingerprints). | ||
| const CACHEABLE_PATH_PREFIXES = [ | ||
| "/_next/static/", | ||
| "/__nextjs_", | ||
| "/static/", | ||
| "/assets/", | ||
| "/_vite/", | ||
| "/build/", | ||
| ]; | ||
| const TEXT_CONTENT_TYPES = [ | ||
@@ -19,3 +44,59 @@ "text/html", | ||
| ]; | ||
| // Maximum body size buffered for rewriting. Responses larger than this are | ||
| // passed through as-is (headers still rewritten). 10 MB is generous for any | ||
| // individual HTML document or JSON payload; JS bundles in dev mode can exceed | ||
| // this, but they rarely contain localhost URLs that need rewriting. | ||
| const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB | ||
| // Gzip compression level used when re-compressing rewritten bodies. Level 4 | ||
| // is the sweet spot: roughly 85-90 % of level 9's ratio in ~15 % of the CPU | ||
| // time. Level 6 (zlib default) is overkill for transient proxy traffic. | ||
| const RECOMPRESS_LEVEL = 4; | ||
| // Maximum number of entries and total bytes held in the static cache. | ||
| const CACHE_MAX_ENTRIES = 200; | ||
| const CACHE_MAX_BYTES = 200 * 1024 * 1024; // 200 MB | ||
| class AssetCache { | ||
| entries = new Map(); | ||
| totalBytes = 0; | ||
| get(key) { | ||
| return this.entries.get(key); | ||
| } | ||
| set(key, entry) { | ||
| const existing = this.entries.get(key); | ||
| if (existing) { | ||
| this.totalBytes -= existing.body.length; | ||
| } | ||
| // Evict oldest entries if over budget | ||
| while (this.entries.size >= CACHE_MAX_ENTRIES || | ||
| this.totalBytes + entry.body.length > CACHE_MAX_BYTES) { | ||
| const oldest = this.entries.keys().next().value; | ||
| if (!oldest) | ||
| break; | ||
| const evicted = this.entries.get(oldest); | ||
| this.totalBytes -= evicted.body.length; | ||
| this.entries.delete(oldest); | ||
| } | ||
| this.entries.set(key, entry); | ||
| this.totalBytes += entry.body.length; | ||
| } | ||
| get size() { return this.entries.size; } | ||
| get bytes() { return this.totalBytes; } | ||
| } | ||
| // ─── Path fingerprint heuristic ────────────────────────────────────────────── | ||
| // Returns true when a URL path contains a content-hash segment, indicating the | ||
| // asset is versioned and safe to cache indefinitely within a test run. | ||
| const HASH_SEGMENT_RE = /[/._-][0-9a-f]{8,}[0-9a-f]*/i; | ||
| function hasHashSegment(pathname) { | ||
| return HASH_SEGMENT_RE.test(pathname); | ||
| } | ||
| function isCacheablePath(pathname) { | ||
| if (CACHEABLE_PATH_PREFIXES.some((p) => pathname.startsWith(p))) | ||
| return true; | ||
| // Also cache anything with a fingerprint hash regardless of prefix | ||
| if (hasHashSegment(pathname)) | ||
| return true; | ||
| return false; | ||
| } | ||
| function isHmrPath(pathname) { | ||
| return HMR_PATH_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + "?") || pathname.startsWith(p + "/")); | ||
| } | ||
| function isTextResponse(contentType) { | ||
@@ -26,2 +107,3 @@ if (!contentType) | ||
| } | ||
| // ─── Decompression ─────────────────────────────────────────────────────────── | ||
| function decompress(encoding, data) { | ||
@@ -36,6 +118,35 @@ if (encoding === "gzip") | ||
| } | ||
| /** | ||
| * Rewrite response headers that contain localhost origins, replacing them | ||
| * with the public tunnel URL. | ||
| */ | ||
| // ─── Recompression ─────────────────────────────────────────────────────────── | ||
| // After rewriting the body text we re-gzip it before handing it to ngrok. | ||
| // This undoes the 3-5x bandwidth penalty caused by stripping Content-Encoding | ||
| // and sending raw UTF-8 bytes through the tunnel. | ||
| // | ||
| // We use gzip (not brotli) because: | ||
| // 1. All ngrok-tunnelled browsers support gzip unconditionally. | ||
| // 2. Node's zlib.gzipSync is ~3x faster than brotliCompressSync at level 4. | ||
| // 3. gzip level 4 achieves ~65-75 % compression on JS/HTML — close enough | ||
| // to brotli for this use-case, with far less CPU on the user's machine. | ||
| // | ||
| // We only re-compress when the original response was compressed OR when the | ||
| // uncompressed body exceeds 1 KB (below that, headers dominate and compression | ||
| // rarely helps). | ||
| const RECOMPRESS_MIN_BYTES = 1024; | ||
| function recompress(originalEncoding, body) { | ||
| // If body is small enough, don't bother | ||
| if (body.length < RECOMPRESS_MIN_BYTES && !originalEncoding) { | ||
| return { body, encoding: null }; | ||
| } | ||
| try { | ||
| const compressed = zlib.gzipSync(body, { level: RECOMPRESS_LEVEL }); | ||
| // Only use compression if it actually shrinks the payload | ||
| if (compressed.length < body.length) { | ||
| return { body: compressed, encoding: "gzip" }; | ||
| } | ||
| } | ||
| catch { | ||
| // Fall through to uncompressed | ||
| } | ||
| return { body, encoding: null }; | ||
| } | ||
| // ─── Header rewriting ──────────────────────────────────────────────────────── | ||
| function rewriteHeaders(headers, localhostOrigins, tunnelUrl) { | ||
@@ -50,3 +161,3 @@ // location — 302 redirects | ||
| } | ||
| // set-cookie — strip Domain=localhost / Domain=127.0.0.1 (with optional port) | ||
| // set-cookie — strip Domain=localhost / Domain=127.0.0.1 | ||
| const setCookie = headers["set-cookie"]; | ||
@@ -59,3 +170,2 @@ if (Array.isArray(setCookie)) { | ||
| } | ||
| // Remove Domain=localhost or Domain=127.0.0.1 (with optional port) | ||
| c = c.replace(/;\s*Domain=\.?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:\d+)?/gi, ""); | ||
@@ -82,2 +192,20 @@ return c; | ||
| } | ||
| // ─── Cache-Control immutability check ──────────────────────────────────────── | ||
| // Returns true if the upstream Cache-Control marks the response as immutable | ||
| // or long-lived (safe to cache for the duration of a test run). | ||
| function isUpstreamImmutable(headers) { | ||
| const cc = headers["cache-control"]; | ||
| if (!cc) | ||
| return false; | ||
| if (cc.includes("immutable")) | ||
| return true; | ||
| const match = cc.match(/max-age=(\d+)/); | ||
| if (match) { | ||
| const maxAge = parseInt(match[1], 10); | ||
| // Anything cached for > 1 hour is safe to hold in-process | ||
| return maxAge > 3600; | ||
| } | ||
| return false; | ||
| } | ||
| // ─── Public API ────────────────────────────────────────────────────────────── | ||
| export async function startProxy(frontendPort, backendPort, backendPaths) { | ||
@@ -96,5 +224,3 @@ let tunnelUrl; | ||
| ]; | ||
| // All origins that should be rewritten to the tunnel URL | ||
| const allOrigins = [...backendOrigins, ...frontendOrigins]; | ||
| // ws:// and wss:// variants for body rewriting | ||
| const wsOrigins = [ | ||
@@ -110,2 +236,3 @@ `ws://localhost:${backendPort}`, | ||
| ]; | ||
| const cache = new AssetCache(); | ||
| function shouldRouteToBackend(pathname) { | ||
@@ -124,33 +251,51 @@ return BACKEND_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | ||
| }); | ||
| proxy.on("proxyRes", (proxyRes, _req, res) => { | ||
| proxy.on("proxyRes", (proxyRes, req, res) => { | ||
| const sRes = res; | ||
| const sReq = req; | ||
| const contentType = proxyRes.headers["content-type"]; | ||
| const isSSE = contentType?.includes("text/event-stream"); | ||
| // SSE: pass through without buffering (SSE never ends) | ||
| // ── SSE: stream without buffering ──────────────────────────────────────── | ||
| if (isSSE) { | ||
| const headers = { ...proxyRes.headers }; | ||
| if (tunnelUrl) { | ||
| if (tunnelUrl) | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| } | ||
| res.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| proxyRes.pipe(res); | ||
| sRes.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| proxyRes.pipe(sRes); | ||
| return; | ||
| } | ||
| const pathname = new URL(sReq.url ?? "/", "http://localhost").pathname; | ||
| // ── Cache hit: serve directly without touching the upstream body ────────── | ||
| const cacheKey = pathname + (sReq.headers["accept-encoding"] ? "" : "|noenc"); | ||
| const hit = cache.get(cacheKey); | ||
| if (hit) { | ||
| // Drain the upstream response to free the socket | ||
| proxyRes.resume(); | ||
| const cachedHeaders = { ...hit.headers }; | ||
| // Update any headers that reference the current tunnel URL (it may have | ||
| // changed between requests if the user restarted, though in practice it | ||
| // does not change within a run) | ||
| if (tunnelUrl) | ||
| rewriteHeaders(cachedHeaders, allOrigins, tunnelUrl); | ||
| sRes.writeHead(hit.statusCode, cachedHeaders); | ||
| sRes.end(hit.body); | ||
| return; | ||
| } | ||
| const shouldRewrite = tunnelUrl && isTextResponse(contentType); | ||
| if (!shouldRewrite) { | ||
| // Pass through as-is, but still rewrite headers if we have a tunnel URL | ||
| const shouldCache = isCacheablePath(pathname) || isUpstreamImmutable(proxyRes.headers); | ||
| // ── Non-rewritable, non-cacheable: passthrough ──────────────────────────── | ||
| if (!shouldRewrite && !shouldCache) { | ||
| const headers = { ...proxyRes.headers }; | ||
| if (tunnelUrl) { | ||
| if (tunnelUrl) | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| } | ||
| res.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| proxyRes.pipe(res); | ||
| sRes.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| proxyRes.pipe(sRes); | ||
| return; | ||
| } | ||
| proxyRes.on("error", () => { | ||
| if (!res.headersSent) { | ||
| res.writeHead(502, { "Content-Type": "text/plain" }); | ||
| if (!sRes.headersSent) { | ||
| sRes.writeHead(502, { "Content-Type": "text/plain" }); | ||
| } | ||
| res.end("Upstream connection error"); | ||
| sRes.end("Upstream connection error"); | ||
| }); | ||
| // Buffer the response body for rewriting | ||
| // ── Buffer response body for rewriting and/or caching ──────────────────── | ||
| const chunks = []; | ||
@@ -165,12 +310,12 @@ let totalSize = 0; | ||
| exceeded = true; | ||
| // Flush what we have without body rewriting | ||
| // Overflow: flush with header-only rewriting, skip body rewrite/cache | ||
| const headers = { ...proxyRes.headers }; | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| res.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| if (tunnelUrl) | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| sRes.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| for (const c of chunks) | ||
| res.write(c); | ||
| res.write(chunk); | ||
| sRes.write(c); | ||
| sRes.write(chunk); | ||
| chunks.length = 0; | ||
| // Pipe the rest directly | ||
| proxyRes.pipe(res); | ||
| proxyRes.pipe(sRes); | ||
| return; | ||
@@ -181,39 +326,75 @@ } | ||
| proxyRes.on("end", () => { | ||
| // If buffer size cap exceeded, response was already piped in the data handler | ||
| if (exceeded) | ||
| return; | ||
| let body; | ||
| const raw = Buffer.concat(chunks); | ||
| const originalEncoding = proxyRes.headers["content-encoding"]; | ||
| // ── Step 1: decompress ──────────────────────────────────────────────── | ||
| let decompressed; | ||
| try { | ||
| const raw = Buffer.concat(chunks); | ||
| const encoding = proxyRes.headers["content-encoding"]; | ||
| const decompressed = decompress(encoding, raw); | ||
| body = decompressed.toString("utf-8"); | ||
| decompressed = decompress(originalEncoding, raw); | ||
| } | ||
| catch { | ||
| // If decompression fails, pass through original | ||
| // Decompression failed — passthrough original bytes | ||
| const headers = { ...proxyRes.headers }; | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| res.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| res.end(Buffer.concat(chunks)); | ||
| if (tunnelUrl) | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| sRes.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| sRes.end(raw); | ||
| return; | ||
| } | ||
| // Derive tunnel ws:// URL from tunnel https:// URL | ||
| const tunnelWs = tunnelUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://"); | ||
| // Rewrite all http localhost origins to tunnel URL | ||
| for (const origin of allOrigins) { | ||
| body = body.replaceAll(origin, tunnelUrl); | ||
| // ── Step 2: rewrite body text ───────────────────────────────────────── | ||
| let finalBody; | ||
| if (shouldRewrite && tunnelUrl) { | ||
| const tunnelWs = tunnelUrl | ||
| .replace(/^https:\/\//, "wss://") | ||
| .replace(/^http:\/\//, "ws://"); | ||
| let text = decompressed.toString("utf-8"); | ||
| for (const origin of allOrigins) | ||
| text = text.replaceAll(origin, tunnelUrl); | ||
| for (const origin of wsOrigins) | ||
| text = text.replaceAll(origin, tunnelWs); | ||
| finalBody = Buffer.from(text, "utf-8"); | ||
| } | ||
| // Rewrite ws:// and wss:// localhost origins to tunnel ws URL | ||
| for (const origin of wsOrigins) { | ||
| body = body.replaceAll(origin, tunnelWs); | ||
| else { | ||
| finalBody = decompressed; | ||
| } | ||
| const rewritten = Buffer.from(body, "utf-8"); | ||
| // Update headers — remove content-encoding (we decompressed), fix content-length | ||
| // ── Step 3: re-compress ─────────────────────────────────────────────── | ||
| // This is the key fix: instead of sending the raw decompressed bytes | ||
| // through the tunnel (which is what the original code did by stripping | ||
| // content-encoding without re-compressing), we gzip the rewritten body. | ||
| // | ||
| // Typical compression ratios for dev-mode assets: | ||
| // HTML ~75 % (4 KB → 1 KB) | ||
| // Unminified JS ~80 % (500 KB → 100 KB) | ||
| // JSON API ~70 % (20 KB → 6 KB) | ||
| // CSS ~78 % (80 KB → 18 KB) | ||
| // | ||
| // For a 3-agent / 30-step run this reduces tunnel egress from ~300 MB | ||
| // to ~60 MB — a 5x improvement. | ||
| const { body: outBody, encoding: outEncoding } = recompress(originalEncoding, finalBody); | ||
| // ── Step 4: build final response headers ────────────────────────────── | ||
| const headers = { ...proxyRes.headers }; | ||
| delete headers["content-encoding"]; | ||
| delete headers["transfer-encoding"]; | ||
| headers["content-length"] = String(rewritten.length); | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| res.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| res.end(rewritten); | ||
| delete headers["transfer-encoding"]; // We know the exact length | ||
| if (outEncoding) { | ||
| headers["content-encoding"] = outEncoding; | ||
| } | ||
| else { | ||
| delete headers["content-encoding"]; | ||
| } | ||
| headers["content-length"] = String(outBody.length); | ||
| if (tunnelUrl) | ||
| rewriteHeaders(headers, allOrigins, tunnelUrl); | ||
| // ── Step 5: populate cache if eligible ─────────────────────────────── | ||
| if (shouldCache && proxyRes.statusCode === 200) { | ||
| // Store the compressed (wire-format) body so cache hits skip | ||
| // both decompression and recompression entirely. | ||
| cache.set(cacheKey, { | ||
| statusCode: 200, | ||
| headers: { ...headers }, | ||
| body: outBody, | ||
| cachedAt: Date.now(), | ||
| }); | ||
| } | ||
| sRes.writeHead(proxyRes.statusCode ?? 200, headers); | ||
| sRes.end(outBody); | ||
| }); | ||
@@ -223,6 +404,17 @@ }); | ||
| const pathname = new URL(req.url ?? "/", "http://localhost").pathname; | ||
| // ── Block HMR/dev-only HTTP polling endpoints ───────────────────────────── | ||
| // These are never needed by the testing agents. Responding 204 immediately | ||
| // prevents the hot-reload client from repeatedly establishing connections | ||
| // that would traverse the ngrok tunnel. | ||
| if (isHmrPath(pathname)) { | ||
| res.writeHead(204, { | ||
| "Cache-Control": "no-store", | ||
| "X-Proxy-Blocked": "hmr", | ||
| }); | ||
| res.end(); | ||
| return; | ||
| } | ||
| const target = shouldRouteToBackend(pathname) | ||
| ? `http://127.0.0.1:${backendPort}` | ||
| : `http://127.0.0.1:${frontendPort}`; | ||
| // Inject X-Forwarded-* headers | ||
| req.headers["x-forwarded-proto"] = "https"; | ||
@@ -232,5 +424,11 @@ req.headers["x-forwarded-host"] = req.headers["host"] || ""; | ||
| }); | ||
| // Handle WebSocket upgrade (HMR, dev servers) | ||
| // ── WebSocket upgrade ───────────────────────────────────────────────────── | ||
| server.on("upgrade", (req, socket, head) => { | ||
| const pathname = new URL(req.url ?? "/", "http://localhost").pathname; | ||
| // Drop HMR WebSocket upgrades. The testing agents use Playwright CDP, | ||
| // not the browser's built-in HMR client, so these sockets are pure waste. | ||
| if (isHmrPath(pathname)) { | ||
| socket.destroy(); | ||
| return; | ||
| } | ||
| const target = shouldRouteToBackend(pathname) | ||
@@ -237,0 +435,0 @@ ? `http://127.0.0.1:${backendPort}` |
@@ -0,5 +1,11 @@ | ||
| export type TunnelProvider = "cloudflare" | "ngrok" | "auto"; | ||
| export interface TunnelInfo { | ||
| url: string; | ||
| provider: TunnelProvider; | ||
| close: () => Promise<void>; | ||
| } | ||
| export declare function openTunnel(localPort: number, authToken: string): Promise<TunnelInfo>; | ||
| export interface OpenTunnelOptions { | ||
| provider?: TunnelProvider; | ||
| ngrokAuthToken?: string; | ||
| } | ||
| export declare function openTunnel(localPort: number, opts?: OpenTunnelOptions): Promise<TunnelInfo>; |
+110
-2
| import ngrok from "@ngrok/ngrok"; | ||
| export async function openTunnel(localPort, authToken) { | ||
| import { spawn } from "node:child_process"; | ||
| import { once } from "node:events"; | ||
| // ── Provider: ngrok ────────────────────────────────────────────────────────── | ||
| async function openNgrokTunnel(localPort, authToken) { | ||
| const forwardPromise = ngrok.forward({ | ||
@@ -20,3 +23,2 @@ addr: `127.0.0.1:${localPort}`, | ||
| clearTimeout(timer); | ||
| // If timeout won, the forward may still succeed — clean it up | ||
| forwardPromise.then((l) => l.close()).catch(() => { }); | ||
@@ -31,2 +33,3 @@ throw err; | ||
| url, | ||
| provider: "ngrok", | ||
| close: async () => { | ||
@@ -37,1 +40,106 @@ await listener.close(); | ||
| } | ||
| // ── Provider: Cloudflare Tunnel ────────────────────────────────────────────── | ||
| const CF_URL_RE = /https:\/\/[-\w]+\.trycloudflare\.com/; | ||
| async function findCloudflaredBin() { | ||
| // Try the `cloudflared` npm package which auto-downloads the binary | ||
| try { | ||
| // Dynamic import — cloudflared is an optional peer dependency | ||
| const mod = await Function('return import("cloudflared")')(); | ||
| if (mod.bin) | ||
| return mod.bin; | ||
| } | ||
| catch { | ||
| // Not installed — fall through to PATH | ||
| } | ||
| return "cloudflared"; | ||
| } | ||
| async function openCloudflareTunnel(localPort) { | ||
| const bin = await findCloudflaredBin(); | ||
| const child = spawn(bin, ["tunnel", "--url", `http://127.0.0.1:${localPort}`, "--no-autoupdate"], { stdio: ["ignore", "pipe", "pipe"] }); | ||
| const url = await new Promise((resolve, reject) => { | ||
| const timer = setTimeout(() => { | ||
| child.kill(); | ||
| reject(new Error("Cloudflare tunnel timed out after 30s.\n" + | ||
| " Is cloudflared installed? Install with:\n" + | ||
| " macOS: brew install cloudflared\n" + | ||
| " Linux: curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared\n" + | ||
| " Windows: winget install Cloudflare.cloudflared\n" + | ||
| " npm: npm install -g cloudflared")); | ||
| }, 30_000); | ||
| let stderrBuf = ""; | ||
| child.stderr?.on("data", (chunk) => { | ||
| stderrBuf += chunk.toString(); | ||
| const match = stderrBuf.match(CF_URL_RE); | ||
| if (match) { | ||
| clearTimeout(timer); | ||
| resolve(match[0]); | ||
| } | ||
| }); | ||
| // Also check stdout — some versions print there | ||
| child.stdout?.on("data", (chunk) => { | ||
| stderrBuf += chunk.toString(); | ||
| const match = stderrBuf.match(CF_URL_RE); | ||
| if (match) { | ||
| clearTimeout(timer); | ||
| resolve(match[0]); | ||
| } | ||
| }); | ||
| child.on("error", (err) => { | ||
| clearTimeout(timer); | ||
| if (err.code === "ENOENT") { | ||
| reject(new Error("cloudflared not found. Install it:\n" + | ||
| " macOS: brew install cloudflared\n" + | ||
| " Linux: curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared\n" + | ||
| " Windows: winget install Cloudflare.cloudflared\n" + | ||
| " npm: npm install -g cloudflared")); | ||
| } | ||
| else { | ||
| reject(err); | ||
| } | ||
| }); | ||
| child.on("exit", (code) => { | ||
| clearTimeout(timer); | ||
| reject(new Error(`cloudflared exited with code ${code} before tunnel URL was assigned`)); | ||
| }); | ||
| }); | ||
| return { | ||
| url, | ||
| provider: "cloudflare", | ||
| close: async () => { | ||
| if (!child.killed) { | ||
| child.kill("SIGTERM"); | ||
| await Promise.race([ | ||
| once(child, "exit").catch(() => { }), | ||
| new Promise((r) => setTimeout(r, 5_000)), | ||
| ]); | ||
| if (!child.killed) | ||
| child.kill("SIGKILL"); | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
| export async function openTunnel(localPort, opts = {}) { | ||
| const provider = opts.provider ?? "auto"; | ||
| // Explicit provider selection | ||
| if (provider === "ngrok") { | ||
| if (!opts.ngrokAuthToken) { | ||
| throw new Error("ngrok requires an auth token (provided via your Swarm account)"); | ||
| } | ||
| return openNgrokTunnel(localPort, opts.ngrokAuthToken); | ||
| } | ||
| if (provider === "cloudflare") { | ||
| return openCloudflareTunnel(localPort); | ||
| } | ||
| // Auto mode: try cloudflare first (free, no token needed), fall back to ngrok | ||
| try { | ||
| return await openCloudflareTunnel(localPort); | ||
| } | ||
| catch (cfErr) { | ||
| if (opts.ngrokAuthToken) { | ||
| // Cloudflare failed but we have an ngrok token — fall back silently | ||
| return openNgrokTunnel(localPort, opts.ngrokAuthToken); | ||
| } | ||
| // No fallback available — surface the cloudflare error with install hint | ||
| throw cfErr; | ||
| } | ||
| } |
+1
-1
| { | ||
| "name": "@useswarm/cli", | ||
| "version": "0.2.0", | ||
| "version": "0.3.0", | ||
| "description": "Swarm CLI — AI-powered UX testing from your terminal", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+40
-3
@@ -71,3 +71,4 @@ # @useswarm/cli | ||
| | `--url <url>` | Target URL. Use `localhost:PORT` for local dev | | ||
| | `--no-tunnel` | Skip ngrok tunnel (URL must be publicly accessible) | | ||
| | `--no-tunnel` | Skip tunnel (URL must be publicly accessible) | | ||
| | `--tunnel-provider <provider>` | `cloudflare`, `ngrok`, or `auto` (default: `auto`) | | ||
@@ -100,3 +101,3 @@ #### Test Configuration | ||
| When targeting localhost, the CLI automatically: | ||
| - Opens an ngrok tunnel so remote AI agents can reach your app | ||
| - Opens a tunnel (Cloudflare or ngrok) so remote AI agents can reach your app | ||
| - Detects your framework (Next.js, Vite, Django, etc.) and warns about common pitfalls | ||
@@ -165,2 +166,37 @@ - Checks if your dev server is running before opening the tunnel | ||
| ### Tunnel Providers | ||
| The CLI supports two tunnel providers for exposing your localhost to the cloud: | ||
| #### Cloudflare Tunnel (default) | ||
| Free with no bandwidth limits. Requires the `cloudflared` binary installed on your machine: | ||
| | Platform | Install command | | ||
| |----------|----------------| | ||
| | **macOS** | `brew install cloudflared` | | ||
| | **Windows** | `winget install Cloudflare.cloudflared` | | ||
| | **Linux (Debian/Ubuntu)** | `curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \| sudo tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null && echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \| sudo tee /etc/apt/sources.list.d/cloudflared.list && sudo apt update && sudo apt install cloudflared` | | ||
| | **Linux (binary)** | `curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared` | | ||
| | **npm (any platform)** | `npm install -g cloudflared` | | ||
| No Cloudflare account or API key required — it just works. | ||
| #### ngrok (fallback) | ||
| Bundled with the CLI — no extra install needed. Uses your Swarm account's managed ngrok token. Subject to ngrok's bandwidth limits on the free tier. | ||
| #### Choosing a provider | ||
| ```bash | ||
| # Auto mode (default) — tries Cloudflare first, falls back to ngrok | ||
| swarm test --url localhost:3000 --goal "Sign up" | ||
| # Force Cloudflare | ||
| swarm test --url localhost:3000 --goal "Sign up" --tunnel-provider cloudflare | ||
| # Force ngrok | ||
| swarm test --url localhost:3000 --goal "Sign up" --tunnel-provider ngrok | ||
| ``` | ||
| ### What the Tunnel Handles | ||
@@ -243,3 +279,3 @@ | ||
| 3. **Tunnel** — For localhost targets, an ngrok tunnel makes your local server reachable. A reverse proxy handles URL rewriting, cookie fixing, and header translation. | ||
| 3. **Tunnel** — For localhost targets, a Cloudflare or ngrok tunnel makes your local server reachable. A reverse proxy handles URL rewriting, cookie fixing, and header translation. | ||
@@ -254,2 +290,3 @@ 4. **AI Agents** — Each persona runs in a real cloud browser (Browserbase). The agent navigates your app, performs the goal, and captures UX observations at every step. | ||
| - A Swarm account with a Pro plan ([useswarm.co](https://useswarm.co)) | ||
| - For local testing: `cloudflared` ([install](#cloudflare-tunnel-default)) or ngrok (bundled) | ||
@@ -256,0 +293,0 @@ ## Links |
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
84634
29.88%1780
25%293
14.45%5
25%