The single shared, canonical implementation of the Phlix SyncPlay wire protocol + NTP time-sync for JavaScript clients (mobile / React Native, windows / Electron, tizen / Vite).
It eliminates the protocol drift that accumulated across clients (Windows
missing message types and reading group_state flat; Tizen inventing
member_joined/member_left and wrapping sends as {type,data,timestamp};
Roku using a wrong syncplay. dot prefix over HTTP-POST). The PHP server is
the source of truth — this package mirrors it exactly.
- No framework deps. Pure TypeScript, usable in RN/Metro, Electron renderer, and Tizen Vite.
- Transport-agnostic. WebSocket is injected — you pass a
sendfunction and callhandleIncoming(raw)per frame. The library never imports a WS lib. - Deterministic. The clock is injected (
now: () => number); noDate.now()/Math.random()at module scope.
See SPEC.md for the full wire-protocol documentation.
This library performs NO authentication and NO authorization — it is a transport-agnostic protocol codec with no socket and no view of credentials. All security is a server responsibility:
- The server MUST authenticate the WebSocket connection before accepting any
syncplay_*frame. password_hash(oncreateGroup/joinGroup) is an unsalted SHA-256 hex string that is replayable — a weak group gate, never an identity.member_id/ host ids are self-asserted on the wire; the server MUST derive the effective identity from the authenticated connection and authorize host-only actions by connection identity, not by the client-claimed id. Client-side, that law has a mirror obligation: thememberIdyou handSyncPlayClientMUST be the id the server derives for your connection — see "Identity contract (memberId↔your_id)" under Usage.- Peer-supplied display strings (
group_name,member_name, chat/infomessage) are passed through unescaped — consumers MUST sanitize before rendering.
See SPEC.md §8 Security model and
§9 Server-derived identity contract
for the full contract.
@phlix/syncplay is distributed as a git-tagged dependency (not published to
npm). Pin an exact tag:
npm installThe library owns no socket — you dial the WebSocket, and the handshake MUST
carry your credential: both listeners reject the upgrade before the 101
without it (nothing is ever read on an unauthenticated connection). Browsers
(and React Native) cannot set an Authorization header on a WebSocket
upgrade, so the bearer token rides as the second WebSocket constructor
value. See SPEC.md §8.4
for the full carrier law.
Direct — server :8097:
const ws = new WebSocket('wss://phlix.example:8097/api/v1/syncplay/ws', ['bearer', jwt]);:8097 is the SyncPlay listener — the HTTP API port has no WebSocket upgrade.
The Sec-WebSocket-Protocol: bearer, <jwt> two-entry subprotocol is the
estate target carrier; the server answers the 101 with the bearer marker
only, never the token. The legacy ?token=<jwt> query is still accepted on
:8097 for older client builds but MUST NOT be added to new code paths
— a URL-borne credential leaks into access/proxy logs and handshake
diagnostics.
Via hub relay — :8804:
const ws = new WebSocket(`wss://hub.example:8804/syncplay/${serverId}`, ['bearer', relayToken]);The hub relay speaks the same protocol but accepts the bearer subprotocol (or
an Authorization: Bearer header — non-browser runtimes; SPEC §8.4) only —
a ?token= query is refused there by design. Each relay socket is
scoped to one server ({server_id} in the path) and one hub account.
import { SyncPlayClient } from '@phlix/syncplay';
// Dial per "Connecting" above (SPEC §8.4): the credential rides the bearer
// subprotocol, never in the URL.
const ws = new WebSocket('wss://phlix.example:8097/api/v1/syncplay/ws', ['bearer', jwt]);
// IDENTITY LAW (SPEC §9, "Identity contract" below): `memberId` MUST be the
// id the server derives from the AUTHENTICATED CONNECTION — on `:8097` that
// is your account user id (the JWT subject your auth layer already holds
// from login / GET /auth/me; the create/join ack echoes it back as
// `your_id`). NEVER fabricate a value here — a mismatched id is the estate's
// dead-host bug: `isHost()` stays false for a true host and your own echoed
// commands double-apply.
const memberId = authSession.userId; // = JWT subject, e.g. '8f2a…-uuid'
const client = new SyncPlayClient({
send: (message) => ws.send(JSON.stringify(message)),
now: () => Date.now(),
memberId,
memberName: 'Alice',
onState: (group, yourId) => {
// `yourId` is the identity the server stamped for THIS connection
// (phlix-server `SyncPlayManager.php:1853`/`:1909` @ `a63dc0db`). Reconcile
// — if it ever differs from the id you passed, you have a ghost identity:
// fix the source, or gate host UX on `group.host_id === yourId`.
/* render group (`group.members` normalized to an array; `group.queue` delivered per producer — SPEC §4); you are yourId */
},
onSync: ({ offset, latency, isStable }) => { /* time-sync status */ },
onSyncDegraded: (consecutiveRejections) => { /* fires per matched-but-rejected ping round (rtt outside SPEC §5's window); resets on any acceptance — consumer owns the threshold */ },
onPlaybackCommand: ({ type, position, serverTime }) => { /* apply locally */ },
onMemberJoined: ({ id, name }) => {},
onMemberLeft: ({ id, name }) => {}, // roster diff inside a same-group group_state (SPEC §6 leave law)
onHostChanged: (hostId) => {},
onError: (code, message) => {},
onInfo: (message) => { /* syncplay_info text frames (member-join INFOs also drive onMemberJoined) */ },
onMemberTyping: (memberId, isTyping) => {},
onChat: (memberId, memberName, message, timestampMs) => { /* every accepted message, INCLUDING your own echo (fan-out includes the sender) — render chat from here only */ },
onQueueChanged: (queue) => { /* wholesale queue replacement; also folded into getGroup().queue; [] = cleared */ },
onHostTransfer: (currentHostId, newHostId) => {}, /* defensive-only arm: zero live inbound producers — SPEC §3 types the frame client → server and the server/hub announce transfers via group_state/host_elect; build transfer UX on onState/onHostChanged */
onPlaybackSync: (memberId, position, isPlaying, serverTime, nudge) => { /* `nudge` = server-directed S446 soft correction ({drift_ms, direction, suggested_rate, tolerance_ms, cooldown_ms}); undefined unless the `:8097` server unicasted it to THIS member — apply suggested_rate, never seek (SPEC §4) */ },
onTimeSync: (state) => { /* { memberId, groupId, serverTimeSec, offsetMs, latencyMs, driftRate, isStable } */ },
onGroupList: (groups) => { /* [{ id, name, member_count, has_password, current_media, is_playing }] — live producer row, SPEC §4 */ },
onUnknownFrame: (frame) => { /* non-SyncPlay types, e.g. the hub relay's `pending_command` — plus the four canonical types with no inbound arm (`group_create`/`group_join`/`group_leave` echoes, stray `time_ping`), surfaced as unknown-to-this-client; see the option docblock */ },
onFrameDropped: (reason, frame) => { /* the no-silent-drop law: 'undecodable' | 'malformed' inbound refusals, 'send-failed' when the send seam throws — see the option docblock */ },
onDisconnect: () => { /* UI hook, e.g. show a "reconnecting…" banner */ },
});
ws.onmessage = (e) => client.handleIncoming(e.data);
ws.onopen = () => {
client.pingTime(); // schedule this every ~10–30s
client.createGroup('Movie Night'); // or joinGroup(groupId)
};
// Host-only controls (the server rejects non-hosts with NOT_HOST — the gated
// set is play/pause/seek/queue/transfer, SPEC §9 item 2):
client.sendPlay(positionMs);
client.sendPause(positionMs);
client.sendSeek(fromMs, toMs);
// Position report — ANY authenticated member may send it; NOT host-gated
// (state report, §9.1: the server ingests every member's position for the
// S446 drift policy and echoes the group state back to everyone):
client.reportPosition(positionMs, isPlaying);
// Group-chat surface (any member; identity is server-derived, SPEC §4/§9):
client.sendChat('popcorn tonight?'); // your message arrives back via onChat
client.sendTyping(true); // peers see it on THEIR onMemberTypingThe rest of the surface: leaveGroup() — returns whether the LEAVE frame
actually dispatched (false with no cached group), and always cancels a
pending coalesced seek; isHost() / getGroup() / getMemberId() (the
constructor identity — on :8804 adopt yourId per the identity contract
below); getTimeSync() (the live TimeSync instance); getSynchronizedTime()
(server-corrected clock in ms — the value the outbound frames stamp as
server_time); and destroy() (cancel the seek timer, reset time-sync, drop
the group; fires no onDisconnect callback — close your transport yourself,
and build the next client fresh rather than re-joining on an inert instance).
Every id the server puts on an inbound frame — your_id on
syncplay_group_state, host_id, the member_id rebroadcast on
play/pause/seek — is derived from the authenticated connection, never
from the member_id a client claims (SPEC §9; the server overwrites claims
before rebroadcast, §9 item 3). The library's local identity decisions
compare the constructor memberId against exactly those stamped ids —
isHost() (group.host_id === memberId) and the command echo suppressions
(msg.member_id === memberId) — so the constructor id and the server-stamped
id MUST be the same identity. What that identity is, per carrier:
| Carrier | Stamped identity | Source of truth |
|---|---|---|
Direct :8097 (WS + REST) |
the authenticated user id (JWT subject) | server SyncPlayManager.php:1853/:1909 (your_id = $connection->getUserId()), SyncPlayController.php:173/:267/:305 (S289: "identity is the authenticated JWT subject ONLY"), rebroadcast stamps :1107-1117/:1206-1211 — server tree @ a63dc0db |
Hub relay :8804 |
a hub-generated per-connection uuid | hub SyncPlayRelayWorker.php:694 + generateClientId() :2766-2779, echoed as your_id at :2057-2062 — hub tree @ 30012378 |
On :8097 you know the id before dialing — your login/auth store holds
it; pass it to the constructor and assert yourId === memberId in onState.
On :8804 the uuid is unknowable before the first group_state: adopt it
from onState's yourId (rebuild the client with it, or gate host UX on
group.host_id === yourId) — until adopted, isHost() and echo suppression
are inert on that carrier. This is the mobile wrapper's law: your_id is
"authoritative for host comparisons", the pre-join id is "only a placeholder
claim" (phlix-mobile-client src/syncplay/SyncPlayService.ts, audit H4).
Mismatch is not hypothetical. mobile shipped a fabricated
memberId: 'mobile_*' and every true host's controls went permanently inert
(the dead-host bug) while each host command echoed back as a foreign one and
double-applied. The library surfaces your_id and deliberately reconciles
nothing (the ctor id is immutable by design) — the ghost-identity class is
the consumer's assertion to make, and the reason this example sources
memberId from the auth layer instead of inventing one.
A TV remote key held on a seek bar emits 10–30 repeat events/s, and every
playback command is a broadcast: the server rebroadcasts each accepted
syncplay_playback_play / _pause / _seek to every member of the group
(phlix-server SyncPlayManager.php:1206 @ a63dc0db). The server absorbs none of
this rate: its only WebSocket limiter guards the CONNECT handshake (30 conns /
60 s, RateLimitProfiles::WS_CONNECT), JoinPasswordThrottle guards wrong group
passwords, and queue_limit_exceeded caps media-QUEUE depth — none of them
throttle command repeat. The law for senders is therefore: commands are
per-intent, not per-key-event. How that is met per sender:
sendSeek— the library coalesces it for you (default on). A leading + trailing throttle windowed by theseekThrottleMsconstructor option (default 250 ms): the first call of a burst dispatches synchronously (single-press UX is byte-identical to the raw path, so every existing call pattern keeps working), repeats inside the window merge latest-wins (an intermediate seek target is meaningless — only the last position matters), and the last position always reaches the wire within one window of release. A held remote costs the group at most 4 seek frames/s instead of 10–30 full player seeks. Escape hatch:seekThrottleMs: 0restores raw per-call dispatch — the seek coalescer is this library's ONLY owned timer, and off-mode owns none.leaveGroup()/onDisconnect()cancel a not-yet-flushed trailing seek: you never seek a group you just left.sendPlay/sendPause— per-intent, consumer-side. Dispatch stays synchronous and un-shaped: each is a state EDGE and latest-wins merging would be semantically wrong (it can lose a legitimate pause behind a repeat). If your input layer can key-repeat play/pause, gate the repeat before calling (a trailing debounce, e.g. the S519 patternphlix-uiuses for its own controls).reportPosition— cadence is consumer-owned; seconds-scale is sane. The server's S446 out-of-sync nudge judges staleness of exactly these reports (server verdictSyncPlayManager.php:1563-1565, nudge send:1587@a63dc0db), so flooding them mis-signals as surely as long silence does. Remember background-tab timer clamping: resume your interval on visibility change.
- Render chat from
onChatonly — never also locally onsendChat. Both producers fan chat out including the sender (phlix-serverSyncPlayManager.php:1347-1350broadcast viabroadcastToGroup's default$excludeIds = []:1986 — server tree @a63dc0db; hubSyncPlayRelayWorker.php:2402-2405omits the exclusion @30012378), so your own message arrives back as a frame. The library deliberately does not suppress that echo: suppression would key on the ctor-vs-stamped identity gap SPEC §9 documents as unreconciled.onChatcarries(memberId, memberName, message, timestampMs)— the broadcast's producer bytes (member_id/member_name/message,SyncPlayManager.php:1347-1350@a63dc0db; the envelopetimestampis authoritative ms on both carriers, S417), andundefinedfor the timestamp only on hand-built frames. Note the producers drop blank messages in silence (serverSyncPlayManager.php:1338-1340@a63dc0db, hubSyncPlayRelayWorker.php:2398-2400@30012378) — no error, no echo — so gate empty sends yourself. - Typing is the exception that proves the law: its fan-out EXCLUDES the
sender (server
[$memberId]SyncPlayManager.php:1401-1404@a63dc0db, hubSyncPlayRelayWorker.php:2432-2435@30012378), so your own indicator never comes back to youronMemberTyping. onQueueChangedis the only live queue signal. An acceptedsyncplay_playback_queuebroadcast replaces the queue wholesale and the server does NOT re-broadcastgroup_stateafter it (SyncPlayManager.php:1295-1297@a63dc0db— thepublishSnapshot()at :1302 is an internal HTTP-worker snapshot). The library delivers the normalized items (per-carrier item law, SPEC §4: hub items lackadded_*— absent, never zeroed), folds them intogetGroup().queue, and does not re-fireonState. A cleared queue arrives as[], distinct from a frame that carried no array (dropped whole). Refusals (syncplay.queue_limit_exceeded,NOT_HOST) come throughonError, never here.
This library owns no socket and no periodic timers — your transport opens,
closes, and reconnects the WebSocket. When the socket drops, call
client.onDisconnect()
before reconnecting. It resets the time-sync samples + drift, forgets the
current group, and drops the outstanding ping so a late pong from the dead
connection cannot seed a bogus sample (then fires the optional onDisconnect
callback for your UI). It does NOT reconnect or re-join.
The required ordering (also in SPEC.md §10):
socket close → client.onDisconnect() → reconnect → joinGroup(...) → resume pingTime()
Backoff is your transport's job (this lib schedules no periodic timers —
the one owned-timer exception is the sendSeek coalescing flush, see "Command
repeat policy"). Recommended
recipe — exponential backoff with full jitter, capped, reset on a clean open.
The recipe is consumer-side code: its Math.random() sits outside the
module-scope purity claim above (which binds this library's src/, never your
transport), and SPEC §10.3 defers to this snippet as the estate recipe. The
load-bearing lines are the ordering ones — client.onDisconnect() before every
reschedule and attempt = 0 only on a clean open (SPEC §10.1/§10.2). The other
load-bearing duty is single-fire: transports commonly raise error and
then close for one failure, and the two handlers must count that drop
once (the per-socket guard below), while every reschedule and every clean
open cancels the pending timer — otherwise one blip arms two dials and the
backoff ladder doubles per storm. (Verified against real browser WebSocket and
ws behavior; an unguarded onclose + onerror pair calls
client.onDisconnect() twice per drop.)
const BASE_MS = 1000; // first retry delay
const MAX_MS = 30_000; // cap
let attempt = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
function scheduleReconnect() {
if (timer !== null) clearTimeout(timer); // one pending dial at a time
const expo = Math.min(MAX_MS, BASE_MS * 2 ** attempt);
const delay = Math.random() * expo; // full jitter (consumer-side — see purity note above)
attempt += 1;
timer = setTimeout(connect, delay);
}
function connect() {
// Carrier on every dial, reconnects included (SPEC §8.4):
const ws = new WebSocket(URL, ['bearer', token]);
let dropped = false; // this socket's drop is counted once
ws.onmessage = (e) => client.handleIncoming(e.data);
ws.onopen = () => {
if (timer !== null) clearTimeout(timer); // this dial won — retire its fallback
attempt = 0; // reset backoff on success
client.joinGroup(savedGroupId, savedPasswordHash);
// resume your periodic pingTime() scheduler here
};
const onDrop = () => {
if (dropped) return; // error→close is ONE drop
dropped = true;
client.onDisconnect(); // reset client state FIRST
scheduleReconnect();
};
ws.onclose = onDrop;
ws.onerror = onDrop;
}import {
SYNCPLAY_MESSAGE_TYPES, // the 19 canonical type strings
ALL_MESSAGE_TYPES, // the same 19 as a frozen array (validation/iteration)
PROTOCOL_VERSION, // 1
encodeMessage, // (type, payload, now) -> flat RAW JSON object
serializeMessage, // (RAW object) -> wire JSON string (server-shaped envelope)
decodeMessage, // (raw) -> flat RawMessage | UnknownFrame | null (unwraps Tizen envelope)
isValidMessageType, // (type) -> true for exactly the 19 canonical strings
TimeSync, // NTP offset/latency/stability/drift
} from '@phlix/syncplay';TimeSync's tuning constants ship too: OFFSET_SAMPLE_COUNT (5),
MAX_ACCEPTABLE_RTT (ms), STABILITY_VARIANCE_THRESHOLD,
DRIFT_CORRECTION_FACTOR, DRIFT_RATE_MIN/DRIFT_RATE_MAX,
TIME_SYNC_PROTOCOL_VERSION; instances expose getSampleAgeMs() (ms since
the last accepted sample, null with none).
Types for the two group_state shapes are exported too: SyncPlayGroupWire /
SyncPlayMembersWire (the frame as received — members is a dict keyed by
member id) and SyncPlayGroup (the normalized model with members: SyncPlayMember[]).
TimeSync reproduces the server's TimeSync.php math:
rtt = t4 - t1 - (t3 - t2) // server pong has no t3 → pass t3 = t2
offset = t2 - t1 + trunc(rtt/2) // add to local time to get server time —
// rtt/2 is INTEGER-TRUNCATED (`Math.trunc`
// in addSample), mirroring the server's int cast
Offset is a low-RTT-weighted mean over the last 5 samples; sync is "stable" when ≥5 samples and the population variance of recent offsets is < 50 (unit ms² — variance of ms; mirrors the server's unit-less 50); drift is an EMA with factor 0.1.
One application law for getAdjustedPosition(): it advances the playhead
unconditionally from the last commanded position on the corrected clock. Call
it ONLY while the group's playback_state is playing — while paused, HOLD the
commanded position (the seek/playback frames carry it); interpolating past a
pause drifts the picture off the frame every peer honored.
npm install
npm run lint
npm run typecheck
npm run build # ES + CJS + d.ts into dist/
npm run test:run # vitestMIT