Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions partyplug/PartyConnection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ declare class PartyConnection {
readonly connected: boolean;

connect(): void;
/** Create a room (display, slot 0). */
create(maxClients: number): void;
/**
* Create a room (display, slot 0). `url` is an optional controller-URL
* template ({room}/{instance} placeholders) the relay resolves for clients
* that hold only the room code. Must be absolute https or the relay rejects
* the create; omit it on non-https origins.
*/
create(maxClients: number, url?: string): void;
/** Join a room by code (controller). */
join(room: string): void;
/** Pin auto-reconnect to a relay shard by rebuilding the sharded URL. */
Expand All @@ -21,13 +26,15 @@ declare class PartyConnection {
broadcast(data: any): void;
/** Publish a retained state snapshot (host/slot-0 only). Replayed to clients on (re)join and pushed live to peers. <= 16 KiB serialized. */
setState(data: any): void;
/** Tear the room down for everyone (host/slot-0 only). The relay deletes the room and closes every member socket with 4001 (onClose meta.roomClosed). */
closeRoom(): void;
reconnectNow(): void;
resetReconnectCount(): void;
close(): void;

// Callbacks (assigned as properties).
onOpen: (() => void) | null;
onClose: ((attempt: number, maxAttempts: number, meta?: { replaced?: boolean }) => void) | null;
onClose: ((attempt: number, maxAttempts: number, meta?: { replaced?: boolean; roomClosed?: boolean }) => void) | null;
onError: (() => void) | null;
onMessage: ((from: number, data: any) => void) | null;
onProtocol: ((type: string, msg: any) => void) | null;
Expand Down
55 changes: 49 additions & 6 deletions partyplug/PartyConnection.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
* (e.g. in localStorage) and pass it in.
*
* Party-Server protocol:
* Client → PS: create { clientId, maxClients }
* Client → PS: create { clientId, maxClients, url? }
* Client → PS: join { clientId, room }
* Client → PS: send { data, to? } // to is a peer index (number)
* PS → Client: created { room, index: 0, instance?, region? }
* PS → Client: joined { room, index, peers: number[] }
* PS → Client: created { room, index: 0, instance?, region?, url? }
* PS → Client: joined { room, index, peers: number[], url? }
* PS → Client: peer_joined { index }
* PS → Client: peer_left { index }
* PS → Client: message { from, data } // from is a peer index (number)
Expand All @@ -40,7 +40,7 @@ class PartyConnection {

// Callbacks
this.onOpen = null; // () => void
this.onClose = null; // (attempt: number, maxAttempts: number, meta?: {replaced: boolean}) => void
this.onClose = null; // (attempt: number, maxAttempts: number, meta?: {replaced?: boolean, roomClosed?: boolean}) => void
this.onError = null; // () => void
this.onMessage = null; // (from: number, data: object) => void
this.onProtocol = null; // (type: string, msg: object) => void
Expand Down Expand Up @@ -98,6 +98,14 @@ class PartyConnection {
if (this.onClose) this.onClose(0, 0, { replaced: true });
return;
}
if (event && event.code === 4001) {
// The room itself is gone (host sent close_room, or the relay's
// hostless grace expired). Terminal: a reconnect would only bounce
// off "Room not found".
this._shouldReconnect = false;
if (this.onClose) this.onClose(0, 0, { roomClosed: true });
return;
}
this.reconnectAttempt++;
if (this.onClose) this.onClose(this.reconnectAttempt, this.maxReconnectAttempts);
if (this._shouldReconnect && this.reconnectAttempt <= this.maxReconnectAttempts) {
Expand Down Expand Up @@ -135,8 +143,17 @@ class PartyConnection {
}
}

create(maxClients) {
this._send({ type: 'create', clientId: this.clientId, maxClients: maxClients });
// Create a room. `url` is an optional controller-URL template the relay
// retains on the room and hands (with {room}/{instance} filled in) to anyone
// who holds only the room code — in `created`/`joined` replies and via
// GET /room/:code — so a code-only client can resolve which page to load.
// The relay only accepts absolute https templates and rejects the whole
// create on an invalid one, so callers must omit it rather than send a
// non-https URL (e.g. a plain-http dev origin).
create(maxClients, url) {
var msg = { type: 'create', clientId: this.clientId, maxClients: maxClients };
if (url) msg.url = url;
this._send(msg);
}

join(room) {
Expand Down Expand Up @@ -170,11 +187,37 @@ class PartyConnection {
this._send({ type: 'set_state', data: data });
}

// Tear the room down for everyone (host/slot-0 only; the relay rejects it
// from anyone else). The relay deletes the room, GET /room/:code turns 404
// (killing stale rejoin links), and every member socket is closed with 4001,
// which surfaces to them as onClose(0, 0, {roomClosed: true}). There is no
// ack message: the sender's own 4001 close is the confirmation, unless the
// caller close()s first (fine on pagehide, where the page is going away).
closeRoom() {
this._send({ type: 'close_room' });
}

reconnectNow() {
clearTimeout(this._reconnectTimer);
this.connect();
}

// Treat the current socket as a failed attempt and drive the normal
// backoff/give-up path — for callers that detect failure themselves (e.g. a
// socket that opened but the relay never answered create/join, which never
// triggers onclose). Mirrors an unexpected onclose: discards the dead socket,
// bumps the attempt counter, notifies onClose, and either schedules a backoff
// reconnect or gives up once maxReconnectAttempts is passed.
failAttempt() {
if (!this._shouldReconnect) return;
this._discardOldWs();
this.reconnectAttempt++;
if (this.onClose) this.onClose(this.reconnectAttempt, this.maxReconnectAttempts);
if (this.reconnectAttempt <= this.maxReconnectAttempts) {
this._scheduleReconnect();
}
}

resetReconnectCount() {
this.reconnectAttempt = 0;
}
Expand Down
6 changes: 5 additions & 1 deletion partyplug/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,23 @@ new PartyConnection(relayUrl, { clientId?, maxReconnectAttempts = 5 })
| Method | Purpose |
| --- | --- |
| `connect()` | Open the socket (auto-reconnects up to max) |
| `create(maxClients)` | Create a room (display, slot 0) |
| `create(maxClients, url?)` | Create a room (display, slot 0). `url` is an optional controller-URL template (`{room}`/`{instance}` placeholders) the relay resolves for clients that hold only the room code (in `created`/`joined` and via `GET /room/:code`). Absolute https only, or the relay rejects the create; omit it on non-https origins |
| `join(room)` | Join a room by code (controller) |
| `pinInstance(baseUrl, room, instance)` | Pin auto-reconnect to a relay shard (rebuilds the sharded URL) |
| `sendTo(to, data)` | Send to one slot |
| `broadcast(data)` | Send to all peers |
| `setState(data)` | Publish the retained room snapshot (host/slot-0 only; ≤ 16 KiB serialized) |
| `closeRoom()` | Tear the room down for everyone (host/slot-0 only): the relay deletes it (`GET /room/:code` turns 404) and closes every member socket with 4001, surfaced to them as `onClose` `{ roomClosed }` |
| `reconnectNow()` / `resetReconnectCount()` | Manual reconnect control |
| `close()` | Tear down, stop reconnecting |

Callbacks (assigned as properties):

- `onOpen()`
- `onClose(attempt, maxAttempts, meta?)` where `meta` may carry `{ replaced }`
(evicted by a same-clientId join, close 4000) or `{ roomClosed }` (the room
itself is gone: host `closeRoom()` or the relay's hostless grace, close 4001);
both are terminal, no auto-reconnect follows
- `onError()`
- `onMessage(from, data)` for game messages
- `onProtocol(type, msg)` for relay events (`created`, `joined`, `peer_joined`, `peer_left`)
Expand Down
44 changes: 44 additions & 0 deletions partyplug/tests/party-connection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ describe('PartyConnection', () => {
});
});

test('closeRoom sends a close_room message', () => {
const pc = new PartyConnection('wss://test.example.com', { clientId: 'display' });
pc.connect();
MockWebSocket._instances[0]._simulateOpen();
pc.closeRoom();
assert.deepStrictEqual(MockWebSocket._instances[0]._sent[0], { type: 'close_room' });
});

test('onState fires for a state message and carries the snapshot data', () => {
const pc = new PartyConnection('wss://test.example.com');
let received;
Expand Down Expand Up @@ -256,6 +264,24 @@ describe('PartyConnection - reconnect with exponential backoff', () => {
assert.strictEqual(MockWebSocket._instances.length, 1);
});

test('room-closed close (4001) stops reconnecting and reports roomClosed', () => {
const pc = new PartyConnection('wss://test.example.com', { maxReconnectAttempts: 3 });
let closeArgs = null;
pc.onClose = (attempt, max, meta) => { closeArgs = { attempt, max, meta }; };
pc.connect();

MockWebSocket._instances[0]._simulateClose({ code: 4001 });

assert.strictEqual(pc._shouldReconnect, false);
assert.strictEqual(pc.reconnectAttempt, 0);
assert.deepStrictEqual(closeArgs, {
attempt: 0,
max: 0,
meta: { roomClosed: true },
});
assert.strictEqual(MockWebSocket._instances.length, 1);
});

test('reconnect stops after maxReconnectAttempts', () => {
const pc = new PartyConnection('wss://test.example.com', { maxReconnectAttempts: 2 });
pc.connect();
Expand Down Expand Up @@ -354,6 +380,24 @@ describe('PartyConnection - reconnect with exponential backoff', () => {
});
});

test('create with a controller-URL template includes url; undefined omits the field', () => {
// The relay rejects the whole create on an invalid template, so the field
// must be absent (not null/undefined) when the caller has none to register.
const pc = new PartyConnection('wss://test.example.com', { clientId: 'display' });
pc.connect();
MockWebSocket._instances[0]._simulateOpen();
pc.create(9, 'https://play.example.com/{room}#{instance}');
pc.create(9, undefined);
const sent = MockWebSocket._instances[0]._sent;
assert.deepStrictEqual(sent[0], {
type: 'create',
clientId: 'display',
maxClients: 9,
url: 'https://play.example.com/{room}#{instance}'
});
assert.deepStrictEqual(Object.keys(sent[1]), ['type', 'clientId', 'maxClients']);
});

test('join sends correct relay message', () => {
const pc = new PartyConnection('wss://test.example.com', { clientId: 'player1' });
pc.connect();
Expand Down
4 changes: 4 additions & 0 deletions public/controller/Net.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export class ControllerNet extends GameNet {
this.party.onClose = (attempt, max, meta) => {
this._stopPing();
if (meta && meta.replaced) { this.onStatus('replaced'); return; }
// The relay tore the room down (display sent close_room on its way out,
// or the hostless grace expired): terminal — PartyConnection has already
// stopped reconnecting, a rejoin would only bounce off "Room not found".
if (meta && meta.roomClosed) { this.onStatus('room_closed'); return; }
// attempt > max: PartyConnection has stopped retrying — the link is down for
// good until the player acts. Surface a distinct 'lost' so the UI can offer a
// retry (and point at the big screen's reconnect QR).
Expand Down
5 changes: 5 additions & 0 deletions public/controller/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ const net = new ControllerNet({
setStatus('Waiting for the big screen…');
if (inRoom) showConn('Waiting for the big screen…', 'The host’s screen dropped — hang tight, it’ll reconnect you.', false);
armBail(); // not back within the grace window → the game has ended (also covers a fresh join into an abandoned room, still on the name screen)
} else if (state === 'room_closed') {
// The relay closed the whole room (4001) — the display tore it down on
// its way out, or its hostless grace expired. Same destination as the
// DISPLAY_CLOSED goodbye, minus the wait: the party is over for good.
bailTo('game_ended');
} else if (state === 'replaced') {
setStatus('Opened on another tab.');
if (inRoom) showConn('Opened on another tab', 'This seat is now controlled from another tab or device.', false);
Expand Down
39 changes: 37 additions & 2 deletions public/display/Net.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export class DisplayNet extends GameNet {

this.party.onOpen = () => {
if (this.roomCode) this.party.join(this.roomCode);
else this.party.create(MAX_PLAYERS + 1); // +1 for the display itself
else this.party.create(MAX_PLAYERS + 1, this._controllerUrlTemplate()); // +1 for the display itself
};
this.party.onProtocol = (type, msg) => this._onProtocol(type, msg);
this.party.onMessage = (from, data) => this._onMessage(from, data);
Expand Down Expand Up @@ -149,7 +149,17 @@ export class DisplayNet extends GameNet {
this._removePeer(msg.index);
break;
case 'error':
console.warn('[relay]', msg.message);
// A rejected CREATE (bad url template, relay hiccup) would otherwise
// dead-end silently — the socket is open, so no onclose-driven retry
// ever fires. failAttempt() treats it as a failed attempt and drives
// the normal backoff/reconnect, which re-creates on the next onOpen.
// Join-phase errors (roomCode set) keep the old warn-only behavior.
if (!this.roomCode) {
console.warn('[relay] create rejected:', msg.message);
this.party.failAttempt();
} else {
console.warn('[relay]', msg.message);
}
break;
}
}
Expand Down Expand Up @@ -381,11 +391,36 @@ export class DisplayNet extends GameNet {
sendTo(id, data) { if (this.party) this.party.sendTo(id, data); }
get roomState() { return this.flow.state; }

// Goodbye on intentional close/navigate-away: tear the room down on the
// relay. Every controller socket gets a 4001 "room closed" (surfaced as
// onClose {roomClosed} — their terminal party-over signal) and GET
// /room/:code turns 404 right away, so stale rejoin/claim QRs die with the
// room. Best-effort on pagehide (bfcache freeze may drop the send); the
// DISPLAY_CLOSED broadcast and the controllers' display-gone bail cover
// whatever doesn't land. close() last so no auto-reconnect resurrects us.
closeRoom() {
if (!this.party) return;
try { this.party.closeRoom(); } catch (_) {}
this.party.close();
}

// ---- join URL / QR base ----
_joinUrl() {
const base = this.baseUrlOverride || window.location.origin;
return base + '/' + this.roomCode + (this.instance ? '#' + enc(this.instance) : '');
}
// Controller-URL template registered with the relay on room create. The relay
// fills {room}/{instance} and hands the result to anyone holding only the
// room code (native shells via GET /room/:code, controllers in `joined`), so
// a code-only join can still resolve which page to load. Mirrors _joinUrl's
// shape: instance in the fragment, kept out of request logs. The relay
// accepts only absolute https templates and rejects the whole create on an
// invalid one, so plain-http origins (local dev, e2e) register none.
_controllerUrlTemplate() {
const base = (this.baseUrlOverride || window.location.origin).replace(/\/+$/, '');
if (!base.startsWith('https://')) return undefined;
return base + '/{room}#{instance}';
}
async _fetchBaseUrl() {
const host = window.location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1') return;
Expand Down
14 changes: 10 additions & 4 deletions public/display/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -1079,10 +1079,16 @@ if (params.get('test') === '1' || scenario) {
// Goodbye on the way out: closing/navigating the big screen ends the game
// (a reload creates a brand-new room), so tell the phones NOW — they bail
// straight to the device chooser instead of sitting out the display-gone
// grace window. Best-effort: an unload-time WS send can be dropped (crash,
// dead battery, iOS killing the page), which is exactly what the
// controller's bail timer still covers.
window.addEventListener('pagehide', () => net.broadcast({ type: MSG.DISPLAY_CLOSED }));
// grace window — and tear the room down on the relay (closeRoom): every
// controller socket gets its terminal 4001 {roomClosed} and GET /room/:code
// turns 404, so stale join/claim QRs die with the room. DISPLAY_CLOSED goes
// first (same socket, in order) as the app-level goodbye. Best-effort: an
// unload-time WS send can be dropped (crash, dead battery, iOS killing the
// page), which is exactly what the controller's bail timer still covers.
window.addEventListener('pagehide', () => {
net.broadcast({ type: MSG.DISPLAY_CLOSED });
net.closeRoom();
});
}

// debug hooks
Expand Down