Spaces wire protocol
Cursors, member presence, location tracking, and distributed locks — over a pure-relay ephemeral channel.
Spaces has no channel of its own. It runs on a PubSub room:
channel with the ephemeral- prefix:
Topic: app:{appKey}:room:ephemeral-{spaceId}
Ephemeral mode: no envelope, no serial, no replay, no WAL. Every event is a
straight broadcast_from! from the publisher to all other members. Missed
events while disconnected are gone.
The ephemeral- prefix is how the current server selects pure-relay mode. It
is stable but treat it as an implementation detail — a future version makes
"no durability" a channel-config flag.
Join
["1", "1", "app:pk_live_x:room:ephemeral-doc-42", "phx_join", {}]No auth needed unless the app has enable_user_authentication = 1, in which
case the join carries a capability token granting
channel:subscribe:ephemeral-doc-42.
Join reply
{ "status": "ok", "response": { "socket_id": "abc-123" } }On join the server tracks this connection in Phoenix.Tracker and pushes the
current member set once:
presence:state (once, on join)
["1", null, "app:pk_live_x:room:ephemeral-doc-42", "presence:state", {
"members": [
{ "id": "sock-1", "client_id": "user-42", "joined_at": 1738500000000 }
]
}]Known gap. The server sends presence:state on join but does not currently
broadcast live presence:join / presence:leave when other members come and
go. The member list is accurate as of your join only. Cursor / location / lock
events do flow live. This is the driver for the presence redesign — the SDK
already listens for the diffs the server needs to start sending.
Client → server
Any event name that isn't lock:* is relayed verbatim to every other member.
The SDK uses these conventions:
cursor
{ "client_id": "user-42", "position": { "x": 124, "y": 88 } }High-frequency. The SDK throttles to ~30 fps (throttle option, default 33 ms).
The server does not rate-limit cursor events beyond the app's
max_client_events_per_sec.
location
{ "client_id": "user-42", "location": { "elementId": "heading-2" } }What the member is looking at / editing.
presence:update
{ "client_id": "user-42", "profile": { "name": "Ada", "color": "#ffe500" } }The SDK sends this on space.enter(profile) and again on any profile change.
Relayed to other members as-is.
presence:leave
{ "client_id": "user-42" }Sent on space.leave() before the channel phx_leave.
lock:acquire
{ "id": "block-3" }Routed through the space's SpaceWorker GenServer (serialised — the only
non-relay path). Reply:
{ "status": "ok", "response": { "id": "block-3", "status": "locked" } }or { "status": "error", "response": { "reason": "lock held by another member" } }.
lock:release
{ "id": "block-3" }Reply { "status": "ok" }. Locks are also auto-released when the holder's
socket disconnects.
Server → client
cursor / location / presence:update / presence:leave
Relayed verbatim from another member — same shapes as the pushes above, always
carrying the originating client_id.
lock:update
Broadcast by SpaceWorker on every lock state change (acquire, release,
disconnect-release):
["1", null, "app:pk_live_x:room:ephemeral-doc-42", "lock:update", {
"id": "block-3",
"status": "locked" | "unlocked",
"holder": "sock-7" | null
}]Lock semantics
- One
lockIdstring → one holder at a time. Scoped to the space. - Acquire is first-come-first-served; no queue — contenders retry at the app layer with backoff.
- Auto-released on holder disconnect (
SpaceWorkerdetects the dropped socket). - No TTL.
SpaceWorkeridles out after inactivity; the nextlock:acquirerestarts it.
SDK
PingerchipsSpaces (in pingerchips-js) wraps all of this:
import PingerchipsSpaces from 'pingerchips-js/spaces';
const spaces = new PingerchipsSpaces('pk_live_x');
const space = await spaces.get('doc-42', { clientId: 'user-42', profile: { name: 'Ada' } });
space.members.subscribe('enter', (m) => …); // once presence diffs ship
space.cursors.set({ x, y });
space.cursors.subscribe('update', ({ member, position }) => …);
space.locations.set({ elementId: 'heading-2' });
await space.locks.acquire('block-3');See Spaces SDK reference.