OpenParable
Skip to content
← Back to skills

Yjs Server

DevOps & Security
yjs-server

Yjs Server is an Agent Skill for devops & security published in the durable-streams/durable-streams repository on GitHub: a SKILL.md instruction file an AI agent can load to do this task.

Deploy Yjs collaborative editing. YjsServer setup with compaction threshold, Caddy reverse proxy with flush_interval -1 for SSE, 3-layer architecture (Browser → Caddy → YjsServer → DS Server), Electric Cloud managed alternative with @electric-sql/cli provisioning. Load when deploying y-durable-streams to production or configuring server infrastructure.

View on GitHub ↗
QUICK START

How to use this skill

Bring this guide into your coding agent with a prompt tailored to the tool you use.

  1. Open your project in Codex.
  2. Copy and paste the prompt below.
  3. Review files and risks before approving any action.
Prompt to paste
Inspect the Agent Skill at https://github.com/durable-streams/durable-streams/blob/HEAD/packages/y-durable-streams/skills/yjs-server/SKILL.md and all supporting files. Report provenance, permissions, scripts, dependencies, and security risks. Propose an exact installation into .agents/skills/yjs-server, but wait for my approval before writing files or running scripts.

Copying this prompt does not install or run the skill. Review third-party files before use.

This skill builds on durable-streams/yjs-getting-started. Read it first for basic setup.

Durable Streams — Yjs Server Deployment

Three deployment options: dev server for prototyping, Caddy for self-hosted production, Electric Cloud for managed hosting.

Architecture

Browser (YjsProvider)
    │ HTTPS
    ▼
Caddy reverse proxy (:443)
    ├─ /v1/stream/* → Durable Streams storage
    └─ /v1/yjs/*   → YjsServer (flush_interval -1)
                         │ HTTP
                         ▼
                    DS Server (storage)

YjsServer implements the Yjs wire protocol (snapshot discovery, compaction, awareness routing) and proxies all storage operations to a Durable Streams server.

Development

import { DurableStreamTestServer } from "@durable-streams/server"
import { YjsServer } from "@durable-streams/y-durable-streams/server"

const dsServer = new DurableStreamTestServer({ port: 4437 })
await dsServer.start()

const yjsServer = new YjsServer({
  port: 4438,
  host: "127.0.0.1",
  dsServerUrl: "http://localhost:4437",
  compactionThreshold: 1024 * 1024, // 1MB (default)
})
await yjsServer.start()

Single-origin dev server (HTTP/2 multiplexing)

For local development you usually want one origin the browser hits so HTTP/2 can multiplex the DS stream, Yjs stream, and the Vite dev server over a single connection. Spawn Caddy from a Node script alongside YjsServer:

// server.ts
import { spawn } from "node:child_process"
import { resolve } from "node:path"
import { YjsServer } from "@durable-streams/y-durable-streams/server"

process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" // trust Caddy's self-signed cert

const CADDY_PORT = 4443
const YJS_PORT = 4438

const yjsServer = new YjsServer({
  port: YJS_PORT,
  host: "127.0.0.1",
  dsServerUrl: `https://localhost:${CADDY_PORT}`, // go through Caddy for TLS
  compactionThreshold: 1024 * 1024,
})
await yjsServer.start()

const caddy = spawn(
  resolve(import.meta.dirname, "./durable-streams-server"),
  ["run", "--config", resolve(import.meta.dirname, "./Caddyfile")],
  { stdio: ["ignore", "pipe", "pipe"] }
)

// Wait for Caddy's ready line before returning control
await new Promise<void>((ok, fail) => {
  const t = setTimeout(() => fail(new Error("Caddy start timeout")), 10_000)
  caddy.stderr.on("data", (buf: Buffer) => {
    if (buf.toString().includes("serving initial configuration")) {
      clearTimeout(t)
      ok()
    }
  })
  caddy.on("exit", (code) => {
    clearTimeout(t)
    if (code && code !== 0) fail(new Error(`Caddy exited ${code}`))
  })
})

process.on("SIGINT", async () => {
  await yjsServer.stop()
  caddy.kill("SIGTERM")
  process.exit(0)
})

And the matching dev Caddyfile — DS at /v1/stream/*, Yjs proxied to the internal YjsServer, everything else to Vite:

{
  admin off
}

localhost:4443 {
  route /v1/stream/* {
    durable_streams
  }

  route /v1/yjs/* {
    reverse_proxy localhost:4438 {
      flush_interval -1
    }
  }

  reverse_proxy localhost:3001   # Vite dev server
}

The flush_interval -1 on the Yjs route is mandatory (see Common Mistakes below). Keep the dev and production Caddyfiles consistent on this flag.

YjsServer options

OptionDefaultDescription
port—Listen port
host"127.0.0.1"Listen host
dsServerUrl—Backing DS server URL
compactionThreshold1048576 (1MB)Trigger compaction after this many bytes of updates
dsServerHeaders{}Headers sent to the DS server (e.g. auth)

Compaction

When accumulated updates for a document exceed compactionThreshold, the server automatically creates a snapshot. New clients load the snapshot instead of replaying all updates — keeps initial sync fast. Connected clients are unaffected.

Production with Caddy

Download the Caddy binary with the durable_streams plugin from GitHub releases.

Caddyfile

:443 {
  route /v1/stream/* {
    durable_streams {
      data_dir ./data
      max_file_handles 200
    }
  }

  route /v1/yjs/* {
    reverse_proxy localhost:4438 {
      flush_interval -1
    }
  }
}

flush_interval -1 is mandatory — without it, Caddy buffers SSE responses and live updates stop working. This is the #1 production deployment mistake.

Production YjsServer

Point YjsServer at the Caddy server (not the raw DS server) if Caddy handles TLS:

const yjsServer = new YjsServer({
  port: 4438,
  dsServerUrl: "https://localhost:443",
  compactionThreshold: 1024 * 1024,
})

Managed with Electric Cloud

Skip infrastructure setup entirely. Provision a Yjs service via the Electric Cloud CLI:

# Install and authenticate
npx @electric-sql/cli auth login

# Create a Yjs service
npx @electric-sql/cli services create yjs --json

# Get the service URL and secret
npx @electric-sql/cli services get-secret <service-id> --json

Then point YjsProvider at the cloud URL:

const provider = new YjsProvider({
  doc,
  baseUrl: "https://api.electric-sql.cloud/v1/yjs/<service-id>",
  docId: "my-doc",
  headers: {
    Authorization: `Bearer <secret>`,
  },
})

Server-side proxy (required for browser apps)

Do NOT expose the Electric Cloud secret to browser clients. Use a server-side proxy route that injects the Authorization header:

// Server route: /api/yjs/*
app.all("/api/yjs/*", async (req, res) => {
  const targetUrl = `https://api.electric-sql.cloud/v1/yjs/<service-id>${req.path.replace("/api/yjs", "")}`
  const response = await fetch(targetUrl, {
    method: req.method,
    headers: {
      ...req.headers,
      Authorization: `Bearer ${process.env.YJS_SECRET}`,
    },
    body: req.method !== "GET" ? req.body : undefined,
    duplex: "half",
  })

  // Block-list headers that break when proxied
  const skipHeaders = new Set([
    "content-encoding",
    "content-length",
    "transfer-encoding",
    "connection",
  ])

  for (const [key, value] of response.headers) {
    if (!skipHeaders.has(key.toLowerCase())) {
      res.setHeader(key, value)
    }
  }

  res.status(response.status)
  response.body.pipe(res)
})

Key proxy rules:

  • Use a block-list for response headers — Yjs protocol uses custom headers like stream-next-offset that an allow-list would miss
  • Block content-encoding and content-length — Node's fetch auto-decompresses gzip but leaves the headers, causing ERR_CONTENT_DECODING_FAILED
  • Use duplex: "half" when forwarding request bodies

Then point the provider at your proxy:

const provider = new YjsProvider({
  doc,
  baseUrl: "/api/yjs", // Must be absolute — use window.location.origin + path
  docId: "my-doc",
})

Common Mistakes

CRITICAL Missing flush_interval -1 in Caddy config

Wrong:

route /v1/yjs/* {
  reverse_proxy localhost:4438
}

Correct:

route /v1/yjs/* {
  reverse_proxy localhost:4438 {
    flush_interval -1
  }
}

Without this, Caddy buffers SSE responses. Live updates appear to hang — clients connect but never receive data.

HIGH Exposing Electric Cloud secret to browser clients

Wrong:

new YjsProvider({
  doc,
  baseUrl: "https://api.electric-sql.cloud/v1/yjs/<service-id>",
  headers: { Authorization: `Bearer ${cloudSecret}` }, // Leaked!
})

Correct: Use a server-side proxy that injects the secret. See the proxy section above.

MEDIUM Not configuring compaction threshold

Default is 1MB. For documents with frequent small edits (collaborative text), this is reasonable. For documents with large binary content (images, files), increase it to avoid excessive compaction I/O.

See also