Curated from the repo’s CLAUDE.md and DESIGN.md

Field notes.

Things that only appear once you build the thing. Every entry below is either a bug that shipped while the tests stayed green, or an obvious design that died on contact with a real browser, a real resolver or a real user. They are recorded so the next person does not pay twice, and most now have a test whose only job is to make the mistake unrepeatable.

  1. 01 resolver §

    Servers may bind IPv6 loopback only.

    listen(port, 'localhost') binds whichever family the resolver returns first, and on modern macOS that is ::1. Vite does exactly this. Readiness therefore probes both 127.0.0.1 and ::1, records which one answered, and the gateway proxies to that host. Miss the last step and a perfectly healthy app 502s.

    guarded by npm run test:vite

  2. 02 chrome match patterns §

    A * scheme means http or https, and nothing else.

    So *://*.reef.localhost/* never matched a WebSocket handshake. Every upgrade reached the gateway with no credential and was destroyed: HMR was dead in the real app while the Vite test stayed green, because that harness sets the auth header itself. The filter now names ws:// and wss:// explicitly. A Node client is not a browser.

    guarded by test/electron/iframe-websocket.mjs · relive it with HTTP_ONLY_FILTER=1

  3. 04 origins §

    The gateway port is identity, not plumbing.

    An app’s origin is <id>.reef.localhost:<port>, and localStorage keys to the full origin. listen(0) minted a new port per launch, which minted a new origin per launch, which stranded every app’s data under the previous origin; a real user lost a day’s entries. The port is pinned and persisted, and startup refuses to fall back to another port when it is taken, because a fallback would “work” while silently swapping every app’s storage out from under it.

    guarded by test/electron/iframe-storage.mjs · relive it with REEF_STORAGE_DRIFT=1

  4. 05 node http §

    Upgraded sockets escape closeAllConnections().

    After an HTTP upgrade Node detaches the socket from the server’s tracked list, but getConnections() still counts it, so server.close() waits forever. The gateway tracks upgraded sockets in its own set and destroys them on teardown. Any server that accepts upgrades needs the same treatment.

  5. 06 macos §

    A GUI launch inherits no shell environment.

    An app launched from Finder or the Dock has no PATH worth having: node, npm and uv are simply missing, and so is ANTHROPIC_API_KEY. The supervisor resolves PATH once from a login shell and caches it, and the API key gets a home in Settings. Both are consequences of the same fact, and both look like something else when they bite.

  6. 07 processes §

    Signalling the shell orphans the server.

    npm start is really sh → npm → node. Kill the shell and the process actually holding the port survives as an orphan, still bound, still answering. Children spawn detached: true into their own process group and are signalled as a group.

  7. 08 readiness §

    Vite ignores PORT.

    It uses server.port, default 5173; Next.js honours the variable. Readiness therefore runs two strategies concurrently: a TCP probe on the assigned port, and sniffing stdout for whatever loopback URL the server announces, adopting its port. Whichever answers first wins. Removing either one breaks a real framework.

  8. 09 fsevents §

    FSEvents lies twice at watcher start.

    fs.watch(dir, {recursive: true}) on macOS replays a beat of pre-watch history the moment the stream opens: the folder’s own creation, files written before launch. Separately, it can report events with a null filename or the watched directory’s own basename. The watcher swallows a settle window after watch() and treats anonymous events as ignorable; without both, every frame reloaded the moment it opened.

    guarded by test/watcher.test.js

  9. 10 permissions policy §

    A framed app has no microphone unless the frame says so.

    Permissions Policy defaults microphone and camera to self, the parent’s origin, so an app on *.reef.localhost inside the file:// renderer is denied the device before any prompt exists. getUserMedia rejects with NotAllowedError and no amount of clicking allow helps. It looks like a macOS problem or a gateway problem and is neither: the manifest’s mic declaration has to become the iframe’s allow attribute and Electron’s permission-request answer. Both gates have to open.

    guarded by test/electron/iframe-media.mjs · relive it with DECLARE=0

  10. 11 css §

    A minimized window reads every offset as zero.

    display: none zeroes every offset* property, so persisting geometry from a parked window would wipe its real bounds the moment it was minimized. Session persistence reads the inline style.left/top/width/height instead, which survive being hidden.

    guarded by test/electron/session-restore.mjs

  11. 12 iframes §

    A click inside a cross-origin frame tells the parent nothing.

    The event dies in the frame’s own document, so a back window could never be raised by clicking its content, only its titlebar. Each unfocused window carries a transparent click-catcher that intercepts exactly one click in the parent document, focuses the window, then vanishes so the focused app pays no pointer tax. The test asserts the effect (which window paints on top, via elementFromPoint), not the class the code sets.

  12. 13 claude api §

    A refusal arrives as HTTP 200.

    stop_reason: "refusal" comes back as a success with an empty or partial content array, so content[0].text throws if you check the body before the stop reason. Two more from the same battlefield: max_tokens caps thinking and output together, and a 64k-token request at the highest effort ran long enough that the socket died with undici terminated. The stream’s events must be drained, not just awaited, to keep the socket reading through a long generation.

  13. 14 source code §

    Control characters do not survive being written into source.

    A literal ESC or NUL gets mangled somewhere between an editor, a diff and a clipboard. Anything that needs one builds it with String.fromCharCode at runtime. The markdown renderer generating this very page does the same for its placeholder sentinels.

Assert the effect a user sees, not the mechanism you just wrote.

The pattern behind every entry on this page. A test that checks the state your code sets will pass while the user watches the feature fail.