Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

# September 2026

- [fixed] **The JavaScript SDK's `GameRealtime` reconnects with a refreshed JWT.** It baked the access token into the Phoenix.Socket connect params at construction and never updated it, so once the 15-minute token expired (or the transport closed on a backgrounded tab) the socket retried with the stale token, the server rejected the handshake with a `403`, and the client looped forever until the app destroyed and rebuilt the entire instance. `GameRealtime` now takes an optional `tokenProvider` function as its fourth argument; when supplied, socket and channel params are stored as functions that Phoenix JS evaluates fresh on every reconnect and rejoin, picking up the current token without an instance rebuild. Omitting it preserves the original static-token behaviour. The JS SDK guide documents the option; the Godot client already does the same via its `_token_provider` Callable. Fixes #47.

- [added] **`panel/1`, `eyebrow/1`, `page_title/1` and `search_input/1`, and a boxed `empty_state/1`.** The pieces a host kept redrawing with its own classes: a box set on the page (`compact`, `title`, `tag`), a group's small uppercase heading, the page `<h1>` (`header/1` draws it now, a step smaller on a phone), and a search box with its magnifier and an optional X (`close` attributes). The site search palette uses `search_input/1`, so its magnifier and the host's cannot drift. `empty_state/1` takes an `:actions` slot and `compact`, its icon is optional, and it draws a dashed box rather than a bare block, on the changelog and blog empty pages too. `/ui` shows them all and uses them itself.

- [added] **A page of the site's building blocks at `/ui`.** Buttons (roles, icons, icon-only, sizes, states, colours), badges, form controls, alerts, surfaces, the theme's colours, type, and loading, progress, tabs and a dropdown, each captioned with the classes that draw it, so a new screen copies them rather than inventing a look. `PageController.ui/2` with `PageHTML.ui_section/1`, `ui_row/1` and `specimen/1`; `noindex, follow`, since it is a reference. Linked from the footer of the default theme and the starter's; a host adds `/ui` to its own footer. It spells every class out in full, so Tailwind generates them, and uses no component a host may `exclude`.
Expand Down
66 changes: 54 additions & 12 deletions clients/realtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,46 @@
* realtime.disconnect()
*
* Dependency: `phoenix` (bundled with @ughuuu/gamend)
*
* Token refresh on reconnect:
*
* Access tokens expire (default 15 min). Pass a tokenProvider so the socket
* reconnects automatically with the current token — no instance rebuild:
*
* const realtime = new GameRealtime(
* 'https://your-server.com',
* accessToken, // initial token (used immediately)
* {}, // socketOpts (e.g. { format: 'protobuf' })
* () => auth.currentAccessToken, // tokenProvider — called on every reconnect
* )
*/

import { Socket } from 'phoenix'
import { decodeEvent, registerMetaSchema, registerKvSchema } from './gamend_proto.js'

export class GameRealtime {
/**
* @param {string} serverUrl - Base HTTP(S) or WS(S) server URL,
* e.g. "https://game.example.com" or "wss://game.example.com"
* @param {string} token - JWT access token from the REST login endpoints
* @param {Object} socketOpts - Optional Phoenix.Socket constructor options.
* Pass `format: 'protobuf'` to receive server
* events as protobuf binary frames; channels
* obtained through the join helpers decode them
* transparently (timestamps become unix-ms
* numbers, see proto/gamend_realtime.proto).
* @param {string} serverUrl - Base HTTP(S) or WS(S) server URL,
* e.g. "https://game.example.com" or "wss://game.example.com"
* @param {string} token - JWT access token from the REST login endpoints
* @param {Object} socketOpts - Optional Phoenix.Socket constructor options.
* Pass `format: 'protobuf'` to receive server
* events as protobuf binary frames; channels
* obtained through the join helpers decode them
* transparently (timestamps become unix-ms
* numbers, see proto/gamend_realtime.proto).
* @param {Function} [tokenProvider] - Optional function returning the current
* JWT access token string. When supplied, the
* socket params are evaluated on every
* reconnect (and channel rejoin), so an
* expired token is replaced with the refreshed
* one automatically — no instance rebuild needed.
* The initial `token` is used for the first
* connection; supply a provider that returns
* the live access token (e.g. from your auth
* session).
*/
constructor(serverUrl, token, socketOpts = {}) {
constructor(serverUrl, token, socketOpts = {}, tokenProvider) {
// Normalise URL: strip trailing slash, ensure ws(s):// scheme, append /socket
const wsUrl =
serverUrl
Expand All @@ -48,14 +70,28 @@ export class GameRealtime {

const { format, ...opts } = socketOpts
this._token = token
this._tokenProvider = typeof tokenProvider === 'function' ? tokenProvider : null
this._format = format === 'protobuf' ? 'protobuf' : 'json'
const params = this._format === 'protobuf' ? { token, format: 'protobuf' } : { token }
// When tokenProvider is set, params is a function: Phoenix JS calls it fresh
// on every transportConnect() (reconnect), picking up the current token.
// When it is not set, params is a static object (backward compatible).
const params = this._tokenProvider ? () => this._socketParams() : this._socketParams()
this._socket = new Socket(wsUrl, { params, ...opts })
this._socket.connect()
/** @type {Map<string, Object>} topic → Phoenix Channel */
this._channels = new Map()
}

/**
* Build the connection params, sourcing the token from tokenProvider when
* available so reconnects use the current (possibly refreshed) token.
* @returns {Object}
*/
_socketParams() {
const token = this._tokenProvider ? this._tokenProvider() : this._token
return this._format === 'protobuf' ? { token, format: 'protobuf' } : { token }
}

/**
* Registers the game's protobuf metadata schema for an entity (mirrors the
* server plugin's UserMeta/LobbyMeta/GroupMeta/PartyMeta registration), so
Expand Down Expand Up @@ -231,7 +267,13 @@ export class GameRealtime {
if (this._channels.has(topic)) {
return this._channels.get(topic)
}
const ch = this._socket.channel(topic, { token: this._token, ...extraParams })
// When tokenProvider is set, params is a function so Phoenix JS calls it
// fresh on every channel rejoin (after a socket reconnect), using the
// current token. When not set, params is a static object (backward
// compatible).
const buildParams = () => ({ ...this._socketParams(), ...extraParams })
const params = this._tokenProvider ? buildParams : buildParams()
const ch = this._socket.channel(topic, params)
if (this._format === 'protobuf') this._wrapBinaryDecode(ch)
ch.join()
.receive('error', (err) =>
Expand Down
18 changes: 18 additions & 0 deletions clients/smoke_package.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ server.listen(0, '127.0.0.1', async () => {
check('GameRealtime is exported', typeof GameRealtime === 'function')
check('GameWebRTC is exported', typeof GameWebRTC === 'function')

// Constructing GameRealtime exercises the Phoenix.Socket wrapper and proves
// the bundle can reach phoenix. Passing a tokenProvider (4th arg, the fix
// for #47) must not throw: Phoenix JS stores params as a function and only
// calls it on an actual transport connect, which we pre-empt with disconnect().
try {
const realtime = new GameRealtime(
`ws://127.0.0.1:${port}`,
'dummy-token',
{},
() => 'dummy-token'
)
check('GameRealtime accepts a tokenProvider (4th arg)', typeof realtime === 'object')
realtime.disconnect()
check('GameRealtime disconnects cleanly with a tokenProvider', true)
} catch (e) {
check('GameRealtime accepts a tokenProvider (4th arg)', false, e.message)
}

// realtime.js pulls phoenix; gamend_realtime.pb.js pulls protobufjs.
// Requiring them here is what catches an undeclared runtime dependency.
const proto = require(path.join(__dirname, 'javascript', 'dist', 'gamend_proto.js'))
Expand Down
28 changes: 28 additions & 0 deletions priv/docs/30-clients/20-js-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,31 @@ unix-ms numbers.
`updated` carries the **full** object rather than a delta, so diff against your
last copy if you need to know which field moved. The complete topic and event
list is in the Realtime guide.

### Token refresh on reconnect

Access tokens last 15 minutes. Once one expires, a WebSocket that reconnects
sends the stale token and the server rejects the handshake with a `403`, putting
the client in a reconnect loop until the app itself tears the socket down and
rebuilds it. To avoid that, pass a `tokenProvider` — a zero-argument function
that returns the current access token — as the **fourth** constructor argument:

```javascript
const realtime = new GameRealtime(
'https://your-server.com',
access_token, // initial token, used for the first connection
{}, // socketOpts (e.g. { format: 'protobuf' })
() => auth.currentAccessToken // tokenProvider, called on every reconnect + rejoin
)
```

When a `tokenProvider` is supplied, the socket stores its params as a function,
which Phoenix JS evaluates fresh on every `transportConnect()` (reconnect). Your
auth layer refreshes the token ahead of the 15-minute expiry (see [Authenticate](#authenticate)
above) and writes the new value to whatever the provider reads, so the next
reconnect carries the valid token with no instance rebuild. Channel join params
become a function too, so rejoins after a socket reconnect also use the current
token. Omitting `tokenProvider` preserves the original behaviour (a static token
baked at construction). The Godot SDK does the same thing automatically:
`GamendWebSocket` takes a `_token_provider` Callable and reconnects with a
refreshed token.