Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/workflows/plotter-server-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: plotter server image

# Publishes the plotter backend container to GHCR.
#
# Tags are prefixed `plotter-server-` so image releases don't collide with the
# Python package's versioning elsewhere in this repo. Push
# `plotter-server-v1.0.0` to cut a release; pushes to main refresh `edge` so
# there's always something current to pull onto the Pi.
on:
push:
branches: [main]
tags: ["plotter-server-v*"]
paths:
- "paint-app/server/**"
- ".github/workflows/plotter-server-image.yml"
pull_request:
paths:
- "paint-app/server/**"
- ".github/workflows/plotter-server-image.yml"
workflow_dispatch:

env:
IMAGE: ghcr.io/${{ github.repository_owner }}/plotter-server

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: paint-app/server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: paint-app/server/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test

build:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4

# The Pi is arm64 and these runners are amd64. Emulation is slow but the
# only compile step is `tsc`; the native serialport binding comes down as
# a prebuilt arm64 binary rather than being built here.
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

# Skipped on pull_request: forks have no write access, and a PR should
# verify the image builds without publishing it.
- if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=match,pattern=plotter-server-v(.*),group=1
type=raw,value=edge,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/plotter-server-v') }}

- uses: docker/build-push-action@v6
with:
context: paint-app/server
platforms: linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
12 changes: 12 additions & 0 deletions paint-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ strokes drawn while connected get plotted.
(clipped to the page rectangle and translated to machine origin), with a
pause + audio chime + on-screen prompt between layers so you can swap pens.

## Network backend (`server/`)

Web Serial means the browser has to be on the machine holding the USB cable —
one laptop, tethered, for the whole print. [`server/`](server/README.md) is a
Node backend that owns the port instead, so the plotter can live on a Raspberry
Pi and be driven from anywhere on the network.

It is not wired into this app yet. The client still talks Web Serial and is
unchanged; swapping it over is a follow-up. See
[`server/README.md`](server/README.md) for the protocol and the deployment
notes.

## Hardware notes

See [`experiments/README.md`](experiments/README.md) for the CH340 driver
Expand Down
6 changes: 6 additions & 0 deletions paint-app/server/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
*.log
.DS_Store
README.md
docker-compose.example.yml
3 changes: 3 additions & 0 deletions paint-app/server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
*.log
50 changes: 50 additions & 0 deletions paint-app/server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Debian, not Alpine, on purpose.
#
# `serialport` is a native addon. Its prebuilt binaries are glibc; on musl there
# is no prebuild, so `npm ci` falls back to compiling from source and the image
# needs a whole toolchain — under QEMU that turns a 30-second build into a very
# long one, for a runtime that then behaves subtly differently. bookworm-slim is
# a few MB larger and just works.
FROM node:22-bookworm-slim AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build

# Reinstall with dev dependencies pruned. `npm ci --omit=dev` from scratch is
# what gets the right prebuild for the target arch into the runtime layer.
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production

# `SerialPort.list()` shells out to `udevadm` on Linux to read the USB
# descriptors it ranks ports by. Without it enumeration fails outright with
# "spawn udevadm ENOENT" — the server starts fine and then has no ports to
# offer, which is a confusing way to find out.
RUN apt-get update \
&& apt-get install -y --no-install-recommends udev \
&& rm -rf /var/lib/apt/lists/*

COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

# The node user needs to be in the group that owns the tty device, or opening
# it fails with EACCES. On Raspberry Pi OS that group is `dialout` (GID 20).
# Compose passes the real GID via `group_add:` — see docker-compose.example.yml.
USER node

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["node", "dist/index.js"]
Loading
Loading