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:
parent
185515c97b
commit
97590ff8c4
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"));
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue