diff --git a/README.md b/README.md index 5aa71a37ee..8e25e8f668 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,8 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as pnpm dev # Full dev (API + UI, watch mode) pnpm dev:once # Full dev without file watching pnpm dev:server # Server only +pnpm dev:mobile # Serve prebuilt UI on :3101 for phones/tablets (proxies /api → :3100) +pnpm dev:both # Run `pnpm dev` and `pnpm dev:mobile` together pnpm build # Build all pnpm typecheck # Type checking pnpm test # Cheap default test run (Vitest only) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c8c9d1df47..e16c40beaf 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -45,6 +45,15 @@ This starts: Issue execution may also use project execution workspace policies and workspace runtime services for per-project worktrees, preview servers, and managed dev commands. Configure those through the project workspace/runtime surfaces rather than starting long-running unmanaged processes when a task needs a reusable service. +### Mobile-friendly preview (`pnpm dev:mobile`) + +The vite dev server serves an unbundled module graph. This is fast to reload on a local machine but too heavy for phones and tablets on slow links (airplane wifi, mobile data, distant tailnet peers). `pnpm dev:mobile` builds the UI once and serves the small production bundle on port `3101` via `vite preview`, proxying `/api` requests to the dev API on `3100`. + +- `pnpm dev:mobile` — build the UI and start the preview server on `:3101`. Rebuild manually to pick up UI source changes. +- `pnpm dev:both` — run `pnpm dev` and `pnpm dev:mobile` together with prefixed output and shared signal handling. + +The preview server binds `0.0.0.0` and accepts any Host, so a tailnet or LAN address (e.g. `http://.ts.net:3101/`) works out of the box. The `/api` proxy sets `x-forwarded-host` and `x-forwarded-proto`, which the server's board mutation guard uses to trust the browser's Origin — mutations from `:3101` succeed against the API on `:3100` without further configuration. An HTTPS tunnel in front of the preview server (ngrok, tailscale funnel) is also supported: the tunnel's `x-forwarded-proto` header is preserved when set. + ## Storybook The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes. diff --git a/package.json b/package.json index 38a3abc023..37eeae0ccc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", + "dev:mobile": "pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui preview", + "dev:both": "node scripts/dev-both.mjs", "storybook": "pnpm --filter @paperclipai/ui storybook", "build-storybook": "pnpm --filter @paperclipai/ui build-storybook", "build": "pnpm run preflight:workspace-links && pnpm -r build", diff --git a/scripts/dev-both.mjs b/scripts/dev-both.mjs new file mode 100644 index 0000000000..0c9a37f7ac --- /dev/null +++ b/scripts/dev-both.mjs @@ -0,0 +1,43 @@ +import { spawn } from "node:child_process"; + +const jobs = [ + { name: "dev", script: "dev" }, + { name: "mobile", script: "dev:mobile" }, +]; +const nameWidth = Math.max(...jobs.map((j) => j.name.length)); +const children = []; +let stopping = false; + +function prefix(name, data) { + const label = `[${name.padEnd(nameWidth)}] `; + return label + String(data).replace(/\n(?!$)/g, `\n${label}`); +} + +function stopAll(code) { + if (stopping) return; + stopping = true; + for (const { child } of children) { + if (child.exitCode === null && !child.killed) { + child.kill("SIGTERM"); + } + } + setTimeout(() => process.exit(code), 500).unref(); +} + +for (const job of jobs) { + const child = spawn("pnpm", ["run", job.script], { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + child.stdout.on("data", (d) => process.stdout.write(prefix(job.name, d))); + child.stderr.on("data", (d) => process.stderr.write(prefix(job.name, d))); + child.on("exit", (code, signal) => { + process.stderr.write(prefix(job.name, `exited (code=${code} signal=${signal})\n`)); + stopAll(code ?? 1); + }); + children.push({ name: job.name, child }); +} + +for (const sig of ["SIGINT", "SIGTERM"]) { + process.on(sig, () => stopAll(0)); +} diff --git a/ui/src/lib/vite-api-proxy.test.ts b/ui/src/lib/vite-api-proxy.test.ts new file mode 100644 index 0000000000..766f38c55e --- /dev/null +++ b/ui/src/lib/vite-api-proxy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { createApiProxy } from "./vite-api-proxy"; + +describe("createApiProxy", () => { + function fireProxyReq(req: { + headers: Record; + socket?: { encrypted?: boolean }; + }) { + const proxy = createApiProxy(); + const proxyEmitter = new EventEmitter(); + proxy["/api"].configure!(proxyEmitter as never, {} as never); + const setHeader = vi.fn(); + proxyEmitter.emit("proxyReq", { setHeader }, { socket: {}, ...req }); + return setHeader; + } + + it("proxies /api to the given target with ws support", () => { + const proxy = createApiProxy("http://example.local:9999"); + expect(proxy["/api"].target).toBe("http://example.local:9999"); + expect(proxy["/api"].ws).toBe(true); + }); + + it("injects x-forwarded-host and defaults x-forwarded-proto to http on plain sockets", () => { + const setHeader = fireProxyReq({ headers: { host: "goldie.gerbil-company.ts.net:3101" } }); + expect(setHeader).toHaveBeenCalledWith("x-forwarded-host", "goldie.gerbil-company.ts.net:3101"); + expect(setHeader).toHaveBeenCalledWith("x-forwarded-proto", "http"); + }); + + it("derives x-forwarded-proto=https when the client socket is TLS", () => { + const setHeader = fireProxyReq({ + headers: { host: "app.example.com" }, + socket: { encrypted: true }, + }); + expect(setHeader).toHaveBeenCalledWith("x-forwarded-proto", "https"); + }); + + it("preserves an upstream x-forwarded-proto header from an HTTPS tunnel", () => { + const setHeader = fireProxyReq({ + headers: { host: "abcd.ngrok.app", "x-forwarded-proto": "https" }, + }); + expect(setHeader).toHaveBeenCalledWith("x-forwarded-proto", "https"); + }); + + it("skips forwarding headers when the client sends no Host", () => { + const setHeader = fireProxyReq({ headers: {} }); + expect(setHeader).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/vite-api-proxy.ts b/ui/src/lib/vite-api-proxy.ts new file mode 100644 index 0000000000..7948530618 --- /dev/null +++ b/ui/src/lib/vite-api-proxy.ts @@ -0,0 +1,29 @@ +import type { ProxyOptions } from "vite"; + +// Shared /api proxy used by both the vite dev server and `vite preview`. +// The `configure` hook forwards the client's original Host as +// x-forwarded-host so the paperclip server's board mutation guard treats +// the browser's Origin as trusted when the SPA is served from a different +// port than the API (e.g. `pnpm dev:mobile` on :3101 → API on :3100). +export function createApiProxy(target = "http://localhost:3100"): Record { + return { + "/api": { + target, + ws: true, + configure: (proxy) => { + proxy.on("proxyReq", (proxyReq, req) => { + const originalHost = req.headers.host; + if (!originalHost) return; + proxyReq.setHeader("x-forwarded-host", originalHost); + // Prefer an upstream x-forwarded-proto (an HTTPS tunnel such as + // ngrok or tailscale funnel terminates TLS and forwards HTTP to + // vite with the header set). Fall back to the socket's TLS state. + const upstreamProto = req.headers["x-forwarded-proto"]; + const proto = Array.isArray(upstreamProto) ? upstreamProto[0] : upstreamProto; + const isTls = (req.socket as { encrypted?: boolean }).encrypted === true; + proxyReq.setHeader("x-forwarded-proto", proto ?? (isTls ? "https" : "http")); + }); + }, + }, + }; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 6ab0e386cd..d235797fec 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { createUiDevWatchOptions } from "./src/lib/vite-watch"; +import { createApiProxy } from "./src/lib/vite-api-proxy"; + +const apiProxy = createApiProxy(); export default defineConfig(({ mode }) => ({ plugins: [react(), tailwindcss()], @@ -25,11 +28,12 @@ export default defineConfig(({ mode }) => ({ server: { port: 5173, watch: createUiDevWatchOptions(process.cwd()), - proxy: { - "/api": { - target: "http://localhost:3100", - ws: true, - }, - }, + proxy: apiProxy, + }, + preview: { + port: 3101, + host: "0.0.0.0", + allowedHosts: true, + proxy: apiProxy, }, }));