mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 20:44:16 +02:00
## 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
50 lines
1.9 KiB
TypeScript
50 lines
1.9 KiB
TypeScript
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();
|
|
});
|
|
});
|