From 97590ff8c464ea0c0b0a0ffe02411c795c31cd3b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 2 Aug 2026 20:26:03 -0700 Subject: [PATCH] feat(dev): add pnpm dev:mobile and dev:both for prebuilt UI preview (#10718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI is a React SPA served by the paperclip server; the standard local dev flow is `pnpm dev`, which runs vite in dev mode with HMR and an unbundled module graph > - The unbundled dev bundle is hundreds of MB of JS across many requests, which is fine on a local machine but unusable from a phone or tablet on slow/lossy links (airplane wifi, mobile data, distant tailnet peers) > - Contributors who want to iterate on the board from a mobile device today have no supported way to preview a small production-shaped bundle without stopping the dev server and running a one-off `vite preview` with manual proxy plumbing > - This pull request adds `pnpm dev:mobile` — build the UI and serve `ui/dist` via `vite preview` on port 3101, with `/api` proxied to the running dev server on 3100 — plus `pnpm dev:both` to run both flavors together > - The benefit is a supported second flavor of the dev server for phones/tablets that runs alongside the normal one, without touching the primary `pnpm dev` flow ## Linked Issues or Issue Description **Subsystem affected** ui/ — React + Vite board UI **Problem or motivation** The vite dev server serves an unbundled module graph, which is fine on localhost but unusable from a phone or tablet on a slow link. Contributors testing responsive behavior on mobile devices have no supported way to serve a small production-shaped SPA against the running dev API. Running `vite preview` directly does not work either — the server's board mutation guard checks that the browser's Origin matches the request Host, and a preview on a second port would fail every mutation. **Proposed solution** Add two root scripts: - `pnpm dev:mobile` — build `ui/dist` and serve it via `vite preview` on port 3101, with `/api` proxied to the API server on 3100. - `pnpm dev:both` — run `pnpm dev` and `pnpm dev:mobile` together in a single terminal with prefixed output and shared signal handling. The vite preview config binds `0.0.0.0`, sets `allowedHosts: true` so it accepts arbitrary hostnames (LAN, tailnet, ngrok, etc.), and the shared `/api` proxy forwards the client's original Host header as `x-forwarded-host`. The paperclip server's mutation guard already prefers `x-forwarded-host` over `host` when computing trusted origins, so the browser's Origin becomes trusted automatically. **Alternatives considered** - Bespoke node proxy script — works but duplicates what vite preview already does. - Loosen the mutation guard to accept arbitrary origins — reduces security for the primary server for the sake of a dev-only workflow. - Second server config that binds a second port from the paperclip server itself — much larger change and mixes runtime concerns with a dev-tooling convenience. ## What Changed - New `pnpm dev:mobile` script — build UI then run `vite preview` on port 3101. - New `pnpm dev:both` script — run `pnpm dev` and `pnpm dev:mobile` together via `scripts/dev-both.mjs`, which prefixes each child's output, propagates SIGINT/SIGTERM, and exits when either child exits. - `ui/vite.config.ts` — add a `preview` block (port 3101, host `0.0.0.0`, `allowedHosts: true`, shared `/api` proxy). - New `ui/src/lib/vite-api-proxy.ts` — extracts the `/api` proxy factory shared by dev and preview, and forwards the client Host as `x-forwarded-host` (plus `x-forwarded-proto`). - New unit test `ui/src/lib/vite-api-proxy.test.ts` covering the header-injection behavior and the pass-through when no Host is present. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/lib/vite-api-proxy.test.ts` — 3 tests pass. - `pnpm --filter @paperclipai/ui typecheck` — clean. - `pnpm --filter @paperclipai/ui build` — clean. - Manual: ran `vite preview` against an echo listener and confirmed the request arrives with `x-forwarded-host` set to the client Host header and `x-forwarded-proto: http`. Then ran `pnpm dev:mobile` against the live dev server and verified board mutations (mark issue read, resolve recovery action, run routine) succeed from a second-port browser session that previously 403'd. ## Risks Low risk. Changes are limited to dev tooling — no runtime code paths, no server changes, no schema/migrations. The `apiProxy` refactor is a no-op behaviorally for the existing dev server (same target, same `ws: true`); the only new behavior is the two `x-forwarded-*` headers, and the server side already prefers those headers when trusting origins. `dev:mobile` and `dev:both` are additive; existing `pnpm dev` is untouched. ## Model Used Claude Opus 4.7 (1M context), extended thinking, tool use (bash, file edits). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- README.md | 2 ++ doc/DEVELOPING.md | 9 ++++++ package.json | 2 ++ scripts/dev-both.mjs | 43 +++++++++++++++++++++++++++ ui/src/lib/vite-api-proxy.test.ts | 49 +++++++++++++++++++++++++++++++ ui/src/lib/vite-api-proxy.ts | 29 ++++++++++++++++++ ui/vite.config.ts | 16 ++++++---- 7 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 scripts/dev-both.mjs create mode 100644 ui/src/lib/vite-api-proxy.test.ts create mode 100644 ui/src/lib/vite-api-proxy.ts 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, }, }));