npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@yuriai/relay-server

v0.2.36

Published

Self-hosted private relay server for Yuri

Readme

Yuri Relay Server

Multi-app APNs push

One Push process can deliver to multiple installed apps. YURI_PUSH_RELAY_APNS_BUNDLE_ID is the default topic for historical registrations without an app identity. Keep that default unchanged when retaining old clients. Add explicitly allowed apps with YURI_PUSH_RELAY_APNS_APPS_JSON:

environment:
  YURI_PUSH_RELAY_APNS_BUNDLE_ID: com.example.legacy
  YURI_PUSH_RELAY_APNS_KEY_ID: ${APNS_KEY_ID}
  YURI_PUSH_RELAY_APNS_TEAM_ID: ${APPLE_TEAM_ID}
  YURI_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_default
  YURI_PUSH_RELAY_APNS_APPS_JSON: >-
    [{"bundleId":"com.yee94.yuri"}]

An entry with only bundleId shares the default APNs key (the key must authorize that app and environment). For a separate key, provide all of keyId, teamId and p8Path (or p8) in that entry. Mount key files read-only. This uses APNs authentication .p8 keys, not Apple Distribution / Developer ID .p12 files. Each app has isolated JWT and HTTP/2 connection state; alert and Live Activity topics follow the stored app binding. Unknown apps fail registration; removed configurations do not fall back to another app's key or invalidate their tokens.

Upgrade Relay first, then the Host and mobile web bundle. New native registrations read the installed shell's Bundle ID and the Host signs it into the Relay binding. An old Host cannot forward this identity, and old clients without it cannot be distinguished automatically. Reopen upgraded clients to re-register. Existing explicit bindings survive older registration requests without being downgraded. No alternate-topic probing is performed. Single-app direct Host APNs remains single-app and skips explicitly mismatched tokens without deleting them.

The SQLite migration adds a nullable bundle_id column without deleting existing rows. Legacy rows retain default-topic behavior until re-registration. Back up the database before rollout; an old writer does not understand explicit app bindings, so rollbacks require the matching pre-upgrade database and single-app deployment. Use one process per database and a persistent volume (not a temporary directory).

For a Docker-only release with an independent Relay package version, run the Relay Docker workflow with version matching this package, push=true, and require_root_version_match=false. The default remains true for full releases.

yuri-relay is the self-hosted Layer 1 relay for Yuri remote access. It gives Yuri Hosts an outbound Relay connection and carries encrypted client traffic through that connection.

The same @yuriai/relay-server package also ships yuri-push-relay, an isolated APNs push process. Layer 1 never receives Apple credentials. Push never terminates E2EE tunnels.

The Relay routes opaque Layer 2/3 frames verbatim. E2EE terminates at the Yuri Host and Client, so the Relay has routing metadata and transport state while the endpoints hold application plaintext, pairing secrets, and client bearer credentials. Host Relay connections authenticate with the Host's long-lived P-256 signing key. Relay reachability grants transport access; endpoint validation continues to enforce pairing and client credentials.

Architecture and transport

The components have the following responsibilities:

  • Host maintains an authenticated control WebSocket to the Relay, receives client connection requests, and opens matching Host data WebSockets.
  • Control channel associates a Host identity with its active route and communicates connection lifecycle events.
  • Data channel pairs one Host data WebSocket with one Client WebSocket and forwards opaque encrypted frames in both directions.
  • Client requests a route to a Host and multiplexes application traffic through its encrypted Relay tunnel.

The tunneled application transport supports HTTP, streaming SSE, and WebSocket traffic. HTTP and SSE use the Client bearer credential through the encrypted tunnel. WebSockets use a short-lived URL-scoped credential (yuri_url_token) minted by the Host endpoint.

Relay state lives in process memory. Hosts reconnect after a Relay restart. A disconnected Host control connection retains its route during the configurable 30-second grace period.

Relay v1 accepts anonymous Client route requests. Admission, connection, frame, queue, and socket limits bound this public entry point.

Dual-process security boundary

Layer 1 (yuri-relay) and Push (yuri-push-relay) are separate processes in one package:

  • Layer 1 authenticates Hosts, brokers WebSocket routes, and forwards opaque frames. It has no APNs key, no device-token database, and no /v1/push/* handlers.
  • Push verifies Host signatures, binds token → serverId in a local SQLite database, and holds the project APNs .p8 key. It has no access to Relay tunnels, pairing secrets, or client bearer credentials.
  • Deploy them as two containers from the same immutable image. Give APNs Key ID / Team ID / Bundle ID and the .p8 secret only to the Push container. Keep Layer 1 free of those secrets.
  • SQLite is a single-writer store. Run one Push instance per database file. Do not share that volume across replicas.

Requirements

  • @yuriai/relay-server installation: Node.js 22.13 or later and a supported package manager such as npm, pnpm, yarn, or Bun. Push uses Node's built-in node:sqlite.
  • Single-file Layer 1 bundle build: Bun. This repository uses Bun 1.3.14. Do not Bun-compile the Push entry; Docker runs it with Node 24.
  • Public deployment: a DNS name, TLS certificate, reverse proxy, and firewall policy appropriate for the deployment.

Cloudflare Workers

Layer 1 also runs on Workers with a Durable Object. Node/Docker and Workers use the same routing, P-256 authentication, replay protection, admission limits, pair lifecycle and forwarding queues in src/core.js. Only HTTP/WebSocket I/O differs. Existing Yuri Hosts and Clients do not need a protocol or encryption change. This does not move the Node APNs Push service to Workers.

From the repository root:

bun run --cwd packages/relay-server worker:dev
bun run --cwd packages/relay-server worker:build

The commands use a pinned Wrangler through npx; no Wrangler dependency is added to the application. The build command is a dry run and creates no cloud resources. For a public staging deployment, authenticate locally first:

npx --yes [email protected] login
bun run --cwd packages/relay-server worker:deploy

Alternatively supply CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID through the process environment. Use an account-scoped Workers Scripts Edit token; add Account Settings Read if needed for account discovery. Custom-domain deployment additionally needs the relevant zone permissions. Never put tokens in chat, source files, Wrangler configuration or command arguments.

The default Worker name is yuri-relay-worker; change it or pass --name before deploying if that service already exists. Wrangler prints a https://…workers.dev URL; use the same hostname as wss://…workers.dev/ws in Add a device. This does not change the product default or replace existing saved pairing endpoints. Production may use a Cloudflare-managed custom domain via Wrangler routes with custom_domain: true. Do not check personal deployment domains/account IDs into the repository. Set them through a local configuration outside the repository or the Cloudflare dashboard.

Operational boundaries:

  • A single Durable Object coordinates the deployment, preserving deployment-wide limits. This is not a horizontally sharded implementation. Active routes and replay/admission records are ephemeral, just like Node. Deployment/restart can disconnect sockets; Hosts and Clients reconnect. Do not delete/recreate the Durable Object binding or migration to perform a normal update or rollback.
  • This version uses ordinary accepted WebSockets, not Hibernation. Open connections keep the object active and can incur duration charges even when quiet. Free-plan quotas are finite; paid usage may exceed its base allocation. Do not enable a paid plan without the account owner's approval.
  • Workers exposes no TCP pause/resume, send-completion callback or documented bufferedAmount. Shared limits bound application-owned queues and frame sizes, but cannot bound or observe Cloudflare's outbound runtime buffers. Node's TCP backpressure guarantee does not apply to Workers. Benchmark slow receivers and remote-desktop traffic before adopting it as a production replacement.
  • Cloudflare handles TCP admission and ping/pong frames. Node-only raw-TCP and server-initiated heartbeat controls do not apply; existing Host/Client liveness checks still apply. Per-IP limits use Cloudflare's CF-Connecting-IP, never client-provided X-Forwarded-For.
  • Workers accepts complete frames before application code can reject them. The default shared 128-KiB frame limit is enforced after runtime receipt. Cloudflare ingress/WAF limits and account budgets remain important.
  • Only Layer 1 /ws, /healthz, and /readyz are provided. APNs notification delivery requires an existing standalone Push service plus an explicit Host YURI_PUSH_RELAY_URL, or is unavailable on this endpoint.
  • Observability and preview URLs are disabled in the supplied configuration. Do not enable logging of WebSocket query strings: they contain Host signatures and public keys. No Apple secrets, client bearer tokens or pairing keys are required for Layer 1 deployment.

YURI_RELAY_PATH defaults to /ws. Optional YURI_RELAY_LIMITS_JSON accepts an object of positive-integer overrides using the same camelCase names as Limits in src/index.d.ts. Invalid configuration fails closed. TCP-only overrides have no effect on Workers. Configure cloud variables in the dashboard or a local Wrangler config; .dev.vars and .wrangler/ are ignored.

Validate against a dedicated local or staging Worker (these tests intentionally trigger rejection limits; do not target a shared production Relay):

# Terminal 1: pin the local port.
bun run --cwd packages/relay-server worker:dev --port 18787

# Terminal 2: the same wire-contract suite runs against Node AND real workerd.
YURI_RELAY_WORKER_TEST_URL=ws://127.0.0.1:18787/ws \
  bun run --cwd packages/relay-server test:worker

# Real Host/TypeScript Client, E2EE, authenticated HTTP/SSE and remote desktop.
YURI_RELAY_WORKER_TEST_URL=ws://127.0.0.1:18787/ws \
  bunx vitest run --project @yuriai/web \
  packages/web/server/lib/relay/relay-server.e2e.test.ts -t 'across Workers'

Without YURI_RELAY_WORKER_TEST_URL, Worker network tests are explicitly skipped; adapter/unit tests and the Node protocol half still run. A local pass does not prove public DNS, regional connectivity, production quotas or slow-peer behavior.

Install and quick start

yuri-relay ships as an executable in the public @yuriai/relay-server package.

npm install -g @yuriai/relay-server
yuri-relay --public-url wss://relay.example.com/ws

The default listener is 127.0.0.1:8787 and the default WebSocket upgrade path is /ws. Keep this loopback listener behind a TLS reverse proxy and set --public-url to the public ws:// or wss:// URL with the same path.

Start the isolated Push process from the same package. The default listener is 127.0.0.1:8788. APNs Key ID, Team ID, and a .p8 value or file path are required:

export YURI_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
export YURI_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
export YURI_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.yuri
export YURI_PUSH_RELAY_APNS_P8_PATH=/etc/yuri/AuthKey.p8
yuri-push-relay --host 127.0.0.1 --port 8788

Keep Push on loopback behind the same TLS reverse proxy. Route only /v1/push/* to port 8788.

Combined mode (single port)

yuri-relay can mount Push on the same listener. If any YURI_PUSH_RELAY_APNS_* variable is non-empty, Layer 1 loads Push and serves /v1/push/* on the Relay port. Partial APNs configuration fails startup; it does not silently disable Push. With no APNs variables set, Layer 1 behavior is unchanged and Push is not loaded.

In combined mode:

  • Push routes share the Relay port at /v1/push/*.
  • /healthz and /readyz remain Layer 1 endpoints.
  • YURI_PUSH_RELAY_HOST and YURI_PUSH_RELAY_PORT are ignored.
  • yuri-push-relay is unchanged and remains the isolated two-process option.

Both modes keep token lookups in a bounded memory index and persist changes in a SQLite Worker. Registration success still means the binding is persisted; the existing database is loaded on startup without migration. Database lock waits cannot block Relay's event loop. Bounded queues reject excess Push work with HTTP 503; Worker failure disables Push authorization until the database is reloaded after a cooldown. See DOCUMENTATION.md for limits and recovery behavior. This does not isolate whole-process or host failures.

export YURI_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
export YURI_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
export YURI_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.yuri
export YURI_PUSH_RELAY_APNS_P8_PATH=/etc/yuri/AuthKey.p8
export YURI_PUSH_RELAY_DATABASE_PATH=/var/lib/yuri/push-relay.sqlite
yuri-relay --public-url wss://relay.example.com/ws

Minimal Compose environment for combined mode:

services:
  relay:
    image: yuri-relay:<version>
    environment:
      YURI_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
      YURI_PUSH_RELAY_APNS_KEY_ID: ${YURI_PUSH_RELAY_APNS_KEY_ID}
      YURI_PUSH_RELAY_APNS_TEAM_ID: ${YURI_PUSH_RELAY_APNS_TEAM_ID}
      YURI_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.yuri
      YURI_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
      YURI_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
    ports:
      - "127.0.0.1:8787:8787"

Yuri Hosts do not need a separate Push URL when they already have a Relay URL. The effective wss:// or ws:// Relay URL maps to the same host as https:// or http:// /v1/push/send (register is /v1/push/register-token). Set YURI_PUSH_RELAY_URL on the Host to override that mapping. After a Relay switch, the Host re-registers persisted device tokens and binds them again before the first send.

Build a standalone executable

Run these commands from the repository root. bun build --compile creates a single executable for the current platform and architecture.

bun build --compile --outfile ./yuri-relay ./packages/relay-server/bin/yuri-relay.js
sudo install -m 0755 ./yuri-relay /usr/local/bin/yuri-relay

Smoke-test the installed executable, process health, and readiness:

yuri-relay --version
yuri-relay --host 127.0.0.1 --port 8787 --json > /tmp/yuri-relay-startup.json 2> /tmp/yuri-relay.stderr &
relay_pid=$!
curl -fsS http://127.0.0.1:8787/healthz
curl -fsS http://127.0.0.1:8787/readyz
kill -TERM "$relay_pid"
wait "$relay_pid"

The executable remains independently deployable after compilation. @yuriai/relay-server owns its distribution and publishes the yuri-relay executable.

Configure the listener and public URL

Command flags take precedence over YURI_RELAY_SERVER_* environment variables, which take precedence over defaults.

yuri-relay \
  --host 127.0.0.1 \
  --port 8787 \
  --path /relay \
  --public-url wss://relay.example.com/relay

The public URL path and the configured Relay path must match. An IPv6 public URL uses brackets around the literal:

yuri-relay --public-url wss://[2001:db8::1]/ws

Available CLI options:

--host HOST
--port PORT
--path PATH
--public-url WS_URL
--trust-proxy | --no-trust-proxy
--json
--quiet, -q
--help, -h
--version, -v

Push CLI options:

--host HOST
--port PORT
--trust-proxy | --no-trust-proxy
--json
--quiet, -q
--help, -h
--version, -v

Connect Yuri Hosts

Set the public Relay URL in every Yuri Host environment, then start the Host and create a Relay pairing link or enable Relay pairing in the application.

export YURI_RELAY_URL=wss://relay.example.com/ws
yuri

The Add a device dialog can override the Relay endpoint for a pairing. The Host persists and switches to the selected endpoint before it emits the QR code, unless YURI_RELAY_URL pins the deployment endpoint. The effective relayUrl is part of the pairing-v2 candidate and is saved by Mobile and Desktop clients for later reconnects.

Existing Clients use a new pairing flow to receive a changed Relay endpoint. Create a fresh pairing link when endpoint replacement is required.

TLS reverse proxies

The Relay serves loopback HTTP health endpoints and WebSocket upgrades. The public proxy terminates TLS and forwards HTTP, SSE, and WebSocket upgrade traffic to the Relay.

Caddy

relay.example.com {
    reverse_proxy 127.0.0.1:8787 {
        header_up X-Forwarded-For {remote_host}
    }
}

Run the Relay with --public-url wss://relay.example.com/ws. Caddy forwards WebSocket upgrades and serves /healthz and /readyz from the same upstream. The X-Forwarded-For rule writes one canonical Client source IP.

When Push shares the hostname, send /v1/push/* to the Push listener and replace X-Forwarded-For once on each upstream:

relay.example.com {
    handle /v1/push/* {
        reverse_proxy 127.0.0.1:8788 {
            header_up X-Forwarded-For {remote_host}
        }
    }

    handle {
        reverse_proxy 127.0.0.1:8787 {
            header_up X-Forwarded-For {remote_host}
        }
    }
}

Nginx

server {
    listen 443 ssl;
    server_name relay.example.com;

    ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8787;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Run the Relay with --public-url wss://relay.example.com/ws. Nginx writes one canonical Client source IP with $remote_addr and forwards WebSocket upgrades over HTTP/1.1.

To share the hostname with Push, proxy /v1/push/ to 127.0.0.1:8788 and keep / (including /ws) on 127.0.0.1:8787. Replace X-Forwarded-For with $remote_addr on both locations.

Trusted proxies and capacity

Enable YURI_RELAY_SERVER_TRUST_PROXY=true or --trust-proxy when a trusted reverse proxy fully isolates Relay ingress and replaces each inbound X-Forwarded-For value with one canonical Client IP.

YURI_RELAY_SERVER_TRUST_PROXY=true \
yuri-relay --public-url wss://relay.example.com/ws

Trusted-proxy mode accepts exactly one valid IP address in X-Forwarded-For. Client admission and per-Client-IP limits then use that address. Configure the proxy to replace the header, and restrict direct access to the Relay listener so proxy peer identity remains authoritative.

YURI_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP always counts the TCP peer connected to the Relay. A reverse proxy is that peer, so set this limit for the proxy's aggregate concurrent traffic rather than an individual public Client.

Environment variables

| Variable | Default | Unit / purpose | | --- | --- | --- | | YURI_RELAY_SERVER_HOST | 127.0.0.1 | Listener address | | YURI_RELAY_SERVER_PORT | 8787 | TCP port | | YURI_RELAY_SERVER_PATH | /ws | WebSocket upgrade path | | YURI_RELAY_SERVER_PUBLIC_URL | unset | Startup output URL; ws:// or wss://, same path as PATH | | YURI_RELAY_SERVER_TRUST_PROXY | false | Read one canonical Client IP from proxy-replaced X-Forwarded-For | | YURI_RELAY_SERVER_MAX_URL_BYTES | 4096 | bytes | | YURI_RELAY_SERVER_MAX_FIELD_BYTES | 512 | bytes per routing field | | YURI_RELAY_SERVER_MAX_HOSTS | 256 | active Host routes | | YURI_RELAY_SERVER_MAX_SOCKETS | 2048 | upgraded WebSockets | | YURI_RELAY_SERVER_MAX_CONNECTIONS | 1000 | global client connections | | YURI_RELAY_SERVER_MAX_CLIENTS_PER_HOST | 100 | connections per Host | | YURI_RELAY_SERVER_MAX_CLIENTS_PER_IP | 30 | connections per Client IP | | YURI_RELAY_SERVER_MAX_PENDING_CLIENTS | 30 | Clients awaiting a Host data connection | | YURI_RELAY_SERVER_PENDING_MS | 15000 | ms awaiting a Host data connection | | YURI_RELAY_SERVER_MAX_RAW_SOCKETS | 4096 | accepted TCP sockets before upgrade | | YURI_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP | 128 | TCP sockets per Relay TCP peer IP | | YURI_RELAY_SERVER_GRACE_MS | 30000 | ms Host control disconnect grace | | YURI_RELAY_SERVER_TIMESTAMP_SKEW_MS | 60000 | ms Host signature timestamp window | | YURI_RELAY_SERVER_REPLAY_MS | 120000 | ms replay-record lifetime; at least twice timestamp skew | | YURI_RELAY_SERVER_MAX_REPLAY_ENTRIES | 10000 | Host signature replay records | | YURI_RELAY_SERVER_MAX_FRAME_BYTES | 131072 | bytes per forwarded frame | | YURI_RELAY_SERVER_MAX_QUEUED_BYTES_PER_CONNECTION | 2097152 | bytes per Client pair | | YURI_RELAY_SERVER_MAX_GLOBAL_QUEUED_BYTES | 33554432 | bytes across all queues | | YURI_RELAY_SERVER_MAX_BUFFERED_AMOUNT | 2097152 | bytes buffered by a WebSocket before pump retry | | YURI_RELAY_SERVER_MAX_CONTROL_QUEUE_ENTRIES | 256 | queued Host control messages | | YURI_RELAY_SERVER_MAX_CONTROL_QUEUED_BYTES | 2097152 | bytes queued for Host control | | YURI_RELAY_SERVER_PUMP_RETRY_MS | 25 | ms between backpressure retries | | YURI_RELAY_SERVER_HEARTBEAT_MS | 30000 | ms WebSocket ping interval | | YURI_RELAY_SERVER_HANDSHAKE_MS | 10000 | ms for TCP and WebSocket admission | | YURI_RELAY_SERVER_CLOSE_DEADLINE_MS | 5000 | ms before forced socket close | | YURI_RELAY_SERVER_ADMISSION_WINDOW_MS | 60000 | ms admission window | | YURI_RELAY_SERVER_MAX_ADMISSIONS_PER_IP | 120 | Client, Host-data, and malformed upgrades per role/IP per window | | YURI_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_IP | 30 | Host-control upgrades per IP per window | | YURI_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_SERVER | 12 | Host-control upgrades per serverId per window | | YURI_RELAY_SERVER_MAX_ADMISSION_ENTRIES | 10000 | tracked admission records | | YURI_RELAY_SERVER_ID_ATTEMPTS | 4 | random connection-ID attempts |

Host Push URL override

These variables belong on the Yuri Host, not on the Push process:

| Variable | Default | Unit / purpose | | --- | --- | --- | | YURI_PUSH_RELAY_URL | derived from the effective Relay ws/wss URL | Host override for https:// or http:// …/v1/push/send | | YURI_PUSH_RELAY_DISABLED | unset | Host-only; true skips Push Relay and uses direct APNs |

The derived send URL always uses /v1/push/send on the same host and port as the Relay URL. wss maps to https; ws maps to http. Register is the same origin with /v1/push/register-token. iOS Live Activity tokens use /v1/push/register-live-activity-token, /v1/push/unregister-live-activity-token, and /v1/push/live-activity on that same origin. Live Activity APNs requests use topic {bundleId}.push-type.liveactivity and never include session IDs, titles, alerts, or collapse IDs.

Push process environment

Command flags take precedence over YURI_PUSH_RELAY_* variables, which take precedence over defaults. APNs Key ID, Team ID, and .p8 material are required.

| Variable | Default | Unit / purpose | | --- | --- | --- | | YURI_PUSH_RELAY_HOST | 127.0.0.1 | Listener address | | YURI_PUSH_RELAY_PORT | 8788 | TCP port | | YURI_PUSH_RELAY_TRUST_PROXY | false | Read one canonical Client IP from proxy-replaced X-Forwarded-For | | YURI_PUSH_RELAY_DATABASE_PATH | ./data/push-relay.sqlite | SQLite file; directory must be writable | | YURI_PUSH_RELAY_TIMESTAMP_SKEW_MS | 300000 | ms signed ts window | | YURI_PUSH_RELAY_REPLAY_MS | 600000 | ms replay-record lifetime; at least twice timestamp skew | | YURI_PUSH_RELAY_MAX_REPLAY_ENTRIES | 10000 | replay records | | YURI_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE | 60 | register requests per Client IP per minute | | YURI_PUSH_RELAY_SEND_LIMIT_PER_MINUTE | 60 | send requests per Client IP per minute | | YURI_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE | 120 | send requests per serverId per minute | | YURI_PUSH_RELAY_MAX_TOKENS | 100000 | persisted device-token bindings | | YURI_PUSH_RELAY_MAX_IN_FLIGHT | 64 | concurrent APNs deliveries | | YURI_PUSH_RELAY_APNS_KEY_ID | required | Apple APNs key ID | | YURI_PUSH_RELAY_APNS_TEAM_ID | required | Apple Team ID | | YURI_PUSH_RELAY_APNS_BUNDLE_ID | com.yee94.yuri | App bundle ID | | YURI_PUSH_RELAY_APNS_P8 | required unless path set | APNs .p8 PEM; literal \n accepted | | YURI_PUSH_RELAY_APNS_P8_PATH | unset | Path to the .p8 file; used when APNS_P8 is empty |

TestFlight and App Store Hosts send env: production (YURI_APNS_ENVIRONMENT=production on the Host). Xcode development builds use sandbox. The Push process does not choose the APNs environment; each send request carries it.

systemd

Create /etc/yuri-relay.env:

YURI_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws

Create /etc/systemd/system/yuri-relay.service:

[Unit]
Description=Yuri Private Relay
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
EnvironmentFile=/etc/yuri-relay.env
ExecStart=/usr/local/bin/yuri-relay
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable and inspect the service:

sudo systemctl daemon-reload
sudo systemctl enable --now yuri-relay
sudo systemctl status yuri-relay

Operations and security

  • GET and HEAD requests to /healthz report process health. /readyz reports ready status after the listener reaches the running state.
  • SIGTERM and SIGINT start graceful Relay shutdown. Hosts reconnect after a process restart.
  • Size Host, Client, pending, socket, frame, and queue limits for expected concurrency and message volume.
  • Keep the default loopback listener, terminate public TLS at a reverse proxy, restrict ingress with firewall rules, and publish the matching wss:// URL.
  • Keep logs and metrics snapshots free of URL query strings, sig, pk, grant, encrypted payloads, pairing material, bearer credentials, APNs .p8 contents, Key ID / Team ID values, and device tokens.
  • Run a single Push process per SQLite file. WAL mode does not make multi-instance sharing safe.

Docker delivery assets

Each non-dry-run Yuri v* release and each relay/v* Relay-only release publishes a Docker Hub image for linux/amd64 and linux/arm64 as <DOCKERHUB_USERNAME>/yuri-relay:<version> and <DOCKERHUB_USERNAME>/yuri-relay:latest. The image default entrypoint is Layer 1. The same image also contains Node 24 and the yuri-push-relay source/bin/package files. Layer 1 is a Bun 1.3.14 compile standalone; Push is executed with Node 24 and node:sqlite. The container runs as a non-root user, exposes 8787 and 8788, and health-checks Layer 1 with Node. The release pipeline requires the DOCKERHUB_USERNAME GitHub Actions repository variable and a DOCKERHUB_TOKEN repository secret with Docker Hub Read and Write permissions. Image publication must succeed before the GitHub Release is finalized. relay/v* publishes the same npm package and Docker image without desktop, mobile, TestFlight, or OTA artifacts.

The Relay Docker workflow can republish only the current Relay package version without creating or modifying a GitHub Release or other platform artifacts.

Pull and run an immutable release tag behind a host TLS reverse proxy:

docker pull <dockerhub-username>/yuri-relay:<version>
docker run -d \
  --name yuri-relay \
  --restart unless-stopped \
  --read-only \
  --tmpfs /tmp \
  --security-opt no-new-privileges:true \
  -p 127.0.0.1:8787:8787 \
  -e YURI_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
  <dockerhub-username>/yuri-relay:<version>

For an end-to-end public deployment with Caddy-managed HTTPS, use docker-compose.relay.remote.yml with Docker Compose 2.23.1 or later. Run these commands from the repository root; --project-directory . preserves existing project names and relative file paths:

YURI_RELAY_IMAGE='<dockerhub-username>/yuri-relay:<version>@sha256:<manifest-digest>' \
RELAY_DOMAIN=relay.example.com \
[email protected] \
docker compose --project-directory . -f deploy/relay/docker-compose.relay.remote.yml up -d

YURI_RELAY_IMAGE is required so the repository never binds this reusable deployment file to a personal registry namespace. Use an immutable version-and-digest reference in production.

To run Layer 1 and Push from that same immutable image, with APNs secrets only on Push, a persistent SQLite volume, read-only root filesystems, and Caddy routing /v1/push/* to Push, use docker-compose.relay-push.remote.yml:

YURI_RELAY_IMAGE='<dockerhub-username>/yuri-relay:<version>@sha256:<manifest-digest>' \
RELAY_DOMAIN=relay.example.com \
[email protected] \
YURI_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
YURI_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
YURI_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.yuri \
YURI_PUSH_RELAY_APNS_P8_FILE=/etc/yuri/AuthKey.p8 \
docker compose --project-directory . -f deploy/relay/docker-compose.relay-push.remote.yml up -d

The Push container reads the .p8 from the Docker secret path /run/secrets/apns_p8. Layer 1 does not receive that secret. Inspect both health checks with docker compose --project-directory . -f deploy/relay/docker-compose.relay-push.remote.yml ps. Public /healthz and /readyz are Layer 1. Push /healthz stays on the internal listener.

Existing Layer-1-only Compose files keep their current behavior: docker-compose.relay.yml for loopback, and docker-compose.relay.remote.yml for public Layer 1 without Push.

The repository provides optional Docker delivery assets at Dockerfile.relay and docker-compose.relay.yml. The Compose service publishes 127.0.0.1:${YURI_RELAY_PUBLISHED_PORT:-8787} and accepts YURI_RELAY_SERVER_PUBLIC_URL plus selected Relay limits.

YURI_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
YURI_RELAY_PUBLISHED_PORT=8787 \
docker compose --project-directory . -f deploy/relay/docker-compose.relay.yml up -d --build

These assets define an optional follow-on deployment path. Validate the image, proxy integration, TLS configuration, and operational limits in the target environment before production use.

Troubleshooting

| Symptom | Checks and resolution | | --- | --- | | Host or Client cannot connect | Confirm the public URL uses the deployed wss:// scheme, host, and exact Relay path. Confirm DNS, certificate, firewall, and proxy upstream reachability. | | /healthz succeeds and /readyz fails | Wait for the listener startup to complete, then inspect process stderr and service logs for bind errors. | | Clients receive admission or connection limits | Review MAX_CONNECTIONS, per-Host, per-Client-IP, pending, admission, and raw-socket limits against current traffic. | | One Host reconnects host-control thousands of times per hour | Relay does not version-gate Hosts. A live control is kept when a later host-control admission is limited (4029). Review MAX_HOST_CONTROL_ADMISSIONS_PER_SERVER / _PER_IP and the Host's control backoff. | | Many Clients share a reverse proxy | Increase MAX_RAW_SOCKETS_PER_IP for aggregate proxy-peer concurrency. | | Per-Client IP limits behave as proxy limits | Enable trusted-proxy mode, fully isolate Relay ingress behind that proxy, and configure a single replaced X-Forwarded-For IP. | | Existing clients continue using an earlier endpoint | Refresh the candidate or create a new pairing link after changing YURI_RELAY_URL. | | WebSocket application traffic fails while HTTP works | Confirm the Host endpoint mints and supplies a short-lived yuri_url_token for the WebSocket path. | | Push register or send returns 404 on the public hostname | Confirm the reverse proxy sends /v1/push/* to the Push listener on port 8788, not to Layer 1. | | Push container is unhealthy | Confirm APNs Key ID / Team ID / Bundle ID and the .p8 secret path, and that the SQLite volume is writable by the non-root user. | | Device tokens stop receiving after a Relay URL change | Confirm the Host re-registered against the new Push origin before the first send, or set YURI_PUSH_RELAY_URL explicitly. |

Development and test coverage

Run Relay package unit tests and Host/Client end-to-end coverage independently from the repository root:

bunx vitest run --project @yuriai/relay-server
bunx vitest run --project @yuriai/web packages/web/server/lib/relay/relay-server.e2e.test.ts

packages/web/server/lib/relay/relay-server.e2e.test.ts builds a compiled Relay executable and exercises a real Host and TypeScript Client across authenticated HTTP, streaming SSE, URL-token WebSocket traffic, Relay restart recovery, and cleanup. Use this E2E coverage when validating Relay transport changes.