feat: Adds a self-hostable Node.js relay#1648
Conversation
A self-hostable, protocol-compatible Node.js relay mirroring the Cloudflare relay (packages/relay) — pure `ws` behind a TLS reverse proxy, with `ws` as the only runtime dependency. - ports packages/relay/src/cloudflare-adapter.ts to a Node http + ws server, preserving the v1/v2 wire protocol (/ws + /health, control/data socket routing, frame buffering, close codes, the legacy JSON-ping COMPAT) - CLI (@getpaseo/relay-node / paseo-relay-node) + Dockerfile + docker-compose - docs/relay-node.md deploy guide; architecture.md + CLAUDE.md cross-links Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(relay-node): add Node.js version of the relay
|
| Filename | Overview |
|---|---|
| packages/relay-node/src/relay-session.ts | Core session logic — faithful port of the Cloudflare Durable Object. v1/v2 routing, frame buffering, control notifications, and eviction all look correct. Decorative section-divider comments are noise per project style rules. |
| packages/relay-node/src/relay-server.ts | HTTP + WebSocket server entry point — clean, small, correct. safeUrl, respond, and refuse helpers are well-scoped. Upgrade validation (role, serverId, version) is thorough. |
| packages/relay-node/src/relay-server.test.ts | Integration tests spin up the real server on an ephemeral port — correct testing strategy. Gaps: validation reject-reason assertions are weak (toBeDefined), and server-data-disconnect → client 1012 path is not exercised. |
| packages/relay-node/src/tagged-sockets.ts | Multi-tag WebSocket index replacing the Cloudflare hibernation API. Add, get, has, remove, and size are all correct. Bucket cleanup (empty-set deletion) prevents stale entries. |
| packages/relay-node/src/index.ts | Designed public surface: exports only createRelayServer, RelayServerOptions, createLogger, Logger, and LogLevel — not a barrel. Internal helpers (RelaySession, TaggedSocketIndex, loadConfig) are not re-exported. |
| packages/relay-node/Dockerfile | Multi-stage build — builder compiles TS, runner installs prod-only deps. tsconfig.json is correctly included in the build context (not in .dockerignore). |
| packages/relay-node/src/version.ts | Protocol version resolution — absent v defaults to v1 (legacy compatibility), unknown v returns null for 400. Clean and well-documented. |
| packages/relay-node/src/config.ts | Zero-dependency config parsing via node:util parseArgs. Port and maxPendingFrames validators are correct. CLI flag takes precedence over env, which is the expected behaviour. |
| packages/relay-node/src/types.ts | ConnectionRole and RelaySessionAttachment types. version is required (not optional), connectionId is optional (correctly handled with ?? null throughout). |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant D as Daemon (server)
participant R as relay-node
participant C as App (client)
D->>R: "WS /ws?role=server&v=2 (control)"
R-->>D: "{type:"sync", connectionIds:[]}"
C->>R: "WS /ws?role=client&v=2"
R-->>D: "{type:"connected", connectionId:"conn_xxx"}"
D->>R: "WS /ws?role=server&v=2&connectionId=conn_xxx (data)"
R-->>D: flush buffered frames (if any)
C->>R: E2EE frame
R->>D: forward frame (via data socket)
D->>R: E2EE frame
R->>C: forward frame
C->>R: close
R->>D: close data socket (1001)
R-->>D: "{type:"disconnected", connectionId:"conn_xxx"}"
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant D as Daemon (server)
participant R as relay-node
participant C as App (client)
D->>R: "WS /ws?role=server&v=2 (control)"
R-->>D: "{type:"sync", connectionIds:[]}"
C->>R: "WS /ws?role=client&v=2"
R-->>D: "{type:"connected", connectionId:"conn_xxx"}"
D->>R: "WS /ws?role=server&v=2&connectionId=conn_xxx (data)"
R-->>D: flush buffered frames (if any)
C->>R: E2EE frame
R->>D: forward frame (via data socket)
D->>R: E2EE frame
R->>C: forward frame
C->>R: close
R->>D: close data socket (1001)
R-->>D: "{type:"disconnected", connectionId:"conn_xxx"}"
Reviews (4): Last reviewed commit: "fix(relay-node): include tsconfig.json i..." | Re-trigger Greptile
index.ts was a barrel re-exporting the whole implementation (RelaySession, TaggedSocketIndex, version helpers, loadConfig, attachment types), which both violates the repo's "no barrel index.ts" rule and couples library callers to internals. Narrow the public entry to the designed surface — createRelayServer (+ RelayServerOptions), with createLogger/Logger/LogLevel so callers can inject the optional logger. The CLI imports internals directly, so it is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
version was optional but is set unconditionally by both connectV1 and connectV2 (the only attachment constructors), so the optionality was never exercised. The defensive `attachment.version ?? LEGACY_RELAY_VERSION` fallback in onClose was therefore unreachable dead code. Making the field required removes the false impression that an attachment can exist without a version and lets onClose read it directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.dockerignore excluded tsconfig.json, but the builder stage does
`COPY package.json tsconfig.json ./` before running tsc — so a real
`docker build` failed at that COPY ("tsconfig.json not found") before
compilation. Drop it from .dockerignore; the builder needs it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Linked issue
Closes #
Type of change
What does this PR do
Adds a self-hostable Node.js relay that mirrors the existing Cloudflare relay behavior while remaining protocol-compatible.
Changes included:
packages/relay/src/cloudflare-adapter.tsto a Node.js HTTP +wsserver implementation./wsand/healthendpoints@getpaseo/relay-nodeCLI (paseo-relay-node) for standalone deployment.docs/relay-node.mddeployment guide.architecture.mdandCLAUDE.md.This provides a lightweight self-hosted alternative to the Cloudflare relay with
wsas the only runtime dependency, making it easier to deploy on standard Node.js environments.How did you verify it
Verified locally by:
paseo-relay-nodeCLI./healthresponds successfully./ws.Checklist
npm run typecheckpassesnpm run lintpassesnpm run formatran (Biome)