feat(dev): add pnpm dev:mobile and dev:both for prebuilt UI preview (#10718)

## 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
This commit is contained in:
Devin Foley 2026-08-02 20:26:03 -07:00 committed by GitHub
parent 185515c97b
commit 97590ff8c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 144 additions and 6 deletions

View File

@ -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)

View File

@ -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://<host>.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.

View File

@ -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",

43
scripts/dev-both.mjs Normal file
View File

@ -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));
}

View File

@ -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<string, string | string[] | undefined>;
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();
});
});

View File

@ -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<string, ProxyOptions> {
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"));
});
},
},
};
}

View File

@ -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,
},
}));