paint-app: serve the app from the plotter server and retire Electron - #79
Merged
Conversation
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
force-pushed
the
plotter-server
branch
from
July 29, 2026 02:50
3ad0ab8 to
f3edbf0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
serial.tsis replaced byplotterClient.ts, which keeps the same surface, soApp.tsxneeded no changes at allKey Concerns
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.M112halts mid-move on this firmware, whether Marlin's timing survivesdrain()per write, whether an unplugged cable surfaces asclose, and whether the 2000ms boot wait holds over a Pi's USB stack.