Summary
On a default self-hosted npm run serve, the editor loads but hangs at "Connecting…". GET /documents/:slug/collab-session advertises a collab websocket URL on appPort + 1 (e.g. ws://localhost:4001/ws), but the WebSocket server is attached to the main HTTP port (:4000). Nothing listens on :4001, so the client retries forever.
Environment
- Self-hosted,
npm run serve on :4000, no COLLAB_* env vars set
- Version:
fb25787
Root cause — embedded vs. split-port mismatch
server/index.ts:42 attaches the WS server to the main HTTP server at path /ws:
const wss = new WebSocketServer({ server, path: '/ws' });
server/index.ts:132 starts the collab runtime embedded (multiplexed on the main port):
await startCollabRuntimeEmbedded(PORT);
A direct handshake to ws://localhost:4000/ws?slug=<slug>&token=<accessToken> succeeds, confirming the WS lives on :4000.
- But
server/routes.ts:579 resolveRequestScopedCollabWsBase(req) decides the client-facing WS port from the COLLAB_EMBEDDED_WS env flag (routes.ts:589). When it is unset, the code assumes a split-port deployment and returns appPort + 1 (routes.ts:613):
wsUrl.port = String(appPort + 1);
- So
collab-session returns collabWsUrl: ws://localhost:4001/ws, which is dead.
The defaults are inverted: the server attaches WS to the main port by default (startCollabRuntimeEmbedded), but the URL resolver defaults to split-port.
Reproduction
npm run serve # no COLLAB_* env
# create a doc, then:
curl -s 'http://localhost:4000/documents/<slug>/collab-session?token=<accessToken>' | jq .session.collabWsUrl
# -> "ws://localhost:4001/ws?slug=<slug>"
lsof -iTCP:4001 -sTCP:LISTEN # -> nothing
Editor → "Connecting…" indefinitely.
Workaround
Set one of:
COLLAB_EMBEDDED_WS=true (keeps the WS on the main port — routes.ts:605-607), or
COLLAB_PUBLIC_BASE_URL=ws://localhost:4000/ws
Suggested fix
Derive embedded-vs-split-port from the runtime that was actually started (the collab runtime already knows its wsUrlBase and whether it's attached/embedded) rather than from the COLLAB_EMBEDDED_WS env flag — or make startCollabRuntimeEmbedded set the default so resolveRequestScopedCollabWsBase keeps the same port unless explicitly told otherwise.
Found while debugging a self-hosted instance with Claude Code.
Summary
On a default self-hosted
npm run serve, the editor loads but hangs at "Connecting…".GET /documents/:slug/collab-sessionadvertises a collab websocket URL onappPort + 1(e.g.ws://localhost:4001/ws), but the WebSocket server is attached to the main HTTP port (:4000). Nothing listens on:4001, so the client retries forever.Environment
npm run serveon:4000, noCOLLAB_*env vars setfb25787Root cause — embedded vs. split-port mismatch
server/index.ts:42attaches the WS server to the main HTTP server at path/ws:server/index.ts:132starts the collab runtime embedded (multiplexed on the main port):ws://localhost:4000/ws?slug=<slug>&token=<accessToken>succeeds, confirming the WS lives on:4000.server/routes.ts:579resolveRequestScopedCollabWsBase(req)decides the client-facing WS port from theCOLLAB_EMBEDDED_WSenv flag (routes.ts:589). When it is unset, the code assumes a split-port deployment and returnsappPort + 1(routes.ts:613):collab-sessionreturnscollabWsUrl: ws://localhost:4001/ws, which is dead.The defaults are inverted: the server attaches WS to the main port by default (
startCollabRuntimeEmbedded), but the URL resolver defaults to split-port.Reproduction
Editor → "Connecting…" indefinitely.
Workaround
Set one of:
COLLAB_EMBEDDED_WS=true(keeps the WS on the main port —routes.ts:605-607), orCOLLAB_PUBLIC_BASE_URL=ws://localhost:4000/wsSuggested fix
Derive embedded-vs-split-port from the runtime that was actually started (the collab runtime already knows its
wsUrlBaseand whether it's attached/embedded) rather than from theCOLLAB_EMBEDDED_WSenv flag — or makestartCollabRuntimeEmbeddedset the default soresolveRequestScopedCollabWsBasekeeps the same port unless explicitly told otherwise.Found while debugging a self-hosted instance with Claude Code.