diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9a65625d..fa4cf54cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` (`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`.
diff --git a/clients/realtime.js b/clients/realtime.js
index 98b4e1834..c96f0a15c 100644
--- a/clients/realtime.js
+++ b/clients/realtime.js
@@ -22,6 +22,18 @@
* 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'
@@ -29,17 +41,27 @@ import { decodeEvent, registerMetaSchema, registerKvSchema } from './gamend_prot
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
@@ -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} 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
@@ -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) =>
diff --git a/clients/smoke_package.js b/clients/smoke_package.js
index 5879a60f9..ce204305a 100644
--- a/clients/smoke_package.js
+++ b/clients/smoke_package.js
@@ -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'))
diff --git a/priv/docs/30-clients/20-js-sdk.md b/priv/docs/30-clients/20-js-sdk.md
index c915353b2..a811dcd89 100644
--- a/priv/docs/30-clients/20-js-sdk.md
+++ b/priv/docs/30-clients/20-js-sdk.md
@@ -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.