Commit Graph
3 Commits
Author SHA1 Message Date
pastilhas e11e0b6475 one port announcement instead of sixteen, so a plugin can name itself
protocol.ts declared a separate SidecarEvent member per sidecar — music, vault,
slskd, headscale, transmission, invoiceshelf, jellyfin, photos, memos, gitea,
caldav, wallet, pty, email, notify, opencode — each an identical one-liner. Two
of them were for plugins that had already left the repository, which is the
tell: the platform was declaring event types on behalf of code it no longer
contains.

The cost was that a plugin could not announce its own port. `connection.send`
takes a SidecarEvent, so `{ type: 'notes:server', port }` did not typecheck
until someone added a line to the platform. A third-party plugin needed a pull
request against Officer before its sidecar would compile — for the one message
every HTTP sidecar has to send.

Now:

  export type SidecarPortAnnouncement = { type: `${string}:server`; port: number };

Nothing was lost at the point of use. The only consumer is
`sidecar.on(`${name}:server`, …)` in create-proxy.ts, which already cast to
read `.port`, because a handler typed `(event: SidecarEvent) => void` cannot
narrow from a computed template string.

The proof is a test that already existed: create-proxy.test.ts announces
`testcar:server` — a name never in the union — and now compiles without its
`as never`.

Removing the two casts that escaped the old type found something else. They
were not hiding union membership, they were hiding `port: number | undefined`:
Bun types Server.port as optional because a unix-socket server has none. So
`as SidecarEvent` on the whole object was suppressing a real nullability
warning, and would have suppressed a genuinely wrong event shape too. Replaced
with a narrow assertion on the port alone. Two further `as SidecarEvent` casts
in the same file turned out to be unnecessary altogether and are gone.

[open] The typo check is genuinely gone: a sidecar sending `muzik:server` while
its proxy listens for `music:server` now compiles, and the symptom is every
request answering 503 forever. The fix is not to restore the list — it is for
createSidecarConnector to announce the port itself from the `name` it already
holds, so the string is written once and the typo becomes unrepresentable.
Recorded in protocol.ts.

Verified live: officer and officer-music restarted, `[music] sidecar registered
on port 38803`, /api/music/manifest and /api/offscale/_health both 200.
tsgo clean, 797 tests, 787 pass, same 7.
2026-08-15 14:49:19 +00:00
pastilhasandClaude Opus 5 da5cd8e918 stop losing the answer when immich rejects mid-upload
An upload over a few MB came back as a 400 with an empty body, no message anywhere, and nothing logged
by Immich, the sidecar or officer. It was a race, not a size limit. Immich judges an asset from its
first few KB and rejects immediately, then closes; both our hops were still writing the body; Node
treats the leftover bytes as a protocol violation and replaces the application's answer with a bodyless
`400 Bad Request` + `Connection: close`. The real message never reached the wire.

Measured before the change: streamed lost the message 1/4 at 8 MB and 4/4 at 32 MB — probability rising
with size, which is why small photos usually worked and a phone's video never did.

Both hops needed it. Fixing only the sidecar took 32 MB from 4/4 failing to 2/4, because the platform
proxy was losing it one hop up.

Bounded at 512 MB, above which the body streams exactly as before. That ceiling is not a refusal and is
deliberately not a 413: a file Immich ACCEPTS is read to the end and never races, so a 4 GB video is
unaffected. All that is given up above the cap is the error message on a file that was going to be
rejected anyway. A first attempt refused over-cap uploads outright and would have broken the working
4 GB case to improve diagnosis of the doomed one.

`bufferRequestBody` is opt-in and off by default: the vault and wallet proxies must keep streaming so a
passphrase or macaroon never lands in the platform's heap.

Also adds the proxy error logging that made this findable at all — status and two byte counts from
headers, never the bodies. `responseBytes: "unknown"` is what exposed the stripped response.

Verified live at 32/256 MB (buffered) and 640 MB (streamed, passes through).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:22:56 +00:00
pastilhasandClaude Opus 5 91898733a4 stop parsing request bodies on sidecar proxy routes
every multipart upload through /api/<sidecar>/* arrived corrupted. bodyParser ran on
proxy routes and called parseBody for multipart, so hono cached a FormData on the
request; when the proxy then asked for the bytes hono re-serialised them from that
cache with a NEW boundary, while the proxy still forwarded the ORIGINAL content-type
header. header and body disagreed and the far side rejected it with
"Multipart: Unexpected end of form".

bodyParser now skips prefixes owned by createSidecarProxy, which register themselves
so a new sidecar cannot forget. the proxy also forwards the body as a stream instead
of buffering it, which drops the second in-memory copy of every upload.

note the bug report proposed skipping multipart in bodyParser outright; that would
have broken /upload, /file-browser upload and /bug-report, which do read a multipart
body from ctx.get('body').

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 03:22:37 +00:00