Skip to content

feat: Adds a self-hostable Node.js relay#1648

Open
opengspace wants to merge 6 commits into
getpaseo:mainfrom
opengspace:main
Open

feat: Adds a self-hostable Node.js relay#1648
opengspace wants to merge 6 commits into
getpaseo:mainfrom
opengspace:main

Conversation

@opengspace

Copy link
Copy Markdown

Linked issue

Closes #

Type of change

  • Bug fix
  • New feature (with prior issue + design alignment)
  • Refactor / code improvement
  • Docs

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:

  • Ported packages/relay/src/cloudflare-adapter.ts to a Node.js HTTP + ws server implementation.
  • Preserved the existing v1/v2 wire protocol, including:
    • /ws and /health endpoints
    • control/data socket routing
    • frame buffering behavior
    • close codes
    • legacy JSON-ping compatibility
  • Added the @getpaseo/relay-node CLI (paseo-relay-node) for standalone deployment.
  • Added Dockerfile and docker-compose examples for self-hosting.
  • Added docs/relay-node.md deployment guide.
  • Added references in architecture.md and CLAUDE.md.

This provides a lightweight self-hosted alternative to the Cloudflare relay with ws as the only runtime dependency, making it easier to deploy on standard Node.js environments.

How did you verify it

Verified locally by:

  1. Starting the relay via the paseo-relay-node CLI.
  2. Confirming /health responds successfully.
  3. Establishing websocket connections through /ws.
  4. Verifying control and data sockets are routed correctly.
  5. Testing compatibility with existing clients using both v1 and v2 protocols.
  6. Confirming legacy JSON-ping behavior remains unchanged.
  7. Running the relay through Docker and validating connectivity behind a TLS reverse proxy.

Checklist

  • One focused change. Unrelated cleanups split out.
  • npm run typecheck passes
  • npm run lint passes
  • npm run format ran (Biome)
  • UI changes include screenshots or video for every affected platform
  • Tests added or updated where it made sense

opengspace and others added 2 commits June 21, 2026 23:09
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
@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds packages/relay-node (@getpaseo/relay-node), a self-hostable Node.js relay that mirrors the Cloudflare Durable Object relay wire protocol. It is a new, self-contained package with no changes to existing packages beyond registration in the monorepo and documentation updates.

  • Core implementation (relay-server.ts, relay-session.ts, tagged-sockets.ts): faithfully ports the Cloudflare Worker entry point and RelayDurableObject to a Node.js HTTP + ws server. v1/v2 protocol, frame buffering, control notifications, eviction, and the nudgeOrResetControl timer all match the Cloudflare behavior.
  • Public surface (index.ts): deliberately narrow — createRelayServer + createLogger only; internals are not re-exported. Module shape is clean and passes the deletion test.
  • Tests (relay-server.test.ts): integration tests spin up the real server on an ephemeral port and exercise v1 forwarding, v2 client-connect notifications, frame buffering, client-disconnect teardown, legacy JSON-ping, and control-socket replacement — good coverage of the happy paths.

Confidence Score: 5/5

Safe to merge — entirely new self-contained package with no changes to existing runtime code paths.

The change is a new, isolated package that does not touch any existing packages beyond doc and workspace registration. The relay logic is a faithful port of the well-understood Cloudflare implementation, the module shape is clean with a narrow public surface, and integration tests exercise the main v1/v2 protocol paths against a real running server. No correctness issues were found in the session lifecycle, frame buffering, eviction, or upgrade validation.

No files require special attention; the style nits in relay-session.ts and relay-server.test.ts are non-blocking.

Important Files Changed

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"}"
Loading
%%{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"}"
Loading

Reviews (4): Last reviewed commit: "fix(relay-node): include tsconfig.json i..." | Re-trigger Greptile

Comment thread packages/relay-node/src/types.ts Outdated
Comment thread packages/relay-node/src/index.ts Outdated
opengspace and others added 3 commits June 22, 2026 09:06
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>
Comment thread packages/relay-node/.dockerignore Outdated
.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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant