Skip to content

paint-app: serve the app from the plotter server and retire Electron - #79

Merged
TravisBumgarner merged 3 commits into
mainfrom
plotter-server
Jul 29, 2026
Merged

paint-app: serve the app from the plotter server and retire Electron#79
TravisBumgarner merged 3 commits into
mainfrom
plotter-server

Conversation

@TravisBumgarner

Copy link
Copy Markdown
Owner

Follow-on to #78, which was merged 20 minutes before these three commits were pushed — so this half never made it onto main. Same branch, remaining work only; the backend from #78 is already in.

Today the app reaches the plotter through the browser's Web Serial API, so the plotter has to be plugged into whatever machine is running the browser. This makes the server hand out the UI as well, so the Pi can hold the USB cable and the page can be opened from anywhere on the network.

  • The server now serves the built renderer — static assets, immutable hashing, and client-route fallback from the same process and image
  • serial.ts is replaced by plotterClient.ts, which keeps the same surface, so App.tsx needed no changes at all
  • Prints upload as a whole job instead of a line at a time; only progress comes back
  • The browser's port chooser becomes a list fetched from the server
  • Pen-swap pauses, and who currently holds control, are now rendered from server state
  • Electron is retired; renderer and server ship as one image

Key Concerns

  • Projects live in the browser via IndexedDB. Opening the app on your phone will show an empty project list. Moving persistence server-side is a separate decision and deliberately not done here.
  • Emergency stop is a convenience, not a safety device. It crosses a network, this process, and USB before reaching the board. It has a dedicated bypass path and is tested three ways — including from a client that cannot send a single G-code line — but the physical switch remains the real stop.
  • Taking control is now a networked capability, with CORS_ORIGIN=* and no auth. Handover is unconditional and can happen mid-print. The takeover dialog spells out the cost, but on a shared network that is a different risk shape than a USB cable.
  • Interactive strokes wait for an ack each, so a slow link will feel laggier than tethered, and there is no back-pressure story.
  • A dropped socket reconnects as a new client id, so the session list can show a stale entry until the heartbeat reaps it.
  • No byte has reached a real board. Unvalidated: whether M112 halts mid-move on this firmware, whether Marlin's timing survives drain() per write, whether an unplugged cable surfaces as close, and whether the 2000ms boot wait holds over a Pi's USB stack.

TravisBumgarner and others added 3 commits July 28, 2026 20:49
The renderer had to be hosted somewhere, and standing up a second
web server next to the one that already owns the serial port is two
things to start, two things to keep in sync, and a cross-origin
WebSocket to reason about. Serving the built page from this process
makes the Pi a single container on a single port.

index.html goes out as no-cache since it is the file that names the
current asset hashes; everything Vite fingerprints into assets/ goes
out immutable. Client-side routes fall through to the shell, but
never /api — an unknown endpoint there now answers JSON rather than
Express's HTML error page, so a client parsing the reply reports a
404 instead of a syntax error.

Two protocol additions the browser client needs: `jog` acks with the
board's reply lines, so reading an M114 doesn't take a second round
trip, and a new `stream` message carries a batch of interactive
stroke moves. `stream` shares jog's allowlist but honours the pause
gate — an interactive stroke is exactly the output pause exists to
hold back — and takes far more lines, because one freehand stroke is
hundreds of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Web Serial puts the port in the browser, so the browser had to be on
the machine holding the USB cable — one laptop, tethered, for the
length of a print. The plotter is going onto a Raspberry Pi, so the
client now talks to the server that owns the port.

`serial.ts` is replaced by `plotterClient.ts`, which keeps the same
shape where it can — connect / disconnect / send / sendMany / pause /
resume / emergencyStop / getPosition, with log and lost callbacks —
so App.tsx did not have to change at all. What genuinely differs:

- A print is uploaded whole and run by the server. G-code never
  crosses the network a line at a time; only progress comes back.
- The pen swap between layers is server state, not a Promise parked
  in a React component, so closing the tab no longer orphans a
  half-drawn page and the swap prompt shows up on every device.
- `navigator.serial.requestPort()` was a browser-native chooser; the
  port list is now fetched from the server, which is the only place
  that can see the cable.
- One client controls, the rest observe. The top bar says which you
  are, because otherwise the first surprise is a Pause button that
  does nothing. Handover is unconditional and can land mid-print, so
  it asks first.

Emergency stop deliberately does not go through the command socket.
POST /api/estop is a bare request the server answers without
consulting the control lock, the write queue, the pause gate, or the
running job, and it is offered to every client including read-only
ones — a safety control you have to request permission to use is not
one. The client tests assert that by firing it from an observer that
cannot send a single G-code line by the ordinary route, by firing it
with the session socket shut, and by wall clock with a two-second
move in flight. Each fails if the bypass is removed.

The client test lives in the server package because that is where the
fake Marlin and the real HTTP server are; a client tested only against
a mock of its own server proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Dockerfile moves up to paint-app/ so the build context covers
both halves: a stage that runs `vite build`, a stage that runs `tsc`,
and a runtime that serves the first from the second. CLIENT_DIR is
baked in, so pulling the image and starting it is the whole install —
there is no separate UI to deploy or keep at the same version.

The renderer imports the wire types straight out of the server rather
than keeping a copy, so the two halves of an emergency stop cannot
drift apart. That means the client build stage needs one file from
server/src, and the server's typecheck config has to reach above src/
to cover the client test.

@types/node becomes an explicit dependency of the renderer: it was
only ever there by hoisting, which a clean `npm ci` in the image does
not reproduce.

CI gains a job that lints and builds the renderer, and the image build
waits on both. The paths filter widens to all of paint-app, since the
image now contains all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TravisBumgarner
TravisBumgarner merged commit 91663aa into main Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant