diff --git a/.github/workflows/plotter-server-image.yml b/.github/workflows/plotter-server-image.yml new file mode 100644 index 0000000..1cbd57f --- /dev/null +++ b/.github/workflows/plotter-server-image.yml @@ -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 diff --git a/paint-app/README.md b/paint-app/README.md index 6fa6c1b..ccf6e44 100644 --- a/paint-app/README.md +++ b/paint-app/README.md @@ -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 diff --git a/paint-app/server/.dockerignore b/paint-app/server/.dockerignore new file mode 100644 index 0000000..0321711 --- /dev/null +++ b/paint-app/server/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.DS_Store +README.md +docker-compose.example.yml diff --git a/paint-app/server/.gitignore b/paint-app/server/.gitignore new file mode 100644 index 0000000..d4d7e3f --- /dev/null +++ b/paint-app/server/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.log diff --git a/paint-app/server/Dockerfile b/paint-app/server/Dockerfile new file mode 100644 index 0000000..a50b7cd --- /dev/null +++ b/paint-app/server/Dockerfile @@ -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"] diff --git a/paint-app/server/README.md b/paint-app/server/README.md new file mode 100644 index 0000000..04e729e --- /dev/null +++ b/paint-app/server/README.md @@ -0,0 +1,238 @@ +# plotter-server + +The serial backend for `paint-app`. It owns the USB link to the plotter so the +UI can be served from a Raspberry Pi that is physically wired to the machine, +instead of only from a laptop with the cable plugged into it. + +This is **backend and API only**. The React client still talks Web Serial and is +unchanged; swapping it over is a follow-up. + +## Why a backend at all + +Web Serial needs the browser to be on the machine holding the cable. That means +one laptop, tethered, for the duration of a print. Moving the port to a Pi means +the plotter is a network appliance: start a page from anywhere, walk away, and +swap pens from a phone. + +Almost all of the value is in `src/plotter.ts`, which is a port of +`paint-app/src/serial.ts` — a pile of Marlin-specific behaviour that was +expensive to discover and would have to be rediscovered from scratch in another +language: + +- Marlin resets when the port opens, so nothing it says for the first 2000ms + counts. +- `M115` is a liveness probe. A board that has been `M112`'d answers nothing at + all, and without the probe a connect marches on into `G28` and reports success + against a dead board twenty seconds later. +- `echo:busy: processing` is a keepalive, not a reply. Waits use an + **inactivity** timeout, never a deadline — `G28` takes as long as it takes. +- A port that reports itself already open gets closed and reopened after 300ms, + because the OS does not release the device synchronously. +- A failed open gets exactly one delayed retry; the second failure is a real + problem and says so. +- The port closing without anyone asking is device loss, and is distinct from a + disconnect. +- Pausing blocks before writing the *next* line. The line in flight finishes and + Marlin holds position. + +## Design + +**Jobs are uploaded whole; the send loop runs on the Pi.** Marlin's flow control +is lockstep — write a line, wait for `ok` — which is correct over USB and +disastrous over a network, where a page of art would cost thousands of round +trips. The client `POST`s the entire program once and then watches progress. + +**The pen swap is server state.** It used to be a `Promise` parked inside a +React component, which meant closing the tab orphaned a half-drawn page. The job +now moves `running -> awaiting_pen_swap -> running`, and any client can post the +continue. + +**Emergency stop has its own path.** `M112` is written straight to the port: no +control check, no write queue, no pause gate, and reachable over both WebSocket +and plain HTTP. Every other command waits its turn behind the one in flight, and +on a real board that wait is unbounded. See *Emergency stop* below. + +**Exactly one controller.** Electron got single-ownership by refusing to launch +twice; a network service can be reached by several browsers at once. One client +holds control and may move the machine, the rest are read-only, and handover is +explicit. The exception is e-stop, which anyone may fire. + +**Ports are enumerated server-side.** No picker, no user gesture, no CH340 +driver quirks — and `/dev/serial/by-id/*` gives a name that survives a replug, +which `/dev/ttyUSB0` does not. + +## Run it + +```sh +npm install +npm run dev # tsx watch, http://localhost:8080 +npm run build # tsc -> dist/ +npm start # node dist/index.js +npm test # vitest, against a simulated Marlin board +npm run typecheck +npm run check # biome lint + format, with writes +``` + +| Env | Default | Meaning | +| --- | --- | --- | +| `PORT` | `8080` | | +| `HOST` | `0.0.0.0` | | +| `CLIENT_DIR` | — | Serve a built renderer from here, so the Pi hands out UI and API on one origin. | +| `CORS_ORIGIN` | `*` | | +| `MAX_JOBS` | `20` | Uploaded jobs retained, oldest evicted. | +| `MAX_UPLOAD` | `64mb` | | + +### On the Pi + +```sh +docker compose up -d +``` + +See `docker-compose.example.yml`. Two things are easy to get wrong: + +- `devices:` grants the device node; it does **not** grant permission to open + it. The tty is owned by `root:dialout`, so the container's user needs that + group via `group_add:`. Check the host's GID with `getent group dialout`. + Getting it wrong surfaces as `EACCES` at connect time, not at startup. +- Bind-mount `/dev/serial/by-id` read-only as well, or the server can only offer + `/dev/ttyUSB0` — the name that moves. + +`npm run release -- 0.2.0` tags `plotter-server-v0.2.0`; the workflow builds +`linux/arm64` and publishes to GHCR. + +## Protocol + +WebSocket for the session, REST for the boring parts. Not REST-only: the `tx` +and `rx` log is thousands of lines per print and polling for it is absurd, and +jog and e-stop want a socket that is already open. Not WebSocket-only either: +uploading a job is a request with a response, and e-stop over plain HTTP is a +useful thing to have when the socket is the broken part. + +Every type below is exported from `src/protocol.ts`, which is dependency-free so +the client can import it verbatim. + +### REST + +| Method | Path | | +| --- | --- | --- | +| `GET` | `/api/health` | `{ ok, version, uptime }` | +| `GET` | `/api/ports` | `{ ports: PortInfo[] }`, likely plotters first | +| `GET` | `/api/state` | `Snapshot` — connection, session, active job | +| `GET` | `/api/jobs` | `{ jobs: JobSummary[] }` | +| `POST` | `/api/jobs` | Upload; `201 { job: JobSummary }` | +| `GET` | `/api/jobs/:id` | Summary; `?lines=1` includes the G-code | +| `DELETE` | `/api/jobs/:id` | `409` if it is the running job | +| `POST` | `/api/estop` | Fire `M112`. No control required. | + +A job upload is the output of the client's existing `buildLayerPrograms`, +`PROLOGUE` and `EPILOGUE` — G-code generation stays in the client, so there is +one implementation of it: + +```json +{ + "name": "page 1", + "plotterName": "Ender-3 V3 SE", + "prologue": ["G21", "G90", "G0 Z5.000 F3000"], + "layers": [ + { "name": "Ink", "color": "#000000", "lines": ["G0 X1 Y1", "G1 X2 Y2"] }, + { "name": "Red", "color": "#ff0000", "lines": ["G0 X3 Y3"] } + ], + "epilogue": ["G0 X0 Y0", "M84"] +} +``` + +One array entry must be one command — embedded newlines are rejected rather than +split, so a job that claims to be 400 lines is 400 lines and the progress count +means something. + +### WebSocket `/ws` + +Client to server. Any message may carry an `id`; if it does, the server replies +with a matching `ack`. + +| Type | Needs control | | +| --- | --- | --- | +| `ping` | no | | +| `identify` | no | `{ name }`, for the client list | +| `log.subscribe` | no | `{ kinds }` — `tx`/`rx` are opt-in, default is `info`+`err` | +| `control.claim` | no | Fails if someone holds it | +| `control.release` | no | | +| `control.takeover` | no | Unconditional; the previous holder is told at once | +| `estop` | **no** | | +| `connect` | yes | `{ portPath }` from `/api/ports` | +| `disconnect` | yes | Refused while a job runs | +| `job.start` | yes | `{ jobId }` | +| `job.continue` | yes | Pen swapped, carry on | +| `job.pause` / `job.resume` | yes | Gates before the next line | +| `job.cancel` | yes | | +| `jog` | yes | `{ lines }`, allowlisted, refused mid-layer | +| `position` | yes | `M114`, parsed | + +Server to client: + +| Type | | +| --- | --- | +| `hello` | `clientId`, `serverVersion`, a full `Snapshot`, and recent `info`/`err` lines | +| `log` | Batched `LogLine[]`, filtered to the kinds you subscribed to | +| `connection` | `ConnectionStatus` | +| `session` | `SessionStatus` — who is connected, who holds control | +| `job` | `JobStatus` or null | +| `lost` | The port dropped without anyone asking | +| `ack` | `{ id, ok, error?, data? }` | +| `pong` | | + +This preserves the browser callback surface conceptually: `log(line, kind)` +becomes the `log` frame, `onPhase` and `onPauseChange` fold into `connection`, +and `onLost` becomes `lost`. + +### Job lifecycle + +``` +queued -> running -> awaiting_pen_swap -> running -> done + | | + +-> paused +-> cancelled + | + +-> failed (device lost, emergency stop, unresponsive board) +``` + +`paused` is derived, not stored: the pause lives on the connection so it gates +every source of G-code, and a job parked at a pen swap is not additionally +paused. + +### Emergency stop + +The one genuine safety property here, so it is worth being explicit about what +is guaranteed and what is not. + +Guaranteed by this code: `M112` is written to the port without waiting on the +control lock, the write queue, the pause gate, or the `ok` of whatever is in +flight. It is reachable from any client, controller or not, over the socket or +over `POST /api/estop`. Everything waiting on a reply is failed immediately +rather than left to time out, and the running job is failed rather than drained. +`src/plotter.test.ts` asserts this by ordering and by wall clock, and the tests +fail if the bypass is removed. + +Not guaranteed by this code: that the stop reaches the board. It travels over +your network, through this process, over USB, and relies on Marlin's +`EMERGENCY_PARSER` being compiled in — which stock Ender-3 V3 SE firmware does +have, and which `M115` reports as `Cap:EMERGENCY_PARSER:1`. **This is not a +substitute for the power switch.** A software stop that depends on a Wi-Fi link +is a convenience; the thing that actually stops the machine is the one you can +reach with your hand. + +## Layout + +``` +src/ + protocol.ts # the wire contract, dependency-free, shared with the client + plotter.ts # PlotterConnection — the Marlin port from serial.ts + transport.ts # the byte pipe: serialport in prod, a fake in tests + ports.ts # /dev/serial/by-id enumeration and ranking + jobs.ts # job store, validation, and the send loop with the pen swap + hub.ts # the single owner: port, job, clients, control lock, log fan-out + server.ts # express routes + websocket wiring + config.ts + index.ts # entry, graceful shutdown + test/ + fakeMarlin.ts # a scripted board: ok, banners, echo:busy, unplug, M112 +``` diff --git a/paint-app/server/biome.json b/paint-app/server/biome.json new file mode 100644 index 0000000..d7b5e6f --- /dev/null +++ b/paint-app/server/biome.json @@ -0,0 +1,22 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { "ignoreUnknown": false }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { "recommended": true } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all" + } + } +} diff --git a/paint-app/server/docker-compose.example.yml b/paint-app/server/docker-compose.example.yml new file mode 100644 index 0000000..fcdd665 --- /dev/null +++ b/paint-app/server/docker-compose.example.yml @@ -0,0 +1,33 @@ +# Copy to docker-compose.yml on the Pi and adjust the device path. +# +# docker compose up -d +# +services: + plotter-server: + image: ghcr.io/travisbumgarner/plotter-server:latest + restart: unless-stopped + ports: + - "8080:8080" + environment: + PORT: "8080" + # Point at a mounted renderer build to serve the UI from the same origin. + # CLIENT_DIR: /client + # CORS_ORIGIN: "http://plotter.local:5173" + + # The container gets the device node itself... + devices: + - /dev/ttyUSB0:/dev/ttyUSB0 + + # ...and a read-only view of the by-id directory, so `GET /api/ports` + # returns the stable name rather than a ttyUSB number that moves on replug. + # Bind-mounting it does NOT create the device; `devices:` above does that. + volumes: + - /dev/serial/by-id:/dev/serial/by-id:ro + # - ../dist:/client:ro + + # `devices:` grants the node, not permission to open it. The tty is owned by + # root:dialout, so the container's user has to be in that group. Check the + # host's GID with `getent group dialout` — 20 on Raspberry Pi OS, 18 on some + # others. Getting this wrong shows up as EACCES on connect, not at startup. + group_add: + - "20" diff --git a/paint-app/server/package-lock.json b/paint-app/server/package-lock.json new file mode 100644 index 0000000..23b7552 --- /dev/null +++ b/paint-app/server/package-lock.json @@ -0,0 +1,3121 @@ +{ + "name": "plotter-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plotter-server", + "version": "0.1.0", + "dependencies": { + "express": "^5.1.0", + "serialport": "^13.0.0", + "ws": "^8.18.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.14", + "@types/express": "^5.0.3", + "@types/node": "^24.10.1", + "@types/ws": "^8.18.1", + "tsx": "^4.20.6", + "typescript": "^6.0.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz", + "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.6", + "@biomejs/cli-darwin-x64": "2.5.6", + "@biomejs/cli-linux-arm64": "2.5.6", + "@biomejs/cli-linux-arm64-musl": "2.5.6", + "@biomejs/cli-linux-x64": "2.5.6", + "@biomejs/cli-linux-x64-musl": "2.5.6", + "@biomejs/cli-win32-arm64": "2.5.6", + "@biomejs/cli-win32-x64": "2.5.6" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz", + "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz", + "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz", + "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz", + "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz", + "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz", + "integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "12.0.0", + "debug": "4.4.0", + "node-addon-api": "8.3.0", + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz", + "integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz", + "integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz", + "integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz", + "integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz", + "integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz", + "integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz", + "integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz", + "integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz", + "integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz", + "integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz", + "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", + "integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "13.0.0", + "@serialport/parser-byte-length": "13.0.0", + "@serialport/parser-cctalk": "13.0.0", + "@serialport/parser-delimiter": "13.0.0", + "@serialport/parser-inter-byte-timeout": "13.0.0", + "@serialport/parser-packet-length": "13.0.0", + "@serialport/parser-readline": "13.0.0", + "@serialport/parser-ready": "13.0.0", + "@serialport/parser-regex": "13.0.0", + "@serialport/parser-slip-encoder": "13.0.0", + "@serialport/parser-spacepacket": "13.0.0", + "@serialport/stream": "13.0.0", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/serialport/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/paint-app/server/package.json b/paint-app/server/package.json new file mode 100644 index 0000000..9e3cae9 --- /dev/null +++ b/paint-app/server/package.json @@ -0,0 +1,38 @@ +{ + "name": "plotter-server", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Serial backend for paint-app: owns the USB link to the plotter so the UI can be served from a Raspberry Pi.", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json", + "lint": "biome check src scripts", + "format": "biome format --write src scripts", + "check": "biome check --write src scripts", + "docker:build": "docker buildx build --platform linux/arm64 -t plotter-server:local .", + "release": "node scripts/release.mjs" + }, + "dependencies": { + "express": "^5.1.0", + "serialport": "^13.0.0", + "ws": "^8.18.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.14", + "@types/express": "^5.0.3", + "@types/node": "^24.10.1", + "@types/ws": "^8.18.1", + "tsx": "^4.20.6", + "typescript": "^6.0.3", + "vitest": "^3.2.4" + } +} diff --git a/paint-app/server/scripts/release.mjs b/paint-app/server/scripts/release.mjs new file mode 100644 index 0000000..f212846 --- /dev/null +++ b/paint-app/server/scripts/release.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Cut a release of the plotter server. + * + * npm run release -- 0.2.0 + * + * Bumps package.json, commits, and pushes a `plotter-server-v*` tag. The tag is + * what the GHCR workflow watches; the prefix keeps server releases from + * colliding with any other versioning in this repo. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const pkgPath = path.join(dir, '..', 'package.json'); + +const run = (cmd, args) => + execFileSync(cmd, args, { stdio: 'inherit', cwd: path.dirname(pkgPath) }); +const capture = (cmd, args) => + execFileSync(cmd, args, { encoding: 'utf8', cwd: path.dirname(pkgPath) }).trim(); + +const version = process.argv[2]; +if (!/^\d+\.\d+\.\d+$/.test(version ?? '')) { + console.error('Usage: npm run release -- '); + process.exit(1); +} + +if (capture('git', ['status', '--porcelain'])) { + console.error('Working tree is dirty. Commit or stash first.'); + process.exit(1); +} + +const tag = `plotter-server-v${version}`; +if (capture('git', ['tag', '--list', tag])) { + console.error(`Tag ${tag} already exists.`); + process.exit(1); +} + +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); +pkg.version = version; +writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + +run('git', ['add', pkgPath]); +run('git', ['commit', '-m', `plotter-server: release ${version}`]); +run('git', ['tag', '-a', tag, '-m', `plotter-server ${version}`]); +run('git', ['push', 'origin', 'HEAD', tag]); + +console.log( + `\nPushed ${tag}. GHCR build: https://github.com/TravisBumgarner/gcode2dplotterart/actions`, +); diff --git a/paint-app/server/src/config.ts b/paint-app/server/src/config.ts new file mode 100644 index 0000000..bf8bced --- /dev/null +++ b/paint-app/server/src/config.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +const schema = z.object({ + /** 0 asks the OS for a free port, which is how the tests avoid collisions. */ + PORT: z.coerce.number().int().min(0).max(65535).default(8080), + HOST: z.string().default('0.0.0.0'), + /** + * Where to serve the built paint-app renderer from, so the Pi hands out both + * the UI and the API on one origin. Unset means API only. + */ + CLIENT_DIR: z.string().optional(), + /** + * Defaults to `*`: this is a device on your LAN wired to a machine anyone + * standing next to it can already unplug. Set it if the Pi is exposed. + */ + CORS_ORIGIN: z.string().default('*'), + MAX_JOBS: z.coerce.number().int().positive().default(20), + /** Biggest job upload accepted, as an Express body-parser size. */ + MAX_UPLOAD: z.string().default('64mb'), +}); + +export type Config = z.infer; + +export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => { + const parsed = schema.safeParse(env); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Invalid configuration:\n${issues}`); + } + return parsed.data; +}; diff --git a/paint-app/server/src/hub.test.ts b/paint-app/server/src/hub.test.ts new file mode 100644 index 0000000..d3a7410 --- /dev/null +++ b/paint-app/server/src/hub.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from 'vitest'; +import { Hub } from './hub.js'; +import type { ServerMessage } from './protocol.js'; +import { FAST_TIMING, FakeMarlin } from './test/fakeMarlin.js'; + +const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms)); +const waitFor = async (pred: () => boolean, ms = 1000) => { + const deadline = Date.now() + ms; + while (!pred()) { + if (Date.now() > deadline) throw new Error('timed out waiting for condition'); + await tick(2); + } +}; + +const setup = (board = new FakeMarlin()) => { + const hub = new Hub({ connection: { transport: board.transport, timing: FAST_TIMING } }); + const join = () => { + const messages: ServerMessage[] = []; + const client = hub.addClient((m) => messages.push(m)); + return { id: client.id, messages }; + }; + return { hub, board, join }; +}; + +const sampleJob = { + name: 'page 1', + plotterName: 'Ender-3 V3 SE', + prologue: ['G21', 'G90'], + layers: [ + { name: 'Ink', color: '#000000', lines: ['G0 X1 Y1', 'G1 X2 Y2'] }, + { name: 'Red', color: '#ff0000', lines: ['G0 X3 Y3', 'G1 X4 Y4'] }, + ], + epilogue: ['M84'], +}; + +describe('session ownership', () => { + it('gives control to the first client and read-only to the rest', async () => { + const { hub, join } = setup(); + const a = join(); + const b = join(); + + expect(hub.controller).toBe(a.id); + const denied = await hub.handle(b.id, { type: 'connect', portPath: '/dev/fake' }); + expect(denied.ok).toBe(false); + expect(denied.error).toMatch(/Read-only/); + + const allowed = await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + expect(allowed.ok).toBe(true); + hub.dispose(); + }); + + it('refuses a claim while someone holds control, but allows a takeover', async () => { + const { hub, join } = setup(); + const a = join(); + const b = join(); + + expect((await hub.handle(b.id, { type: 'control.claim' })).ok).toBe(false); + expect((await hub.handle(b.id, { type: 'control.takeover' })).ok).toBe(true); + expect(hub.controller).toBe(b.id); + // The displaced client is told immediately rather than discovering it on + // its next failed command. + const last = a.messages.filter((m) => m.type === 'session').at(-1); + expect(last).toMatchObject({ session: { controllerId: b.id } }); + hub.dispose(); + }); + + it('hands control on when the controlling client goes away', async () => { + // A closed tab must not be able to strand the machine. + const { hub, join } = setup(); + const a = join(); + const b = join(); + + hub.removeClient(a.id); + expect(hub.controller).toBe(b.id); + hub.dispose(); + }); + + it('leaves control vacant when the last client leaves', () => { + const { hub, join } = setup(); + const a = join(); + hub.removeClient(a.id); + expect(hub.controller).toBeNull(); + hub.dispose(); + }); +}); + +describe('emergency stop', () => { + it('is available to a read-only client', async () => { + // A safety control you have to ask permission to use is not one. + const board = new FakeMarlin(); + const { hub, join } = setup(board); + const a = join(); + const b = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + board.writes.length = 0; + + const result = await hub.handle(b.id, { type: 'estop' }); + expect(result.ok).toBe(true); + expect(board.written).toContain('M112'); + hub.dispose(); + }); + + it('jumps a running job and fails it', async () => { + const board = new FakeMarlin({ replyDelayMs: 5 }); + const { hub, join } = setup(board); + const a = join(); + const b = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + + const upload = hub.jobs.add({ + ...sampleJob, + layers: [ + { name: 'Ink', color: '#000', lines: Array.from({ length: 200 }, (_, i) => `G1 X${i}`) }, + ], + epilogue: [], + }); + board.writes.length = 0; + await hub.handle(a.id, { type: 'job.start', jobId: upload.id }); + await tick(15); + + await hub.handle(b.id, { type: 'estop' }); + await hub.runner.settled(); + + expect(board.written).toContain('M112'); + expect(board.writtenAfter('M112')).toEqual([]); + expect(board.written.indexOf('M112')).toBeLessThan(20); + expect(hub.runner.view?.state).toBe('failed'); + expect(hub.connection.connected).toBe(false); + hub.dispose(); + }); +}); + +describe('jobs over the hub', () => { + it('runs an uploaded job through its pen swap', async () => { + const board = new FakeMarlin(); + const { hub, join } = setup(board); + const a = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + board.writes.length = 0; + + const job = hub.jobs.add(sampleJob); + expect((await hub.handle(a.id, { type: 'job.start', jobId: job.id })).ok).toBe(true); + await waitFor(() => hub.runner.view?.state === 'awaiting_pen_swap'); + + expect((await hub.handle(a.id, { type: 'job.continue' })).ok).toBe(true); + await hub.runner.settled(); + + expect(hub.runner.view?.state).toBe('done'); + expect(board.written).toEqual([ + 'G21', + 'G90', + 'G0 X1 Y1', + 'G1 X2 Y2', + 'G0 X3 Y3', + 'G1 X4 Y4', + 'M84', + ]); + hub.dispose(); + }); + + it('rejects an unknown job id', async () => { + const { hub, join } = setup(); + const a = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + const result = await hub.handle(a.id, { type: 'job.start', jobId: 'nope' }); + expect(result.ok).toBe(false); + hub.dispose(); + }); + + it('refuses to disconnect out from under a running job', async () => { + const board = new FakeMarlin({ replyDelayMs: 5 }); + const { hub, join } = setup(board); + const a = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + const job = hub.jobs.add({ + ...sampleJob, + layers: [ + { name: 'Ink', color: '#000', lines: Array.from({ length: 60 }, (_, i) => `G1 X${i}`) }, + ], + }); + await hub.handle(a.id, { type: 'job.start', jobId: job.id }); + + const result = await hub.handle(a.id, { type: 'disconnect' }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/Cancel the running job/); + + await hub.handle(a.id, { type: 'job.cancel' }); + await hub.runner.settled(); + hub.dispose(); + }); +}); + +describe('jog', () => { + it('rejects anything outside the movement allowlist', async () => { + const { hub, join } = setup(); + const a = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + + const result = await hub.handle(a.id, { type: 'jog', lines: ['M500'] }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/Not allowed/); + hub.dispose(); + }); + + it('refuses to interleave with a running job', async () => { + const board = new FakeMarlin({ replyDelayMs: 5 }); + const { hub, join } = setup(board); + const a = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + const job = hub.jobs.add({ + ...sampleJob, + layers: [ + { name: 'Ink', color: '#000', lines: Array.from({ length: 60 }, (_, i) => `G1 X${i}`) }, + ], + }); + await hub.handle(a.id, { type: 'job.start', jobId: job.id }); + + const result = await hub.handle(a.id, { type: 'jog', lines: ['G91', 'G0 X10'] }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/while a job is running/); + + await hub.handle(a.id, { type: 'job.cancel' }); + await hub.runner.settled(); + hub.dispose(); + }); + + it('is allowed at a pen swap', async () => { + const board = new FakeMarlin(); + const { hub, join } = setup(board); + const a = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + const job = hub.jobs.add(sampleJob); + await hub.handle(a.id, { type: 'job.start', jobId: job.id }); + await waitFor(() => hub.runner.view?.state === 'awaiting_pen_swap'); + board.writes.length = 0; + + expect((await hub.handle(a.id, { type: 'jog', lines: ['G91', 'G0 Z5'] })).ok).toBe(true); + expect(board.written).toEqual(['G91', 'G0 Z5']); + + await hub.handle(a.id, { type: 'job.cancel' }); + await hub.runner.settled(); + hub.dispose(); + }); +}); + +describe('log fan-out', () => { + it('only sends the kinds a client subscribed to', async () => { + const board = new FakeMarlin(); + const { hub, join } = setup(board); + const a = join(); + const quiet = join(); + await hub.handle(a.id, { type: 'log.subscribe', kinds: ['tx', 'rx', 'info', 'err'] }); + await hub.handle(quiet.id, { type: 'log.subscribe', kinds: ['err'] }); + + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + await tick(80); + + const kindsSeen = (msgs: ServerMessage[]) => + new Set(msgs.flatMap((m) => (m.type === 'log' ? m.lines.map((l) => l.kind) : []))); + expect(kindsSeen(a.messages)).toContain('tx'); + expect(kindsSeen(quiet.messages).has('tx')).toBe(false); + expect(kindsSeen(quiet.messages).has('info')).toBe(false); + hub.dispose(); + }); + + it('hands a joining client the recent info log', async () => { + const { hub, join } = setup(); + const a = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + + const late = join(); + const hello = late.messages.find((m) => m.type === 'hello'); + expect(hello?.type === 'hello' && hello.recentLog.length).toBeGreaterThan(0); + hub.dispose(); + }); +}); + +describe('device loss', () => { + it('fails the job and tells every client', async () => { + const board = new FakeMarlin({ replyDelayMs: 5 }); + const { hub, join } = setup(board); + const a = join(); + const b = join(); + await hub.handle(a.id, { type: 'connect', portPath: '/dev/fake' }); + const job = hub.jobs.add({ + ...sampleJob, + layers: [ + { name: 'Ink', color: '#000', lines: Array.from({ length: 60 }, (_, i) => `G1 X${i}`) }, + ], + }); + await hub.handle(a.id, { type: 'job.start', jobId: job.id }); + await tick(15); + + board.unplug(); + await hub.runner.settled(); + + expect(b.messages.some((m) => m.type === 'lost')).toBe(true); + expect(hub.runner.view?.state).toBe('failed'); + expect(hub.snapshot().connection.connected).toBe(false); + hub.dispose(); + }); +}); diff --git a/paint-app/server/src/hub.ts b/paint-app/server/src/hub.ts new file mode 100644 index 0000000..94bffbc --- /dev/null +++ b/paint-app/server/src/hub.ts @@ -0,0 +1,382 @@ +import { randomUUID } from 'node:crypto'; +import { JobRunner, JobStore } from './jobs.js'; +import { PlotterConnection, type PlotterConnectionOptions } from './plotter.js'; +import type { + ClientInfo, + ClientMessage, + ConnectionStatus, + LogKind, + LogLine, + ServerMessage, + SessionStatus, + Snapshot, +} from './protocol.js'; + +/** How long log lines are held before going out as one frame. */ +const LOG_FLUSH_MS = 50; +/** Ceiling on a single log frame; a print outruns any socket otherwise. */ +const LOG_BATCH_MAX = 500; +/** Context handed to a client that joins mid-session. */ +const RECENT_LOG_MAX = 200; + +/** + * G-code a client may send outside a job. Deliberately narrow: this is the + * "type anything at the machine" hole, and jogging plus a few status queries is + * all the UI actually needs. Anything structural belongs in a job. + */ +const JOG_ALLOWED = /^(G(0|1|4|21|28|90|91|92)|M(17|18|84|105|114|115|400))\b/i; +const MAX_JOG_LINES = 32; + +export type Client = { + id: string; + name: string; + since: number; + logKinds: Set; + send: (msg: ServerMessage) => void; +}; + +export type HubOptions = { + serverVersion?: string; + maxJobs?: number; + /** Passed through to `PlotterConnection`; tests shrink the timings. */ + connection?: Pick; +}; + +export type CommandResult = { ok: boolean; error?: string; data?: unknown }; + +/** + * The single owner of the serial port, the job runner, and the connected + * clients. + * + * Electron got single-ownership for free by refusing to launch twice. Over a + * network any number of browsers can reach the Pi at once, so ownership is + * explicit: exactly one client holds control and may move the machine; the rest + * watch. Control is not a security boundary — anyone who can reach the port can + * take it — it exists to stop two well-meaning people from interleaving G-code. + * + * The one exception is the emergency stop, which any client may fire at any + * time. A safety control you have to request permission to use is not one. + */ +export class Hub { + readonly connection: PlotterConnection; + readonly runner: JobRunner; + readonly jobs: JobStore; + + private clients = new Map(); + private controllerId: string | null = null; + private pendingLog: LogLine[] = []; + private recentLog: LogLine[] = []; + private flushTimer: NodeJS.Timeout | null = null; + private droppedLogLines = 0; + private readonly serverVersion: string; + + constructor(opts: HubOptions = {}) { + this.serverVersion = opts.serverVersion ?? '0.0.0'; + this.jobs = new JobStore(opts.maxJobs); + this.connection = new PlotterConnection({ + ...opts.connection, + log: (line, kind) => this.pushLog(line, kind), + onPhase: () => this.broadcastConnection(), + onLost: () => { + // The board went away mid-print. Fail the job now rather than letting + // it grind through the remaining lines against a dead port. + this.runner.abort('connection lost'); + this.broadcast({ type: 'lost' }); + this.broadcastConnection(); + }, + onPauseChange: () => { + this.runner.notify(); + this.broadcastConnection(); + }, + }); + this.runner = new JobRunner(this.connection, (status) => + this.broadcast({ type: 'job', job: status }), + ); + } + + // ---------------------------------------------------------------- clients + + addClient(send: (msg: ServerMessage) => void): Client { + const client: Client = { + id: randomUUID(), + name: 'anonymous', + since: Date.now(), + // tx/rx are opt-in; a print emits thousands of them and most viewers + // only care that something is happening. + logKinds: new Set(['info', 'err']), + send, + }; + this.clients.set(client.id, client); + // First one in gets control, so the common single-user case needs no + // ceremony. Anyone joining a live session starts read-only. + if (!this.controllerId) this.controllerId = client.id; + client.send({ + type: 'hello', + clientId: client.id, + serverVersion: this.serverVersion, + snapshot: this.snapshot(), + recentLog: [...this.recentLog], + }); + this.broadcastSession(); + return client; + } + + removeClient(id: string) { + if (!this.clients.delete(id)) return; + // A closed tab shouldn't be able to strand the machine. Control goes back + // to the pool; the job carries on, because the job is ours, not the tab's. + if (this.controllerId === id) { + this.controllerId = this.clients.keys().next().value ?? null; + } + this.broadcastSession(); + } + + get controller() { + return this.controllerId; + } + + // ---------------------------------------------------------------- status + + connectionStatus(): ConnectionStatus { + return { + connected: this.connection.connected, + connecting: this.connection.currentPhase !== null, + phase: this.connection.currentPhase, + portPath: this.connection.path, + paused: this.connection.isPaused, + emergencyStopped: this.connection.isEmergencyStopped, + }; + } + + sessionStatus(): SessionStatus { + const clients: ClientInfo[] = [...this.clients.values()].map((c) => ({ + id: c.id, + name: c.name, + controller: c.id === this.controllerId, + since: c.since, + })); + return { controllerId: this.controllerId, clients }; + } + + snapshot(): Snapshot { + return { + connection: this.connectionStatus(), + session: this.sessionStatus(), + activeJob: this.runner.view, + }; + } + + // ------------------------------------------------------------------- log + + private pushLog(line: string, kind: LogKind) { + const entry: LogLine = { t: Date.now(), kind, line }; + if (kind === 'info' || kind === 'err') { + this.recentLog.push(entry); + if (this.recentLog.length > RECENT_LOG_MAX) this.recentLog.shift(); + } + if (this.pendingLog.length >= LOG_BATCH_MAX) { + this.droppedLogLines++; + return; + } + this.pendingLog.push(entry); + if (this.flushTimer) return; + this.flushTimer = setTimeout(() => this.flushLog(), LOG_FLUSH_MS); + } + + private flushLog() { + this.flushTimer = null; + const batch = this.pendingLog; + this.pendingLog = []; + if (this.droppedLogLines > 0) { + batch.push({ + t: Date.now(), + kind: 'info', + line: `… ${this.droppedLogLines} log line(s) dropped (client too slow)`, + }); + this.droppedLogLines = 0; + } + if (batch.length === 0) return; + for (const client of this.clients.values()) { + const lines = batch.filter((l) => client.logKinds.has(l.kind)); + if (lines.length > 0) client.send({ type: 'log', lines }); + } + } + + /** Stop the flush timer so a test or a shutdown doesn't hang on it. */ + dispose() { + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + // ------------------------------------------------------------- broadcast + + broadcast(msg: ServerMessage) { + for (const client of this.clients.values()) client.send(msg); + } + + private broadcastConnection() { + this.broadcast({ type: 'connection', connection: this.connectionStatus() }); + } + + private broadcastSession() { + this.broadcast({ type: 'session', session: this.sessionStatus() }); + } + + // -------------------------------------------------------------- commands + + private requireControl(clientId: string) { + if (this.controllerId !== clientId) { + throw new Error('Read-only: another client is controlling this plotter. Take control first.'); + } + } + + /** + * Emergency stop. Not gated on control, not queued behind the job, and + * reachable over both WebSocket and REST so a wedged socket can't be the + * thing standing between a person and a stopped machine. + */ + async emergencyStop() { + await this.connection.emergencyStop(); + this.runner.abort('emergency stop'); + // M112 kills the firmware; the port is useless until the board is + // power-cycled. Tear down so nothing shows a live connection. + await this.connection.disconnect().catch(() => {}); + this.broadcastConnection(); + } + + async handle(clientId: string, msg: ClientMessage): Promise { + const client = this.clients.get(clientId); + if (!client) return { ok: false, error: 'Unknown client' }; + try { + const data = await this.dispatch(client, msg); + return { ok: true, data }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } + + private async dispatch(client: Client, msg: ClientMessage): Promise { + switch (msg.type) { + case 'ping': + return undefined; + + case 'identify': + client.name = String(msg.name ?? '').slice(0, 64) || 'anonymous'; + this.broadcastSession(); + return undefined; + + case 'log.subscribe': { + const kinds = Array.isArray(msg.kinds) ? msg.kinds : []; + client.logKinds = new Set( + kinds.filter((k): k is LogKind => ['tx', 'rx', 'info', 'err'].includes(k)), + ); + return undefined; + } + + case 'control.claim': + if (this.controllerId && this.controllerId !== client.id) { + throw new Error('Another client holds control. Use takeover to force it.'); + } + this.controllerId = client.id; + this.broadcastSession(); + return undefined; + + case 'control.release': + if (this.controllerId !== client.id) return undefined; + this.controllerId = [...this.clients.keys()].find((id) => id !== client.id) ?? null; + this.broadcastSession(); + return undefined; + + case 'control.takeover': + // Deliberately unconditional. The alternative is a stuck session with + // no way out, and the previous holder is told immediately. + this.controllerId = client.id; + this.broadcastSession(); + return undefined; + + case 'estop': + await this.emergencyStop(); + return undefined; + + case 'connect': { + this.requireControl(client.id); + if (typeof msg.portPath !== 'string' || !msg.portPath) { + throw new Error('portPath is required'); + } + await this.connection.connect(msg.portPath); + this.broadcastConnection(); + return undefined; + } + + case 'disconnect': + this.requireControl(client.id); + if (this.runner.isActive) throw new Error('Cancel the running job first.'); + await this.connection.disconnect(); + this.broadcastConnection(); + return undefined; + + case 'job.start': { + this.requireControl(client.id); + const job = this.jobs.get(msg.jobId); + if (!job) throw new Error('No such job. Upload it first.'); + return this.runner.start(job); + } + + case 'job.continue': + this.requireControl(client.id); + this.runner.continue(); + return undefined; + + case 'job.pause': + this.requireControl(client.id); + this.connection.pause(); + return undefined; + + case 'job.resume': + this.requireControl(client.id); + this.connection.resume(); + return undefined; + + case 'job.cancel': + this.requireControl(client.id); + this.runner.cancel(); + return undefined; + + case 'jog': { + this.requireControl(client.id); + // Jogging while a job is mid-layer would splice movement into the + // drawing. At a pen swap, or paused, it is exactly what you want. + if (this.runner.view?.state === 'running') { + throw new Error('Cannot jog while a job is running. Pause it first.'); + } + const lines = Array.isArray(msg.lines) ? msg.lines : []; + if (lines.length === 0 || lines.length > MAX_JOG_LINES) { + throw new Error(`Send between 1 and ${MAX_JOG_LINES} lines.`); + } + for (const line of lines) { + if (typeof line !== 'string' || /[\r\n]/.test(line)) { + throw new Error('Invalid G-code line.'); + } + if (!JOG_ALLOWED.test(line.trim())) { + throw new Error(`Not allowed outside a job: ${line}`); + } + } + await this.connection.sendMany(lines, { bypassPause: true }); + return undefined; + } + + case 'position': { + this.requireControl(client.id); + if (this.runner.view?.state === 'running') { + throw new Error('Cannot query position while a job is running.'); + } + return this.connection.getPosition(); + } + + default: { + const unknown = msg as { type?: string }; + throw new Error(`Unknown message type: ${unknown.type}`); + } + } + } +} diff --git a/paint-app/server/src/index.ts b/paint-app/server/src/index.ts new file mode 100644 index 0000000..84ed28b --- /dev/null +++ b/paint-app/server/src/index.ts @@ -0,0 +1,41 @@ +import { createServer } from 'node:http'; +import { loadConfig } from './config.js'; +import { Hub } from './hub.js'; +import { attachWebSocket, buildApp } from './server.js'; + +const VERSION = process.env.npm_package_version ?? '0.1.0'; + +const config = loadConfig(); +const hub = new Hub({ serverVersion: VERSION, maxJobs: config.MAX_JOBS }); +const app = buildApp({ hub, config, version: VERSION }); +const server = createServer(app); +attachWebSocket(server, hub); + +server.listen(config.PORT, config.HOST, () => { + console.log(`plotter-server ${VERSION} listening on http://${config.HOST}:${config.PORT}`); + if (config.CLIENT_DIR) console.log(`serving client from ${config.CLIENT_DIR}`); +}); + +/** + * A container restart while the pen is down leaves ink on the page. Stopping + * cleanly is the difference between a paused plot and a ruined one — so lift + * the pen, drop the steppers, and let go of the port before exiting. + */ +let shuttingDown = false; +const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`${signal} — shutting down`); + hub.dispose(); + if (hub.runner.isActive) hub.runner.abort('server shutting down'); + if (hub.connection.connected) { + await hub.connection.sendMany(['G91', 'G0 Z5', 'G90', 'M84']).catch(() => {}); + await hub.connection.disconnect().catch(() => {}); + } + server.close(() => process.exit(0)); + // Don't let a half-open socket hold the process past a container's grace. + setTimeout(() => process.exit(0), 5000).unref(); +}; + +process.on('SIGTERM', () => void shutdown('SIGTERM')); +process.on('SIGINT', () => void shutdown('SIGINT')); diff --git a/paint-app/server/src/jobs.test.ts b/paint-app/server/src/jobs.test.ts new file mode 100644 index 0000000..b44f450 --- /dev/null +++ b/paint-app/server/src/jobs.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { type Job, JobRunner, JobStore, jobUploadSchema } from './jobs.js'; +import { PlotterConnection } from './plotter.js'; +import type { JobStatus } from './protocol.js'; +import { FAST_TIMING, FakeMarlin } from './test/fakeMarlin.js'; + +const makeJob = (overrides: Partial = {}): Job => ({ + id: 'job-1', + name: 'page 1', + plotterName: 'Ender-3 V3 SE', + createdAt: Date.now(), + prologue: ['G21', 'G90'], + layers: [ + { name: 'Ink', color: '#000000', lines: ['G0 X1 Y1', 'G1 X2 Y2'] }, + { name: 'Red', color: '#ff0000', lines: ['G0 X3 Y3', 'G1 X4 Y4'] }, + ], + epilogue: ['M84'], + ...overrides, +}); + +const setup = async (board = new FakeMarlin()) => { + const conn = new PlotterConnection({ transport: board.transport, timing: FAST_TIMING }); + await conn.connect('/dev/fake'); + board.writes.length = 0; + const states: JobStatus[] = []; + const runner = new JobRunner(conn, (s) => { + if (s) states.push(structuredClone(s)); + }); + return { board, conn, runner, states }; +}; + +const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms)); +const waitFor = async (pred: () => boolean, ms = 1000) => { + const deadline = Date.now() + ms; + while (!pred()) { + if (Date.now() > deadline) throw new Error('timed out waiting for condition'); + await tick(2); + } +}; + +describe('job upload validation', () => { + it('rejects a line carrying a newline', () => { + // One array entry must be one command, or the progress count is a lie and + // a "single line" can smuggle in a whole program. + const result = jobUploadSchema.safeParse({ + name: 'x', + plotterName: 'p', + layers: [{ name: 'a', color: '#000', lines: ['G0 X1\nM112'] }], + }); + expect(result.success).toBe(false); + }); + + it('requires at least one layer', () => { + expect(jobUploadSchema.safeParse({ name: 'x', plotterName: 'p', layers: [] }).success).toBe( + false, + ); + }); + + it('accepts a normal job and defaults the prologue/epilogue', () => { + const result = jobUploadSchema.safeParse({ + name: 'x', + plotterName: 'p', + layers: [{ name: 'a', color: '#000', lines: ['G0 X1'] }], + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.prologue).toEqual([]); + }); +}); + +describe('JobStore', () => { + it('evicts the oldest job past the cap', () => { + const store = new JobStore(2); + const base = { name: 'j', plotterName: 'p', prologue: [], epilogue: [] }; + const a = store.add({ ...base, layers: [{ name: 'a', color: '#000', lines: ['G0 X1'] }] }); + store.add({ ...base, layers: [{ name: 'b', color: '#000', lines: ['G0 X2'] }] }); + store.add({ ...base, layers: [{ name: 'c', color: '#000', lines: ['G0 X3'] }] }); + + expect(store.get(a.id)).toBeUndefined(); + expect(store.list()).toHaveLength(2); + }); +}); + +describe('running a job', () => { + it('sends prologue, layers and epilogue in order', async () => { + const { board, runner } = await setup(); + runner.start(makeJob()); + await waitFor(() => runner.view?.state === 'awaiting_pen_swap'); + runner.continue(); + await runner.settled(); + + expect(board.written).toEqual([ + 'G21', + 'G90', + 'G0 X1 Y1', + 'G1 X2 Y2', + 'G0 X3 Y3', + 'G1 X4 Y4', + 'M84', + ]); + expect(runner.view?.state).toBe('done'); + expect(runner.view?.progress?.sentLines).toBe(7); + }); + + it('parks between layers and names the pen to load', async () => { + // This is the flow that used to be a Promise held in a React component: + // closing the tab orphaned the print mid-page. It is server state now. + const { board, runner } = await setup(); + runner.start(makeJob()); + await waitFor(() => runner.view?.state === 'awaiting_pen_swap'); + + expect(runner.view?.nextLayer).toEqual({ name: 'Red', color: '#ff0000', lineCount: 2 }); + // Nothing from the second layer has gone out. + expect(board.written).toEqual(['G21', 'G90', 'G0 X1 Y1', 'G1 X2 Y2']); + + await tick(30); + expect(board.written).toHaveLength(4); + + runner.continue(); + await runner.settled(); + expect(runner.view?.state).toBe('done'); + }); + + it('refuses to continue when it is not waiting for a swap', async () => { + const { runner } = await setup(); + expect(() => runner.continue()).toThrow(/not waiting/i); + }); + + it('refuses a second job while one is active', async () => { + const { runner } = await setup(); + runner.start(makeJob()); + await waitFor(() => runner.view?.state === 'awaiting_pen_swap'); + expect(() => runner.start(makeJob())).toThrow(/already running/i); + runner.cancel(); + await runner.settled(); + }); + + it('refuses to start with no connection', () => { + const conn = new PlotterConnection({ transport: new FakeMarlin().transport }); + const runner = new JobRunner(conn); + expect(() => runner.start(makeJob())).toThrow(/Not connected/); + }); +}); + +describe('pause and cancel', () => { + it('reports paused and stops feeding lines', async () => { + const board = new FakeMarlin({ replyDelayMs: 3 }); + const { conn, runner } = await setup(board); + const job = makeJob({ + layers: [ + { + name: 'Ink', + color: '#000', + lines: Array.from({ length: 40 }, (_, i) => `G1 X${i}`), + }, + ], + epilogue: [], + }); + + runner.start(job); + await tick(15); + conn.pause(); + const atPause = board.written.length; + + expect(runner.view?.state).toBe('paused'); + await tick(40); + // The line already in flight finishes; nothing after it goes out. + expect(board.written.length).toBeLessThanOrEqual(atPause + 1); + + conn.resume(); + await runner.settled(); + expect(runner.view?.state).toBe('done'); + expect(board.written).toHaveLength(42); + }); + + it('stops on cancel without marking the job failed', async () => { + const board = new FakeMarlin({ replyDelayMs: 3 }); + const { runner } = await setup(board); + runner.start( + makeJob({ + layers: [ + { name: 'Ink', color: '#000', lines: Array.from({ length: 60 }, (_, i) => `G1 X${i}`) }, + ], + }), + ); + await tick(15); + runner.cancel(); + await runner.settled(); + + expect(runner.view?.state).toBe('cancelled'); + expect(runner.view?.error).toBeNull(); + expect(board.written.length).toBeLessThan(30); + }); + + it('cancels out of a pen swap', async () => { + const { runner } = await setup(); + runner.start(makeJob()); + await waitFor(() => runner.view?.state === 'awaiting_pen_swap'); + runner.cancel(); + await runner.settled(); + expect(runner.view?.state).toBe('cancelled'); + }); +}); + +describe('failure paths', () => { + it('fails the job when the plotter is unplugged mid-print', async () => { + const board = new FakeMarlin({ replyDelayMs: 3 }); + const { conn, runner } = await setup(board); + // Nothing else is watching in this test, so wire the abort by hand; the Hub + // does this via `onLost`. + runner.start( + makeJob({ + layers: [ + { name: 'Ink', color: '#000', lines: Array.from({ length: 60 }, (_, i) => `G1 X${i}`) }, + ], + }), + ); + await tick(15); + board.unplug(); + await tick(5); + runner.abort('connection lost'); + await runner.settled(); + + expect(runner.view?.state).toBe('failed'); + expect(conn.connected).toBe(false); + expect(board.written.length).toBeLessThan(30); + }); +}); diff --git a/paint-app/server/src/jobs.ts b/paint-app/server/src/jobs.ts new file mode 100644 index 0000000..d508add --- /dev/null +++ b/paint-app/server/src/jobs.ts @@ -0,0 +1,299 @@ +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import type { PlotterConnection } from './plotter.js'; +import type { JobLayer, JobLayerSummary, JobProgress, JobStatus, JobSummary } from './protocol.js'; + +/** + * A single G-code line. Newlines are rejected rather than split, so one entry + * in the array can never smuggle in extra commands: a job that claims to be + * 400 lines must be 400 lines, which is what the progress numbers rest on. + */ +const gcodeLine = z + .string() + .max(256) + .refine((s) => !/[\r\n]/.test(s), 'G-code lines must not contain line breaks'); + +/** Around 30 MB of G-code — far past any real page, well short of trouble. */ +const MAX_TOTAL_LINES = 1_000_000; + +export const jobUploadSchema = z + .object({ + name: z.string().min(1).max(200), + plotterName: z.string().min(1).max(200), + prologue: z.array(gcodeLine).max(1000).default([]), + layers: z + .array( + z.object({ + name: z.string().max(200).default(''), + color: z.string().max(64).default('#000000'), + lines: z.array(gcodeLine), + }), + ) + .min(1), + epilogue: z.array(gcodeLine).max(1000).default([]), + }) + .refine( + (j) => j.prologue.length + j.layers.reduce((n, l) => n + l.lines.length, 0) < MAX_TOTAL_LINES, + `Job exceeds ${MAX_TOTAL_LINES} lines`, + ); + +export type JobUpload = z.infer; + +export type Job = JobUpload & { id: string; createdAt: number }; + +export const totalLines = (job: Job) => + job.prologue.length + job.layers.reduce((n, l) => n + l.lines.length, 0) + job.epilogue.length; + +const layerSummary = (l: JobLayer): JobLayerSummary => ({ + name: l.name, + color: l.color, + lineCount: l.lines.length, +}); + +export const summarize = (job: Job): JobSummary => ({ + id: job.id, + name: job.name, + plotterName: job.plotterName, + createdAt: job.createdAt, + layers: job.layers.map(layerSummary), + totalLines: totalLines(job), +}); + +/** + * Uploaded jobs, in memory only. A job is a snapshot of a page the client + * already had; if the server restarts, re-uploading it is one click. Persisting + * megabytes of G-code to the Pi's SD card to save that click is a bad trade. + */ +export class JobStore { + private jobs = new Map(); + + constructor(private readonly max = 20) {} + + add(upload: JobUpload): Job { + const job: Job = { ...upload, id: randomUUID(), createdAt: Date.now() }; + this.jobs.set(job.id, job); + // Map iteration is insertion-ordered, so the first key is the oldest. + while (this.jobs.size > this.max) { + const oldest = this.jobs.keys().next(); + if (oldest.done) break; + this.jobs.delete(oldest.value); + } + return job; + } + + get(id: string) { + return this.jobs.get(id); + } + + delete(id: string) { + return this.jobs.delete(id); + } + + list(): JobSummary[] { + return [...this.jobs.values()].sort((a, b) => b.createdAt - a.createdAt).map(summarize); + } +} + +/** Emit progress at most this often; a print is thousands of lines. */ +const PROGRESS_INTERVAL_MS = 250; + +/** + * Runs a job against the plotter, locally. + * + * The point of the backend is that this loop lives next to the USB cable. Each + * line still waits for its `ok` — that is how Marlin flow control works — but + * the round trip is over USB, not over the network. Clients watch. + * + * The pen swap between layers used to be a `Promise` parked in a React + * component: close the tab and the print was orphaned mid-page. It is state + * here now, so the operator can walk away, swap the pen, and continue from + * whatever device is to hand. + */ +export class JobRunner { + private status: JobStatus | null = null; + private continueWaiter: (() => void) | null = null; + private stopReason: 'cancelled' | string | null = null; + private runPromise: Promise = Promise.resolve(); + private lastProgressAt = 0; + + constructor( + private readonly conn: PlotterConnection, + private readonly onChange: (status: JobStatus | null) => void = () => {}, + ) {} + + /** + * What clients see. `paused` is derived rather than stored: the pause lives on + * the connection (it gates interactive strokes too), and a job that is parked + * at a pen swap is not additionally "paused". + */ + get view(): JobStatus | null { + if (!this.status) return null; + const state = + this.status.state === 'running' && this.conn.isPaused ? 'paused' : this.status.state; + return { ...this.status, state }; + } + + get isActive() { + const s = this.status?.state; + return s === 'running' || s === 'awaiting_pen_swap'; + } + + /** Resolves when the current run finishes, however it finishes. */ + settled() { + return this.runPromise; + } + + /** Re-broadcast, e.g. when the connection's pause flag flipped underneath us. */ + notify() { + this.onChange(this.view); + } + + start(job: Job) { + if (this.isActive) throw new Error('A job is already running.'); + if (!this.conn.connected) throw new Error('Not connected to a plotter.'); + this.stopReason = null; + this.continueWaiter = null; + this.status = { + job: summarize(job), + state: 'running', + progress: { + layerIndex: 0, + layerCount: job.layers.length, + layerName: job.layers[0]?.name ?? '', + color: job.layers[0]?.color ?? '#000000', + lineIndex: 0, + lineCount: job.layers[0]?.lines.length ?? 0, + sentLines: 0, + totalLines: totalLines(job), + }, + nextLayer: null, + error: null, + startedAt: Date.now(), + finishedAt: null, + }; + this.notify(); + this.runPromise = this.run(job); + return this.view as JobStatus; + } + + /** Operator has swapped the pen and wants the next layer. */ + continue() { + if (this.status?.state !== 'awaiting_pen_swap') { + throw new Error('The job is not waiting for a pen swap.'); + } + const waiter = this.continueWaiter; + this.continueWaiter = null; + waiter?.(); + } + + cancel() { + if (!this.isActive) throw new Error('No job is running.'); + this.stop('cancelled'); + } + + /** + * Stop for a reason that isn't the operator asking — device lost, emergency + * stop. The loop notices at the next line boundary; anything already blocked + * on a reply was failed by the connection itself. + */ + abort(reason: string) { + if (!this.isActive) return; + this.stop(reason); + } + + private stop(reason: string) { + this.stopReason = reason; + const waiter = this.continueWaiter; + this.continueWaiter = null; + waiter?.(); + } + + private checkStop() { + if (this.stopReason) throw new Error(this.stopReason); + } + + private progress(patch: Partial, force = false) { + if (!this.status?.progress) return; + this.status.progress = { ...this.status.progress, ...patch }; + const now = Date.now(); + if (!force && now - this.lastProgressAt < PROGRESS_INTERVAL_MS) return; + this.lastProgressAt = now; + this.notify(); + } + + private async sendBlock(lines: string[], onSent: () => void) { + for (const line of lines) { + this.checkStop(); + await this.conn.send(line); + onSent(); + } + } + + private async run(job: Job) { + if (!this.status) return; + let sent = 0; + try { + await this.sendBlock(job.prologue, () => { + sent++; + this.progress({ sentLines: sent }); + }); + + for (let i = 0; i < job.layers.length; i++) { + this.checkStop(); + const layer = job.layers[i]; + this.progress( + { + layerIndex: i, + layerName: layer.name, + color: layer.color, + lineIndex: 0, + lineCount: layer.lines.length, + sentLines: sent, + }, + true, + ); + + let inLayer = 0; + await this.sendBlock(layer.lines, () => { + sent++; + inLayer++; + this.progress({ lineIndex: inLayer, sentLines: sent }); + }); + + const next = job.layers[i + 1]; + if (!next) continue; + // The plotter is holding position with the pen up. Park here until a + // client says the next colour is loaded. + this.status.state = 'awaiting_pen_swap'; + this.status.nextLayer = layerSummary(next); + this.progress({ sentLines: sent }, true); + await new Promise((resolve) => { + this.continueWaiter = resolve; + }); + this.checkStop(); + this.status.state = 'running'; + this.status.nextLayer = null; + this.progress({ sentLines: sent }, true); + } + + await this.sendBlock(job.epilogue, () => { + sent++; + this.progress({ sentLines: sent }); + }); + + this.finish('done', null); + } catch (e) { + const message = (e as Error).message; + this.finish(this.stopReason === 'cancelled' ? 'cancelled' : 'failed', message); + } + } + + private finish(state: 'done' | 'failed' | 'cancelled', error: string | null) { + if (!this.status) return; + this.status.state = state; + this.status.nextLayer = null; + this.status.error = state === 'cancelled' ? null : error; + this.status.finishedAt = Date.now(); + this.notify(); + } +} diff --git a/paint-app/server/src/plotter.test.ts b/paint-app/server/src/plotter.test.ts new file mode 100644 index 0000000..4bd8f3a --- /dev/null +++ b/paint-app/server/src/plotter.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PlotterConnection } from './plotter.js'; +import type { ConnectPhase, LogKind } from './protocol.js'; +import { FAST_TIMING, FakeMarlin } from './test/fakeMarlin.js'; + +type Harness = { + board: FakeMarlin; + conn: PlotterConnection; + log: { line: string; kind: LogKind }[]; + phases: (ConnectPhase | null)[]; + lost: ReturnType; + pauseChanges: boolean[]; +}; + +const harness = (board = new FakeMarlin()): Harness => { + const log: { line: string; kind: LogKind }[] = []; + const phases: (ConnectPhase | null)[] = []; + const lost = vi.fn(); + const pauseChanges: boolean[] = []; + const conn = new PlotterConnection({ + transport: board.transport, + timing: FAST_TIMING, + log: (line, kind) => log.push({ line, kind }), + onPhase: (p) => phases.push(p), + onLost: lost, + onPauseChange: (p) => pauseChanges.push(p), + }); + return { board, conn, log, phases, lost, pauseChanges }; +}; + +const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms)); + +describe('connect', () => { + it('boots, probes with M115, homes, and reports phases', async () => { + const h = harness(); + await h.conn.connect('/dev/fake'); + + expect(h.board.written).toEqual(['M115', 'G28']); + expect(h.phases).toEqual(['connecting', 'homing', null]); + expect(h.conn.connected).toBe(true); + expect(h.log.some((l) => l.line.startsWith('boot wait'))).toBe(true); + }); + + it('fails fast when the board never answers M115', async () => { + // A board that has been M112'd answers nothing. Without the probe this + // would march on into G28 and only fail 20s later, having told the user + // it was connected. + const h = harness(new FakeMarlin({ dead: true })); + await expect(h.conn.connect('/dev/fake')).rejects.toThrow(/not responding/i); + + expect(h.board.written).toEqual(['M115']); + expect(h.conn.connected).toBe(false); + }); + + it('closes and reopens a port that is already open', async () => { + const h = harness(new FakeMarlin({ startsOpen: true })); + await h.conn.connect('/dev/fake'); + + expect(h.log.some((l) => l.line.includes('already open'))).toBe(true); + expect(h.conn.connected).toBe(true); + }); + + it('retries a failed open exactly once', async () => { + const h = harness(new FakeMarlin({ failOpens: 1 })); + await h.conn.connect('/dev/fake'); + + expect(h.board.openAttempts).toBe(2); + expect(h.log.some((l) => l.line.includes('retrying once'))).toBe(true); + }); + + it('gives actionable guidance when the retry also fails', async () => { + const h = harness(new FakeMarlin({ failOpens: 5 })); + await expect(h.conn.connect('/dev/fake')).rejects.toThrow(/Close any other program/); + + expect(h.board.openAttempts).toBe(2); + expect(h.conn.connected).toBe(false); + }); + + it('refuses a second connect while one is live', async () => { + const h = harness(); + await h.conn.connect('/dev/fake'); + await expect(h.conn.connect('/dev/fake')).rejects.toThrow(/Already connected/); + }); +}); + +describe('send / ok handshake', () => { + it('returns the non-ok reply lines and stops at ok', async () => { + const h = harness(new FakeMarlin({ replies: { M105: ['T:20.1 /0.0 B:19.8 /0.0'] } })); + await h.conn.connect('/dev/fake'); + + const reply = await h.conn.send('M105'); + expect(reply).toEqual(['T:20.1 /0.0 B:19.8 /0.0']); + }); + + it('never writes a comment-only line', async () => { + // Marlin sends no `ok` for a comment, so writing one would stall the + // reader for a full idle timeout. + const h = harness(); + await h.conn.connect('/dev/fake'); + h.board.writes.length = 0; + + expect(await h.conn.send('; layer Ink (#000, 0.5mm)')).toEqual([]); + expect(h.board.written).toEqual([]); + }); + + it('serialises concurrent senders so replies are not crossed', async () => { + const h = harness(new FakeMarlin({ replyDelayMs: 5 })); + await h.conn.connect('/dev/fake'); + h.board.writes.length = 0; + + await Promise.all([h.conn.send('G0 X1'), h.conn.send('G0 X2'), h.conn.send('G0 X3')]); + expect(h.board.written).toEqual(['G0 X1', 'G0 X2', 'G0 X3']); + }); + + it('parses M114 into a position', async () => { + const h = harness(); + await h.conn.connect('/dev/fake'); + expect(await h.conn.getPosition()).toEqual({ x: 10, y: 20, z: 5 }); + }); +}); + +describe('echo:busy keepalive', () => { + it('keeps waiting through busy pulses far past the idle timeout', async () => { + // G28 can take arbitrarily long. `echo:busy: processing` every 20ms keeps + // the 60ms inactivity timer from ever firing across a 300ms home. + const board = new FakeMarlin({ busy: { G28: 300 }, busyIntervalMs: 20 }); + const log: { line: string; kind: LogKind }[] = []; + const conn = new PlotterConnection({ + transport: board.transport, + timing: { ...FAST_TIMING, defaultIdleTimeoutMs: 60 }, + log: (line, kind) => log.push({ line, kind }), + }); + + const started = Date.now(); + await conn.connect('/dev/fake'); + const elapsed = Date.now() - started; + + expect(elapsed).toBeGreaterThanOrEqual(280); + expect(conn.connected).toBe(true); + // The keepalives are logged as rx but must not be mistaken for a reply. + expect(log.filter((l) => l.line.includes('echo:busy')).length).toBeGreaterThan(5); + }); + + it('does not hand keepalives back as reply lines', async () => { + // `echo:busy` is flow control, not an answer. Leaking it into the reply + // would put it in front of the M115 banner check and the M114 parse. + const board = new FakeMarlin({ + busy: { G1: 120 }, + busyIntervalMs: 20, + replies: { G1: ['X:1.00 Y:2.00 Z:3.00'] }, + }); + const conn = new PlotterConnection({ transport: board.transport, timing: FAST_TIMING }); + await conn.connect('/dev/fake'); + + expect(await conn.send('G1 X10')).toEqual(['X:1.00 Y:2.00 Z:3.00']); + }); + + it('gives up after real silence rather than hanging forever', async () => { + const board = new FakeMarlin({ silent: ['G0'] }); + const conn = new PlotterConnection({ + transport: board.transport, + timing: { ...FAST_TIMING, defaultIdleTimeoutMs: 40 }, + }); + await conn.connect('/dev/fake'); + + const started = Date.now(); + await conn.send('G0 X10'); + expect(Date.now() - started).toBeGreaterThanOrEqual(35); + expect(conn.connected).toBe(true); + }); +}); + +describe('pause gating', () => { + it('blocks before writing the next line and releases on resume', async () => { + const h = harness(new FakeMarlin({ replyDelayMs: 2 })); + await h.conn.connect('/dev/fake'); + h.board.writes.length = 0; + + h.conn.pause(); + expect(h.conn.isPaused).toBe(true); + expect(h.pauseChanges).toEqual([true]); + + let settled = false; + const pending = h.conn.send('G0 X10').then(() => { + settled = true; + }); + + await tick(30); + expect(h.board.written).toEqual([]); + expect(settled).toBe(false); + + h.conn.resume(); + await pending; + expect(h.board.written).toEqual(['G0 X10']); + expect(h.pauseChanges).toEqual([true, false]); + }); + + it('lets an operator jog while paused', async () => { + // The whole reason to pause is to go and do something to the machine. + const h = harness(); + await h.conn.connect('/dev/fake'); + h.board.writes.length = 0; + h.conn.pause(); + + await h.conn.send('G0 Z5', { bypassPause: true }); + expect(h.board.written).toEqual(['G0 Z5']); + }); + + it('clears the pause on disconnect so nothing is left blocked', async () => { + const h = harness(); + await h.conn.connect('/dev/fake'); + h.conn.pause(); + + const pending = h.conn.send('G0 X10').catch((e: Error) => e.message); + await h.conn.disconnect(); + + await expect(pending).resolves.toMatch(/disconnected|Not connected/); + expect(h.conn.isPaused).toBe(false); + }); +}); + +describe('device loss', () => { + it('reports an unexpected close as a loss', async () => { + const h = harness(); + await h.conn.connect('/dev/fake'); + + h.board.unplug(); + await tick(); + + expect(h.lost).toHaveBeenCalledTimes(1); + expect(h.conn.connected).toBe(false); + expect(h.log.some((l) => l.line === 'connection lost' && l.kind === 'err')).toBe(true); + }); + + it('does not report an intentional disconnect as a loss', async () => { + const h = harness(); + await h.conn.connect('/dev/fake'); + + await h.conn.disconnect(); + await tick(); + + expect(h.lost).not.toHaveBeenCalled(); + expect(h.conn.connected).toBe(false); + expect(h.log.some((l) => l.line === 'closed')).toBe(true); + }); + + it('fails an in-flight send instead of leaving it to time out', async () => { + const h = harness(new FakeMarlin({ silent: ['G0'] })); + await h.conn.connect('/dev/fake'); + + const pending = h.conn.send('G0 X10').catch((e: Error) => e.message); + await tick(5); + h.board.unplug(); + + await expect(pending).resolves.toBeTypeOf('object'); + expect(h.conn.connected).toBe(false); + }); +}); + +describe('emergency stop', () => { + it('lands while a slow command is still in flight', async () => { + // The property being protected. Every other command waits its turn behind + // the one in flight, and on a real board that wait is unbounded — a G28 is + // fifteen seconds. An e-stop that takes the same path arrives after the + // crash it was meant to prevent, so it takes no path at all: straight to + // the port, no queue, no lock, no `ok`. + const h = harness(new FakeMarlin({ busy: { G1: 400 }, busyIntervalMs: 50 })); + await h.conn.connect('/dev/fake'); + h.board.writes.length = 0; + + const slow = h.conn.send('G1 X100').catch(() => {}); + await tick(20); + + const started = Date.now(); + await h.conn.emergencyStop(); + const elapsed = Date.now() - started; + await slow; + + expect(elapsed).toBeLessThan(100); + expect(h.board.written).toEqual(['G1 X100', 'M112']); + }); + + it('lands while the connection is paused', async () => { + // Sharing the pause gate would mean the stop button does nothing until + // somebody presses play. + const h = harness(); + await h.conn.connect('/dev/fake'); + h.board.writes.length = 0; + h.conn.pause(); + + await h.conn.emergencyStop(); + expect(h.board.written).toEqual(['M112']); + }); + + it('stops the stream dead rather than draining it', async () => { + const h = harness(new FakeMarlin({ replyDelayMs: 5 })); + await h.conn.connect('/dev/fake'); + h.board.writes.length = 0; + + const queued = Array.from({ length: 50 }, (_, i) => `G1 X${i}`); + const stream = h.conn.sendMany(queued).catch(() => {}); + await tick(12); + await h.conn.emergencyStop(); + await stream; + + expect(h.board.written.indexOf('M112')).toBeLessThan(10); + expect(h.board.writtenAfter('M112')).toEqual([]); + }); + + it('is a no-op when nothing is connected', async () => { + const h = harness(); + await expect(h.conn.emergencyStop()).resolves.toBeUndefined(); + }); + + it('refuses further sends until the next connect', async () => { + const h = harness(); + await h.conn.connect('/dev/fake'); + await h.conn.emergencyStop(); + + await expect(h.conn.send('G0 X1')).rejects.toThrow(/emergency stop/); + expect(h.conn.isEmergencyStopped).toBe(true); + }); +}); diff --git a/paint-app/server/src/plotter.ts b/paint-app/server/src/plotter.ts new file mode 100644 index 0000000..a87b317 --- /dev/null +++ b/paint-app/server/src/plotter.ts @@ -0,0 +1,453 @@ +import type { ConnectPhase, LogKind } from './protocol.js'; +import { nodeTransport, type SerialTransport, type TransportFactory } from './transport.js'; + +export const BAUD = 115200; + +/** + * Every delay in the connect dance, in one place so tests can shrink them. + * The defaults are the values the Web Serial implementation arrived at against + * a real Ender-3 V3 SE; none of them are arbitrary. + */ +export type Timing = { + /** Marlin resets when the port opens. Nothing it says before this is useful. */ + bootWaitMs: number; + /** How long the OS needs to actually let go of a port we just closed. */ + reopenDelayMs: number; + /** Backoff before the single retry of a failed open. */ + openRetryDelayMs: number; + /** Silence, not total elapsed time, that ends a `readUntilOk`. */ + defaultIdleTimeoutMs: number; + /** The `M115` liveness probe fails fast; a live board answers instantly. */ + probeTimeoutMs: number; +}; + +export const DEFAULT_TIMING: Timing = { + bootWaitMs: 2000, + reopenDelayMs: 300, + openRetryDelayMs: 800, + defaultIdleTimeoutMs: 20_000, + probeTimeoutMs: 6_000, +}; + +export type LogFn = (line: string, kind: LogKind) => void; +export type PhaseFn = (phase: ConnectPhase | null) => void; + +export type PlotterConnectionOptions = { + log?: LogFn; + onPhase?: PhaseFn; + /** + * Fired once when the port drops without a `disconnect()` — unplugged cable, + * board reset, firmware killed. Never fired for an intentional close. + */ + onLost?: () => void; + onPauseChange?: (paused: boolean) => void; + transport?: TransportFactory; + timing?: Partial; +}; + +export type SendOptions = { + idleTimeoutMs?: number; + /** + * Skip the pause gate. For operator-driven commands (jog, position) that are + * the whole point of having paused in the first place. + */ + bypassPause?: boolean; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * A serial link to a Marlin board, ported from the browser's Web Serial + * implementation (`paint-app/src/serial.ts`). + * + * Differences from the browser original, all forced by being a shared server + * rather than a single page: + * + * - Writes are serialised through a queue. In the browser exactly one loop ever + * called `send()`; here a job stream and an operator's jog button can arrive + * at once, and interleaving two commands would mismatch their `ok`s. + * - Anything waiting on a reply is failed immediately on disconnect, device + * loss or emergency stop, rather than being left to time out. A job loop that + * keeps running for 20s after an e-stop is not an e-stop. + */ +export class PlotterConnection { + private transport: SerialTransport | null = null; + private buffer = ''; + /** Lines received but not yet consumed by a reader. */ + private lineQueue: string[] = []; + /** Resolver for a reader currently awaiting the next line, if any. */ + private lineWaiter: ((line: string | null) => void) | null = null; + /** + * True only while an intentional disconnect() is in progress, so the port + * closing doesn't get misreported as an unexpected device loss. + */ + private intentionalClose = false; + /** + * When true, the next send() blocks before writing until resume(). The + * in-flight line still finishes; Marlin then drains its small planner buffer + * and holds position. Applies to every G-code source since they all funnel + * through send(). + */ + private paused = false; + private resumeWaiters: (() => void)[] = []; + /** Non-null once the link is unusable; the reason, for anything still waiting. */ + private aborted: string | null = null; + /** Serialises writes so two callers never share a reply stream. */ + private writeChain: Promise = Promise.resolve(); + private phase: ConnectPhase | null = null; + private portPath: string | null = null; + private emergencyStopped = false; + + private readonly log: LogFn; + private readonly onPhase: PhaseFn; + private readonly onLost: () => void; + private readonly onPauseChange: (paused: boolean) => void; + private readonly makeTransport: TransportFactory; + private readonly timing: Timing; + + constructor(opts: PlotterConnectionOptions = {}) { + this.log = opts.log ?? (() => {}); + this.onPhase = opts.onPhase ?? (() => {}); + this.onLost = opts.onLost ?? (() => {}); + this.onPauseChange = opts.onPauseChange ?? (() => {}); + this.makeTransport = opts.transport ?? nodeTransport; + this.timing = { ...DEFAULT_TIMING, ...opts.timing }; + } + + get connected() { + return this.transport !== null; + } + + get isPaused() { + return this.paused; + } + + get currentPhase() { + return this.phase; + } + + get path() { + return this.portPath; + } + + get isEmergencyStopped() { + return this.emergencyStopped; + } + + pause() { + if (this.paused) return; + this.paused = true; + this.log('paused', 'info'); + this.onPauseChange(true); + } + + resume() { + if (!this.paused) return; + this.paused = false; + this.releasePauseWaiters(); + this.log('resumed', 'info'); + this.onPauseChange(false); + } + + private releasePauseWaiters() { + const waiters = this.resumeWaiters; + this.resumeWaiters = []; + for (const w of waiters) w(); + } + + /** + * Resolves immediately unless paused, in which case it waits for resume() + * (or a disconnect, which forcibly clears the pause). + */ + private gate(): Promise { + if (!this.paused) return Promise.resolve(); + return new Promise((r) => this.resumeWaiters.push(r)); + } + + private setPhase(phase: ConnectPhase | null) { + this.phase = phase; + this.onPhase(phase); + } + + async connect(portPath: string) { + if (this.transport) { + throw new Error('Already connected. Disconnect before connecting again.'); + } + const transport = this.makeTransport(portPath, BAUD); + this.setPhase('connecting'); + try { + await this.openWithRetry(transport, portPath); + } catch (e) { + this.setPhase(null); + throw e; + } + + this.transport = transport; + this.portPath = portPath; + this.aborted = null; + this.emergencyStopped = false; + this.buffer = ''; + this.lineQueue.length = 0; + transport.onData((chunk) => this.ingest(chunk)); + transport.onError((err) => this.log(`serial error: ${err.message}`, 'err')); + transport.onClose(() => this.handleClose()); + this.log(`opened ${portPath} @ ${BAUD}`, 'info'); + + try { + // Marlin resets on open; wait for boot. + this.log(`boot wait ${this.timing.bootWaitMs}ms…`, 'info'); + await sleep(this.timing.bootWaitMs); + this.log('boot wait done; sending M115', 'info'); + // M115 is the liveness probe: a healthy Marlin always replies with a + // FIRMWARE_NAME banner. A board that's been M112'd / killed answers + // nothing, so a short timeout here lets us fail fast instead of marching + // on (M115 timeout -> G28 timeout) and falsely reporting a live + // connection to a dead board. + const info = await this.send('M115', { idleTimeoutMs: this.timing.probeTimeoutMs }); + const alive = info.some((l) => /FIRMWARE_NAME|marlin/i.test(l)); + if (!alive) { + this.log('no firmware response — board not responding', 'err'); + await this.disconnect().catch(() => {}); + throw new Error( + 'Plotter is not responding. If it was emergency-stopped, power-cycle the printer (off, wait, on), then reconnect.', + ); + } + this.log('M115 done; sending G28 (home)', 'info'); + this.setPhase('homing'); + await this.send('G28'); + this.log('G28 done; connected', 'info'); + } catch (e) { + this.setPhase(null); + if (this.transport) await this.disconnect().catch(() => {}); + throw e; + } + this.setPhase(null); + } + + /** + * Open the port, absorbing the two failures that are routine rather than + * fatal: a handle we (or a crashed predecessor) never released, and an OS + * that hasn't finished freeing the device from the last session. + */ + private async openWithRetry(transport: SerialTransport, portPath: string) { + if (transport.isOpen) { + this.log('port already open — closing before reopening', 'info'); + await transport.close().catch(() => {}); + // The OS doesn't release the device synchronously with close(); opening + // again immediately fails with a generic error. Give the previous handle + // time to drop before reopening. + await sleep(this.timing.reopenDelayMs); + } + try { + await transport.open(); + return; + } catch (e) { + const message = (e as Error).message; + if (/already open/i.test(message)) { + await transport.close().catch(() => {}); + await sleep(this.timing.reopenDelayMs); + } + // A transient open failure usually means the OS hasn't freed the device + // yet (just unplugged/replugged, prior session, another program holding + // it). One delayed retry clears the common case; a second failure is + // surfaced with actionable guidance. + this.log(`open failed (${message}) — retrying once`, 'info'); + await sleep(this.timing.openRetryDelayMs); + try { + await transport.open(); + return; + } catch { + throw new Error( + `Could not open ${portPath}. Close any other program using the plotter (a serial monitor, another instance of this server), check that the container has the device passed through, then unplug and replug the USB cable and try again.`, + ); + } + } + } + + async disconnect() { + if (!this.transport) return; + const transport = this.transport; + this.intentionalClose = true; + // Release anything blocked on a pause or on a reply so the job loop can + // unwind instead of hanging on a closed port. + this.paused = false; + this.releasePauseWaiters(); + this.onPauseChange(false); + this.abortPending('disconnected'); + try { + await transport.close().catch(() => {}); + } finally { + this.transport = null; + this.portPath = null; + this.setPhase(null); + this.intentionalClose = false; + this.log('closed', 'info'); + } + } + + /** Called when the transport's handle goes away, for any reason. */ + private handleClose() { + // The port closing without an intentional disconnect() means the device was + // lost (unplugged, reset, or killed). Drop state and notify so the UI stops + // showing a live connection. + if (this.intentionalClose || !this.transport) return; + this.transport = null; + this.portPath = null; + this.setPhase(null); + this.abortPending('connection lost'); + this.paused = false; + this.releasePauseWaiters(); + this.log('connection lost', 'err'); + this.onLost(); + } + + /** + * Fail everything currently waiting, and everything sent from now on, until + * the next connect(). Without this an emergency stop leaves the job loop + * parked on a 20s idle timeout, still holding thousands of queued lines. + */ + private abortPending(reason: string) { + this.aborted = reason; + const waiter = this.lineWaiter; + this.lineWaiter = null; + waiter?.(null); + } + + private ingest(chunk: string) { + this.buffer += chunk; + let nl: number; + // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic line splitting + while ((nl = this.buffer.indexOf('\n')) >= 0) { + const line = this.buffer.slice(0, nl).replace(/\r$/, ''); + this.buffer = this.buffer.slice(nl + 1); + if (!line) continue; + this.log(line, 'rx'); + this.pushLine(line); + } + } + + /** Deliver a line to a waiting reader, or buffer it until one asks. */ + private pushLine(line: string) { + if (this.lineWaiter) { + const w = this.lineWaiter; + this.lineWaiter = null; + w(line); + } else { + this.lineQueue.push(line); + } + } + + private nextLine(timeoutMs: number) { + const queued = this.lineQueue.shift(); + if (queued !== undefined) return Promise.resolve(queued); + if (this.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + let done = false; + const t = setTimeout(() => { + if (done) return; + done = true; + this.lineWaiter = null; + resolve(null); + }, timeoutMs); + this.lineWaiter = (line) => { + if (done) return; + done = true; + clearTimeout(t); + resolve(line); + }; + }); + } + + /** + * Collect reply lines until `ok`. Uses an *inactivity* timeout, not a hard + * deadline: slow commands like `G28` can take far longer than any fixed + * budget, and Marlin emits `echo:busy: processing` every ~2s precisely so the + * host knows it is still alive. We treat those as keepalives that keep us + * waiting; we only give up after `idleTimeoutMs` of true silence. + */ + private async readUntilOk(idleTimeoutMs = this.timing.defaultIdleTimeoutMs): Promise { + const collected: string[] = []; + while (true) { + const line = await this.nextLine(idleTimeoutMs); + if (line === null) return collected; + const lower = line.toLowerCase(); + if (lower.startsWith('ok')) return collected; + if (lower.startsWith('echo:busy') || lower.startsWith('busy')) continue; + collected.push(line); + } + } + + /** Send a single G-code line; returns any non-`ok` reply lines it produced. */ + async send(gcode: string, opts: SendOptions = {}): Promise { + // Strip comments; Marlin sends no `ok` for a comment-only line, so sending + // one would stall readUntilOk for the full idle timeout. + const line = gcode.replace(/;.*$/, '').trim(); + if (!line) return []; + // Block here while paused so the *next* line isn't sent; the previous line + // already got its `ok`, so the machine just holds position. Gating happens + // outside the write queue so a jog can still be serviced while paused. + if (!opts.bypassPause) await this.gate(); + return this.enqueue(async () => { + if (this.aborted) throw new Error(this.aborted); + if (!this.transport) throw new Error('Not connected.'); + // Drop any lines left over from a previous command (e.g. trailing async + // chatter after its `ok`) so they aren't mistaken for this reply. + this.lineQueue.length = 0; + this.log(line, 'tx'); + await this.transport.write(`${line}\n`); + return this.readUntilOk(opts.idleTimeoutMs); + }); + } + + async sendMany(lines: string[], opts: SendOptions = {}) { + for (const l of lines) await this.send(l, opts); + } + + /** Run `fn` once every previously queued write has finished. */ + private enqueue(fn: () => Promise): Promise { + const run = this.writeChain.then(fn, fn); + this.writeChain = run.catch(() => {}); + return run; + } + + /** + * Emergency stop. Writes M112 straight to the port, bypassing the write queue + * so it lands even mid-stream (Marlin's EMERGENCY_PARSER acts on it + * immediately, full planner buffer or not). The board enters a killed state + * afterwards and must be power-cycled and reconnected. + * + * Nothing on this path may await a queue, a lock, or a job — that is the + * entire property being protected. + */ + async emergencyStop() { + const transport = this.transport; + if (!transport) return; + this.emergencyStopped = true; + this.log('M112 (emergency stop)', 'err'); + try { + await transport.write('M112\n'); + } finally { + // Unblock the job loop immediately rather than letting it spend an idle + // timeout waiting for an `ok` from a board that is no longer listening. + this.abortPending('emergency stop'); + this.paused = false; + this.releasePauseWaiters(); + } + } + + /** Query M114 and parse the X/Y/Z position from the reply. Null if no reply. */ + async getPosition(): Promise<{ x: number; y: number; z: number } | null> { + const lines = await this.send('M114', { bypassPause: true }); + for (const line of lines) { + const m = line.match(/X:(-?\d+\.?\d*)\s+Y:(-?\d+\.?\d*)\s+Z:(-?\d+\.?\d*)/); + if (m) { + return { + x: Number.parseFloat(m[1]), + y: Number.parseFloat(m[2]), + z: Number.parseFloat(m[3]), + }; + } + } + return null; + } +} diff --git a/paint-app/server/src/ports.test.ts b/paint-app/server/src/ports.test.ts new file mode 100644 index 0000000..a546003 --- /dev/null +++ b/paint-app/server/src/ports.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { describePorts, type RawPort } from './ports.js'; + +const ch340: RawPort = { + path: '/dev/ttyUSB0', + manufacturer: 'QinHeng Electronics', + vendorId: '1A86', + productId: '7523', + serialNumber: undefined, +}; + +const bluetooth: RawPort = { path: '/dev/cu.Bluetooth-Incoming-Port' }; +const legacy: RawPort = { path: '/dev/ttyS0' }; +const someDongle: RawPort = { path: '/dev/ttyACM1', vendorId: '0bda', productId: '8153' }; + +describe('describePorts', () => { + it('prefers the stable by-id path for connecting', () => { + // /dev/ttyUSB0 is assigned in plug order; the by-id symlink is not. What + // gets stored and reconnected to has to be the stable one. + const byId = new Map([['/dev/ttyUSB0', '/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0']]); + const [port] = describePorts([ch340], byId); + + expect(port.path).toBe('/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0'); + expect(port.device).toBe('/dev/ttyUSB0'); + expect(port.label).toContain('1a86 USB Serial'); + }); + + it('falls back to the device node when there is no by-id link', () => { + const [port] = describePorts([ch340], new Map()); + expect(port.path).toBe('/dev/ttyUSB0'); + }); + + it('flags known plotter chips and sorts them first', () => { + const ports = describePorts([someDongle, ch340], new Map()); + expect(ports.map((p) => p.device)).toEqual(['/dev/ttyUSB0', '/dev/ttyACM1']); + expect(ports[0].known).toBe(true); + expect(ports[1].known).toBe(false); + }); + + it('drops the noise the picker used to have to filter by hand', () => { + const ports = describePorts([bluetooth, legacy, ch340], new Map()); + expect(ports.map((p) => p.device)).toEqual(['/dev/ttyUSB0']); + }); + + it('normalises vendor ids so case never decides the match', () => { + const [port] = describePorts([{ ...ch340, vendorId: '0x1a86' }], new Map()); + expect(port.vendorId).toBe('1a86'); + expect(port.known).toBe(true); + }); +}); diff --git a/paint-app/server/src/ports.ts b/paint-app/server/src/ports.ts new file mode 100644 index 0000000..4a7ebea --- /dev/null +++ b/paint-app/server/src/ports.ts @@ -0,0 +1,130 @@ +import { readdir, readlink } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { PortInfo } from './protocol.js'; + +const requireCjs = createRequire(import.meta.url); + +/** + * USB vendor IDs of the chips that show up on Marlin/GRBL boards: Arduino, + * FTDI, CH340, CP210x, STM32, Teensy, Atmel, RP2040, SparkFun, OpenMoko. + * A port from one of these outranks anything else, which is what lets a single + * plotter be offered without asking. Carried over from the Electron picker. + */ +export const PLOTTER_VENDOR_IDS = new Set([ + '2341', + '2a03', + '0403', + '1a86', + '10c4', + '0483', + '16c0', + '03eb', + '2e8a', + '1b4f', + '1d50', +]); + +/** Shape of one entry from `SerialPort.list()`, minus the fields we ignore. */ +export type RawPort = { + path: string; + manufacturer?: string; + serialNumber?: string; + vendorId?: string; + productId?: string; + pnpId?: string; +}; + +const norm = (id: string | undefined) => id?.toLowerCase().replace(/^0x/, '').padStart(4, '0'); + +const isKnown = (p: RawPort) => { + const v = norm(p.vendorId); + return v !== undefined && PLOTTER_VENDOR_IDS.has(v); +}; + +/** + * Device nodes that are never a plotter. `/dev/ttyS*` is the ISA-era serial + * range Linux enumerates by the dozen; `cu.Bluetooth`/`tty.Bluetooth` is the + * macOS noise the Electron picker also had to filter. + */ +const isNoise = (p: RawPort) => + /^\/dev\/ttyS\d+$/.test(p.path) || /bluetooth|debug-console/i.test(p.path); + +/** + * `/dev/ttyUSB0` is assigned in plug order — it moves when you replug the + * plotter or reboot with a second USB serial device attached. The by-id + * symlink is derived from the descriptor and does not, so it is what gets + * stored and reconnected to. + */ +export const BY_ID_DIR = '/dev/serial/by-id'; + +export async function readByIdLinks(dir = BY_ID_DIR): Promise> { + const map = new Map(); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + // No /dev/serial/by-id: macOS, or a container without /dev/serial mounted. + return map; + } + for (const entry of entries) { + const link = path.join(dir, entry); + try { + map.set(path.resolve(dir, await readlink(link)), link); + } catch { + // Raced with a replug; the next enumeration will pick it up. + } + } + return map; +} + +const label = (p: RawPort, stablePath: string | undefined) => { + const pretty = stablePath + ? path + .basename(stablePath) + .replace(/^usb-/, '') + .replace(/-if\d+.*$/, '') + .replace(/_/g, ' ') + : p.manufacturer; + return pretty ? `${pretty} (${p.path})` : p.path; +}; + +/** + * Turn a raw `SerialPort.list()` into something a picker can render: noise + * dropped, stable paths substituted, likely plotters first. + * + * Split out from `listPorts` so it can be exercised without a real `/dev`. + */ +export function describePorts(raw: RawPort[], byId: Map): PortInfo[] { + return raw + .filter((p) => !isNoise(p)) + .map((p) => { + const stable = byId.get(p.path); + return { + path: stable ?? p.path, + device: p.path, + label: label(p, stable), + manufacturer: p.manufacturer, + vendorId: norm(p.vendorId), + productId: norm(p.productId), + serialNumber: p.serialNumber, + known: isKnown(p), + }; + }) + .sort((a, b) => { + // Known plotter chips first, then anything else with a USB vendor id, + // then the leftovers — the same best-first order the Electron picker used. + if (a.known !== b.known) return a.known ? -1 : 1; + const aUsb = Boolean(a.vendorId); + const bUsb = Boolean(b.vendorId); + if (aUsb !== bUsb) return aUsb ? -1 : 1; + return a.label.localeCompare(b.label); + }); +} + +export async function listPorts(): Promise { + // biome-ignore lint/suspicious/noExplicitAny: CJS interop for a native module + const { SerialPort } = requireCjs('serialport') as any; + const [raw, byId] = await Promise.all([SerialPort.list() as Promise, readByIdLinks()]); + return describePorts(raw, byId); +} diff --git a/paint-app/server/src/protocol.ts b/paint-app/server/src/protocol.ts new file mode 100644 index 0000000..05d5fa2 --- /dev/null +++ b/paint-app/server/src/protocol.ts @@ -0,0 +1,147 @@ +/** + * The wire contract between the plotter backend and its clients. + * + * This file is deliberately dependency-free so the React client can import it + * verbatim once it is ported off Web Serial. + */ + +export type LogKind = 'tx' | 'rx' | 'info' | 'err'; + +/** One line of the connection log. `t` is `Date.now()` at the time it was produced. */ +export type LogLine = { t: number; kind: LogKind; line: string }; + +export type ConnectPhase = 'connecting' | 'homing'; + +export type PortInfo = { + /** + * What to hand back to `connect`. On Linux this is the `/dev/serial/by-id` + * symlink when one exists, because that name survives a replug and a reboot; + * `/dev/ttyUSB0` does not. + */ + path: string; + /** The underlying device node the path resolves to. Display only. */ + device: string; + label: string; + manufacturer?: string; + vendorId?: string; + productId?: string; + serialNumber?: string; + /** The USB vendor id belongs to a chip commonly found on Marlin/GRBL boards. */ + known: boolean; +}; + +export type JobState = + | 'queued' + | 'running' + | 'awaiting_pen_swap' + | 'paused' + | 'done' + | 'failed' + | 'cancelled'; + +export type JobLayer = { name: string; color: string; lines: string[] }; +export type JobLayerSummary = { name: string; color: string; lineCount: number }; + +/** A job without its G-code. Everything the UI needs to describe one. */ +export type JobSummary = { + id: string; + name: string; + plotterName: string; + createdAt: number; + layers: JobLayerSummary[]; + /** Prologue + every layer + epilogue. */ + totalLines: number; +}; + +export type JobProgress = { + layerIndex: number; + layerCount: number; + layerName: string; + color: string; + /** Lines sent within the current layer. */ + lineIndex: number; + lineCount: number; + /** Lines sent across the whole job, prologue and epilogue included. */ + sentLines: number; + totalLines: number; +}; + +export type JobStatus = { + job: JobSummary; + state: JobState; + progress: JobProgress | null; + /** Only set while `awaiting_pen_swap`: the layer that starts once you continue. */ + nextLayer: JobLayerSummary | null; + error: string | null; + startedAt: number | null; + finishedAt: number | null; +}; + +export type ConnectionStatus = { + connected: boolean; + connecting: boolean; + phase: ConnectPhase | null; + portPath: string | null; + paused: boolean; + /** + * True from an `M112` until the next successful connect. The board is in a + * killed state and needs a power cycle before it answers anything. + */ + emergencyStopped: boolean; +}; + +export type ClientInfo = { id: string; name: string; controller: boolean; since: number }; + +export type SessionStatus = { + /** The one client allowed to move the machine. Null when nobody holds it. */ + controllerId: string | null; + clients: ClientInfo[]; +}; + +export type Snapshot = { + connection: ConnectionStatus; + session: SessionStatus; + activeJob: JobStatus | null; +}; + +/** + * Client -> server. Every message may carry an `id`; when it does, the server + * answers with a matching `ack` so callers can await a result. + */ +export type ClientMessage = + | { id?: string; type: 'ping' } + | { id?: string; type: 'identify'; name: string } + /** Which log kinds to stream. `tx`/`rx` are thousands of lines per print. */ + | { id?: string; type: 'log.subscribe'; kinds: LogKind[] } + | { id?: string; type: 'control.claim' } + | { id?: string; type: 'control.release' } + | { id?: string; type: 'control.takeover' } + | { id?: string; type: 'connect'; portPath: string } + | { id?: string; type: 'disconnect' } + | { id?: string; type: 'job.start'; jobId: string } + | { id?: string; type: 'job.continue' } + | { id?: string; type: 'job.pause' } + | { id?: string; type: 'job.resume' } + | { id?: string; type: 'job.cancel' } + | { id?: string; type: 'jog'; lines: string[] } + | { id?: string; type: 'position' } + /** Never gated on control, never queued behind a job. See `estop` in the README. */ + | { id?: string; type: 'estop' }; + +export type ServerMessage = + | { + type: 'hello'; + clientId: string; + serverVersion: string; + snapshot: Snapshot; + /** The last few `info`/`err` lines, so a late joiner has context. */ + recentLog: LogLine[]; + } + | { type: 'log'; lines: LogLine[] } + | { type: 'connection'; connection: ConnectionStatus } + | { type: 'session'; session: SessionStatus } + | { type: 'job'; job: JobStatus | null } + /** The port dropped without anyone asking it to. */ + | { type: 'lost' } + | { type: 'ack'; id: string; ok: boolean; error?: string; data?: unknown } + | { type: 'pong' }; diff --git a/paint-app/server/src/server.test.ts b/paint-app/server/src/server.test.ts new file mode 100644 index 0000000..a496a8e --- /dev/null +++ b/paint-app/server/src/server.test.ts @@ -0,0 +1,194 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; +import { loadConfig } from './config.js'; +import { Hub } from './hub.js'; +import type { ClientMessage, JobSummary, PortInfo, ServerMessage } from './protocol.js'; +import { attachWebSocket, buildApp } from './server.js'; +import { FAST_TIMING, FakeMarlin } from './test/fakeMarlin.js'; + +const sampleJob = { + name: 'page 1', + plotterName: 'Ender-3 V3 SE', + prologue: ['G21', 'G90'], + layers: [ + { name: 'Ink', color: '#000000', lines: ['G0 X1 Y1', 'G1 X2 Y2'] }, + { name: 'Red', color: '#ff0000', lines: ['G0 X3 Y3', 'G1 X4 Y4'] }, + ], + epilogue: ['M84'], +}; + +/** A client that speaks the protocol the way the React app will. */ +class TestClient { + readonly received: ServerMessage[] = []; + private nextId = 0; + private pending = new Map) => void>(); + + private constructor(private readonly socket: WebSocket) {} + + static async connect(url: string) { + const socket = new WebSocket(url); + const client = new TestClient(socket); + socket.on('message', (raw) => { + const msg = JSON.parse(raw.toString()) as ServerMessage; + client.received.push(msg); + if (msg.type === 'ack') client.pending.get(msg.id)?.(msg); + }); + await new Promise((resolve, reject) => { + socket.once('open', resolve); + socket.once('error', reject); + }); + await client.waitFor((m) => m.type === 'hello'); + return client; + } + + /** Send and await the matching ack, the way a real caller would. */ + request(msg: ClientMessage) { + const id = `r${this.nextId++}`; + return new Promise>((resolve) => { + this.pending.set(id, resolve); + this.socket.send(JSON.stringify({ ...msg, id })); + }); + } + + async waitFor(pred: (m: ServerMessage) => boolean, ms = 2000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const hit = this.received.find(pred); + if (hit) return hit; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error('timed out waiting for a message'); + } + + close() { + this.socket.close(); + } +} + +describe('http + websocket', () => { + let hub: Hub; + let server: Server; + let board: FakeMarlin; + let base: string; + let wsUrl: string; + + beforeEach(async () => { + board = new FakeMarlin(); + hub = new Hub({ connection: { transport: board.transport, timing: FAST_TIMING } }); + const config = loadConfig({ PORT: '0' } as NodeJS.ProcessEnv); + server = createServer( + buildApp({ + hub, + config, + version: 'test', + ports: async () => [ + { + path: '/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0', + device: '/dev/ttyUSB0', + label: '1a86 USB Serial (/dev/ttyUSB0)', + vendorId: '1a86', + known: true, + }, + ], + }), + ); + attachWebSocket(server, hub); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + base = `http://127.0.0.1:${port}`; + wsUrl = `ws://127.0.0.1:${port}/ws`; + }); + + afterEach(async () => { + hub.dispose(); + await new Promise((r) => server.close(() => r())); + }); + + it('enumerates ports server-side instead of asking the browser', async () => { + const res = await fetch(`${base}/api/ports`); + const body = (await res.json()) as { ports: PortInfo[] }; + expect(res.status).toBe(200); + expect(body.ports[0].path).toMatch(/by-id/); + }); + + it('rejects a malformed job upload', async () => { + const res = await fetch(`${base}/api/jobs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'x', plotterName: 'p', layers: [] }), + }); + expect(res.status).toBe(400); + }); + + it('uploads a whole job once and streams only progress back', async () => { + // The point of the design: the G-code crosses the network once, and the + // send loop runs next to the USB cable. + const upload = await fetch(`${base}/api/jobs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(sampleJob), + }); + expect(upload.status).toBe(201); + const { job } = (await upload.json()) as { job: JobSummary }; + expect(job.totalLines).toBe(7); + // The summary carries no G-code, only the shape of it. + expect(job.layers[0]).toEqual({ name: 'Ink', color: '#000000', lineCount: 2 }); + + const client = await TestClient.connect(wsUrl); + expect((await client.request({ type: 'connect', portPath: '/dev/fake' })).ok).toBe(true); + board.writes.length = 0; + + expect((await client.request({ type: 'job.start', jobId: job.id })).ok).toBe(true); + await client.waitFor((m) => m.type === 'job' && m.job?.state === 'awaiting_pen_swap'); + + const swap = client.received.filter((m) => m.type === 'job').at(-1); + expect(swap).toMatchObject({ job: { nextLayer: { name: 'Red', color: '#ff0000' } } }); + + expect((await client.request({ type: 'job.continue' })).ok).toBe(true); + await hub.runner.settled(); + await client.waitFor((m) => m.type === 'job' && m.job?.state === 'done'); + + expect(board.written).toEqual([ + 'G21', + 'G90', + 'G0 X1 Y1', + 'G1 X2 Y2', + 'G0 X3 Y3', + 'G1 X4 Y4', + 'M84', + ]); + client.close(); + }); + + it('stops the machine over plain HTTP when the socket is not an option', async () => { + const client = await TestClient.connect(wsUrl); + await client.request({ type: 'connect', portPath: '/dev/fake' }); + board.writes.length = 0; + + const res = await fetch(`${base}/api/estop`, { method: 'POST' }); + expect(res.status).toBe(200); + expect(board.written).toContain('M112'); + client.close(); + }); + + it('frees control when the controlling socket drops', async () => { + const a = await TestClient.connect(wsUrl); + const b = await TestClient.connect(wsUrl); + expect((await b.request({ type: 'connect', portPath: '/dev/fake' })).ok).toBe(false); + + a.close(); + await b.waitFor((m) => m.type === 'session' && m.session.clients.length === 1); + expect((await b.request({ type: 'control.claim' })).ok).toBe(true); + b.close(); + }); + + it('answers an unknown message with an error rather than closing', async () => { + const client = await TestClient.connect(wsUrl); + const ack = await client.request({ type: 'nope' } as unknown as ClientMessage); + expect(ack.ok).toBe(false); + expect(ack.error).toMatch(/Unknown message type/); + client.close(); + }); +}); diff --git a/paint-app/server/src/server.ts b/paint-app/server/src/server.ts new file mode 100644 index 0000000..0a6732c --- /dev/null +++ b/paint-app/server/src/server.ts @@ -0,0 +1,170 @@ +import type { Server } from 'node:http'; +import express, { type Express } from 'express'; +import { WebSocket, WebSocketServer } from 'ws'; +import type { Config } from './config.js'; +import type { Hub } from './hub.js'; +import { jobUploadSchema, summarize } from './jobs.js'; +import { listPorts } from './ports.js'; +import type { ClientMessage } from './protocol.js'; + +/** Dead sockets hold control hostage, so they get reaped rather than trusted. */ +const HEARTBEAT_MS = 30_000; + +export type PortLister = () => Promise>>; + +export type BuildOptions = { + hub: Hub; + config: Config; + version: string; + /** Injectable so the API can be exercised without a real /dev. */ + ports?: PortLister; +}; + +/** + * REST for the things a request/response shape suits — enumerating ports, + * uploading a job, reading state. The live session is on the WebSocket, because + * polling for a `tx`/`rx` log at Marlin's line rate is absurd and because a jog + * or an emergency stop should not wait on a new TCP connection. + */ +export function buildApp({ hub, config, version, ports = listPorts }: BuildOptions): Express { + const app = express(); + app.disable('x-powered-by'); + + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', config.CORS_ORIGIN); + res.setHeader('Access-Control-Allow-Headers', 'content-type'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS'); + next(); + }); + app.options(/.*/, (_req, res) => res.sendStatus(204)); + + // A page of art is megabytes of G-code, uploaded in one shot. That is the + // entire point of the design: the alternative is a network round trip per + // line, thousands of times over. + app.use(express.json({ limit: config.MAX_UPLOAD })); + + app.get('/api/health', (_req, res) => { + res.json({ ok: true, version, uptime: process.uptime() }); + }); + + app.get('/api/ports', async (_req, res) => { + try { + res.json({ ports: await ports() }); + } catch (e) { + res.status(500).json({ error: (e as Error).message }); + } + }); + + app.get('/api/state', (_req, res) => { + res.json(hub.snapshot()); + }); + + app.get('/api/jobs', (_req, res) => { + res.json({ jobs: hub.jobs.list() }); + }); + + app.post('/api/jobs', (req, res) => { + const parsed = jobUploadSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid job', issues: parsed.error.issues }); + return; + } + const job = hub.jobs.add(parsed.data); + res.status(201).json({ job: summarize(job) }); + }); + + app.get('/api/jobs/:id', (req, res) => { + const job = hub.jobs.get(req.params.id); + if (!job) { + res.status(404).json({ error: 'No such job' }); + return; + } + // The G-code is only sent back when asked for; it is the bulk of the job + // and no view needs it. + res.json(req.query.lines === '1' ? { job } : { job: summarize(job) }); + }); + + app.delete('/api/jobs/:id', (req, res) => { + if (hub.runner.view?.job.id === req.params.id && hub.runner.isActive) { + res.status(409).json({ error: 'That job is running' }); + return; + } + res.status(hub.jobs.delete(req.params.id) ? 204 : 404).end(); + }); + + /** + * Emergency stop over plain HTTP as well as the socket. If the WebSocket is + * wedged — which is exactly when you most want to stop the machine — a `curl` + * or a bookmark still works. + */ + app.post('/api/estop', async (_req, res) => { + try { + await hub.emergencyStop(); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ ok: false, error: (e as Error).message }); + } + }); + + if (config.CLIENT_DIR) { + app.use(express.static(config.CLIENT_DIR)); + // SPA fallback, but never for the API. + app.get(/^(?!\/api\/).*/, (_req, res) => { + res.sendFile('index.html', { root: config.CLIENT_DIR }); + }); + } + + return app; +} + +/** Attach the session WebSocket to an already-listening HTTP server. */ +export function attachWebSocket(server: Server, hub: Hub) { + const wss = new WebSocketServer({ server, path: '/ws' }); + + wss.on('connection', (socket) => { + const client = hub.addClient((msg) => { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(msg)); + }); + let alive = true; + socket.on('pong', () => { + alive = true; + }); + + socket.on('message', async (raw) => { + let msg: ClientMessage; + try { + msg = JSON.parse(raw.toString()); + } catch { + socket.send(JSON.stringify({ type: 'ack', id: '', ok: false, error: 'Malformed JSON' })); + return; + } + const result = await hub.handle(client.id, msg); + if (msg.id) { + socket.send( + JSON.stringify({ + type: 'ack', + id: msg.id, + ok: result.ok, + error: result.error, + data: result.data, + }), + ); + } + if (msg.type === 'ping') socket.send(JSON.stringify({ type: 'pong' })); + }); + + socket.on('close', () => hub.removeClient(client.id)); + + const heartbeat = setInterval(() => { + if (!alive) { + socket.terminate(); + return; + } + alive = false; + socket.ping(); + }, HEARTBEAT_MS); + socket.on('close', () => clearInterval(heartbeat)); + }); + + return wss; +} diff --git a/paint-app/server/src/test/fakeMarlin.ts b/paint-app/server/src/test/fakeMarlin.ts new file mode 100644 index 0000000..104eba8 --- /dev/null +++ b/paint-app/server/src/test/fakeMarlin.ts @@ -0,0 +1,193 @@ +import type { SerialTransport, TransportFactory } from '../transport.js'; + +export type WriteRecord = { line: string; at: number; seq: number }; + +export type FakeMarlinOptions = { + /** Fail this many `open()` calls before the first success. */ + failOpens?: number; + /** Report `isOpen` before anything opened it — a handle nobody released. */ + startsOpen?: boolean; + /** Answer nothing, ever. A board that has been killed. */ + dead?: boolean; + /** Extra reply lines emitted before `ok`, keyed by the command prefix. */ + replies?: Record; + /** + * Commands that emit `echo:busy: processing` every `busyIntervalMs` for this + * long before their `ok`. This is what G28 does on a real board. + */ + busy?: Record; + busyIntervalMs?: number; + /** Delay before the `ok` for an ordinary command. */ + replyDelayMs?: number; + /** Emit no reply at all for these commands. */ + silent?: string[]; +}; + +const DEFAULT_REPLIES: Record = { + M115: ['FIRMWARE_NAME:Marlin 2.1.2 (GitHub) SOURCE_CODE_URL:...', 'Cap:EMERGENCY_PARSER:1'], + M114: ['X:10.00 Y:20.00 Z:5.00 E:0.00 Count X:800 Y:1600 Z:2000'], +}; + +/** + * A Marlin board that lives in the test process. + * + * It answers `ok`, banners for `M115`, a position for `M114`, and pulses + * `echo:busy` for slow moves — the four behaviours `PlotterConnection` was + * written around. Every write is timestamped and sequenced so tests can assert + * on *ordering*, which is the only way to prove the emergency stop actually + * jumped the queue. + */ +export class FakeMarlin { + readonly writes: WriteRecord[] = []; + isOpen: boolean; + openAttempts = 0; + /** Set once M112 arrives; the board stops answering, as a killed one does. */ + killed = false; + + private dataCb: (chunk: string) => void = () => {}; + private closeCb: () => void = () => {}; + private errorCb: (err: Error) => void = () => {}; + private seq = 0; + private timers = new Set(); + private readonly opts: Required< + Pick + > & + FakeMarlinOptions; + + constructor(opts: FakeMarlinOptions = {}) { + this.opts = { busyIntervalMs: 10, replyDelayMs: 1, silent: [], ...opts }; + this.isOpen = Boolean(opts.startsOpen); + } + + /** Hand this to `PlotterConnection` in place of the real serial transport. */ + get transport(): TransportFactory { + return () => this.asTransport(); + } + + asTransport(): SerialTransport { + // Captured for the `isOpen` getter below, which cannot be an arrow. + const self = this; + return { + get isOpen() { + return self.isOpen; + }, + open: async () => { + this.openAttempts++; + if (this.opts.failOpens && this.openAttempts <= this.opts.failOpens) { + throw new Error('Error: Resource temporarily unavailable, cannot open'); + } + this.isOpen = true; + }, + close: async () => { + if (!this.isOpen) return; + this.isOpen = false; + this.clearTimers(); + this.closeCb(); + }, + write: async (data: string) => { + if (!this.isOpen) throw new Error('Port is not open'); + for (const raw of data.split('\n')) { + const line = raw.trim(); + if (line) this.accept(line); + } + }, + onData: (cb) => { + this.dataCb = cb; + }, + onClose: (cb) => { + this.closeCb = cb; + }, + onError: (cb) => { + this.errorCb = cb; + }, + }; + } + + /** Rip the cable out. */ + unplug() { + if (!this.isOpen) return; + this.isOpen = false; + this.clearTimers(); + this.errorCb(new Error('device disconnected')); + this.closeCb(); + } + + emit(line: string) { + this.dataCb(`${line}\n`); + } + + /** Every line written, in order. */ + get written() { + return this.writes.map((w) => w.line); + } + + /** Lines written after the first occurrence of `line`. */ + writtenAfter(line: string) { + const i = this.written.indexOf(line); + return i < 0 ? null : this.written.slice(i + 1); + } + + private later(fn: () => void, ms: number) { + const t = setTimeout(() => { + this.timers.delete(t); + fn(); + }, ms); + this.timers.add(t); + return t; + } + + private clearTimers() { + for (const t of this.timers) clearTimeout(t); + this.timers.clear(); + } + + private accept(line: string) { + this.writes.push({ line, at: Date.now(), seq: this.seq++ }); + + if (line.toUpperCase().startsWith('M112')) { + // Marlin's emergency parser acts on M112 out of band, then the firmware + // halts. The USB device stays enumerated; it just stops talking. + this.killed = true; + this.clearTimers(); + return; + } + if (this.killed || this.opts.dead) return; + if (this.opts.silent?.some((s) => line.toUpperCase().startsWith(s.toUpperCase()))) return; + + const key = Object.keys({ ...DEFAULT_REPLIES, ...this.opts.replies }).find((k) => + line.toUpperCase().startsWith(k.toUpperCase()), + ); + const extra = key ? ({ ...DEFAULT_REPLIES, ...this.opts.replies }[key] ?? []) : []; + + const busyKey = Object.keys(this.opts.busy ?? {}).find((k) => + line.toUpperCase().startsWith(k.toUpperCase()), + ); + const busyFor = busyKey ? (this.opts.busy?.[busyKey] ?? 0) : 0; + + if (busyFor > 0) { + const interval = this.opts.busyIntervalMs; + for (let t = interval; t < busyFor; t += interval) { + this.later(() => this.emit('echo:busy: processing'), t); + } + this.later(() => { + for (const l of extra) this.emit(l); + this.emit('ok'); + }, busyFor); + return; + } + + this.later(() => { + for (const l of extra) this.emit(l); + this.emit('ok'); + }, this.opts.replyDelayMs); + } +} + +/** Timings small enough that a full connect takes milliseconds. */ +export const FAST_TIMING = { + bootWaitMs: 5, + reopenDelayMs: 5, + openRetryDelayMs: 5, + defaultIdleTimeoutMs: 500, + probeTimeoutMs: 200, +}; diff --git a/paint-app/server/src/transport.ts b/paint-app/server/src/transport.ts new file mode 100644 index 0000000..4733671 --- /dev/null +++ b/paint-app/server/src/transport.ts @@ -0,0 +1,72 @@ +import { createRequire } from 'node:module'; + +/** + * The byte pipe under `PlotterConnection`. + * + * `PlotterConnection` is the part with all the Marlin knowledge in it, and it + * is the part worth testing. Hiding `serialport` behind this interface lets the + * tests drive a scripted firmware instead of a real board, and keeps the actual + * native binding down to the ~40 lines below. + */ +export interface SerialTransport { + /** True while the underlying device handle is held open. */ + readonly isOpen: boolean; + open(): Promise; + close(): Promise; + write(data: string): Promise; + onData(cb: (chunk: string) => void): void; + /** The handle went away — either we closed it or the device vanished. */ + onClose(cb: () => void): void; + onError(cb: (err: Error) => void): void; +} + +export type TransportFactory = (path: string, baudRate: number) => SerialTransport; + +// `serialport` is a native module. Loading it lazily keeps `import`ing anything +// from this package (the protocol types, the tests) from touching the binding, +// which is the difference between a clear "no prebuild for this platform" at +// connect time and an unexplained crash at startup. +const requireCjs = createRequire(import.meta.url); + +/** Real hardware. */ +export const nodeTransport: TransportFactory = (path, baudRate) => { + // biome-ignore lint/suspicious/noExplicitAny: CJS interop for a native module + const { SerialPort } = requireCjs('serialport') as any; + + const port = new SerialPort({ path, baudRate, autoOpen: false }); + let closeCb: () => void = () => {}; + + // `serialport` emits 'close' both for our own close() and for a yanked USB + // cable (with `err.disconnected`). `PlotterConnection` already distinguishes + // the two via its own intentional-close flag, so both funnel to one callback. + port.on('close', () => closeCb()); + + return { + get isOpen() { + return Boolean(port.isOpen); + }, + open: () => + new Promise((resolve, reject) => { + port.open((err: Error | null) => (err ? reject(err) : resolve())); + }), + close: () => + new Promise((resolve) => { + if (!port.isOpen) return resolve(); + port.close(() => resolve()); + }), + write: (data) => + new Promise((resolve, reject) => { + port.write(data, (err: Error | null | undefined) => { + if (err) return reject(err); + // Without the drain the next line can be handed to the kernel before + // this one has left, which Marlin's line buffer does not appreciate. + port.drain((derr: Error | null | undefined) => (derr ? reject(derr) : resolve())); + }); + }), + onData: (cb) => port.on('data', (chunk: Buffer) => cb(chunk.toString('utf8'))), + onClose: (cb) => { + closeCb = cb; + }, + onError: (cb) => port.on('error', cb), + }; +}; diff --git a/paint-app/server/tsconfig.build.json b/paint-app/server/tsconfig.build.json new file mode 100644 index 0000000..fc94193 --- /dev/null +++ b/paint-app/server/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": ["src/**/*.test.ts", "src/test/**"] +} diff --git a/paint-app/server/tsconfig.json b/paint-app/server/tsconfig.json new file mode 100644 index 0000000..1492c35 --- /dev/null +++ b/paint-app/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/paint-app/server/vitest.config.ts b/paint-app/server/vitest.config.ts new file mode 100644 index 0000000..84e7bc0 --- /dev/null +++ b/paint-app/server/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +// Without a config here, vitest climbs the tree and finds the renderer's +// `paint-app/vite.config.ts`, which pulls in React plugins this package has no +// reason to install. It only works locally because the parent's node_modules +// happens to be there. +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});