fix(journal): decouple broker from session

The broker connection now persists across session changes. Joining or switching a session swaps subscriptions instead of reconnecting, and a lobby lets sessions be listed and created before joining one.

Fixes session switching being a no-op (the store only updated local state) and session creation being unreachable before connecting. Also removes the dead ConnectionBar and the no-op hydrateFromServer/setSessionId store calls.
This commit is contained in:
2026-09-13 13:23:50 +08:00
parent 59f87bffda
commit 30ec8a43cf
9 changed files with 350 additions and 293 deletions
+116 -46
View File
@@ -1,27 +1,84 @@
/** /**
* ConnectDialog — name + role picker to join a session * ConnectDialog — broker connection + session lobby (list / create / join)
*
* The broker connection and the active session are decoupled: this dialog
* connects to the broker, which makes the session list available, and joining
* or creating a session is a separate step that reuses that connection.
*/ */
import { Component, createSignal, onMount, Show } from "solid-js"; import { Component, createSignal, For, onMount, Show } from "solid-js";
import { import {
connectStream, connectBroker,
hydrateFromServer, createSession,
useJournalStream, joinSession,
resolveBrokerUrl,
sessions,
setMyName, setMyName,
setMyRole, setMyRole,
setSessionId, useJournalStream,
} from "../stores/journalStream"; } from "../stores/journalStream";
export const ConnectDialog: Component = () => { export const ConnectDialog: Component = () => {
const stream = useJournalStream(); const stream = useJournalStream();
const manifest = sessions;
const [playerName, setPlayerName] = createSignal(stream.myName); const [playerName, setPlayerName] = createSignal(stream.myName);
const [role, setRole] = createSignal<"gm" | "player" | "observer">( const [role, setRole] = createSignal<"gm" | "player" | "observer">(
stream.myRole, stream.myRole,
); );
const [connecting, setConnecting] = createSignal(false); const [newSessionName, setNewSessionName] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
const [autoJoined, setAutoJoined] = createSignal(false); const [autoJoined, setAutoJoined] = createSignal(false);
// Auto-join when URL has both session and player params const ensureBroker = async () => {
if (stream.connected) return;
await connectBroker(resolveBrokerUrl());
};
const identity = () => ({
name: playerName().trim() || stream.myName,
role: role(),
});
const applyIdentity = () => {
const { name, role: r } = identity();
setMyName(name);
setMyRole(r);
};
const handleJoin = async (sessionId: string) => {
setBusy(true);
setError(null);
try {
applyIdentity();
await ensureBroker();
await joinSession(sessionId, identity());
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setBusy(false);
}
};
const handleCreate = async () => {
const name = newSessionName().trim();
if (!name) return;
setBusy(true);
setError(null);
try {
applyIdentity();
await ensureBroker();
const id = await createSession(name);
if (id) await joinSession(id, identity());
setNewSessionName("");
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setBusy(false);
}
};
// Auto-join when the URL carries session + player + autojoin params;
// otherwise enter the lobby so the session list is available up front.
onMount(() => { onMount(() => {
const params = new URL(window.location.href).searchParams; const params = new URL(window.location.href).searchParams;
if ( if (
@@ -32,51 +89,26 @@ export const ConnectDialog: Component = () => {
setPlayerName(params.get("player") || ""); setPlayerName(params.get("player") || "");
setRole("player"); setRole("player");
setAutoJoined(true); setAutoJoined(true);
handleConnect(); handleJoin(params.get("session")!);
return;
} }
ensureBroker().catch((e) => {
setError(e instanceof Error ? e.message : "Connection failed");
});
}); });
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
// rsbuild dev server. In production, the CLI serves everything itself.
const brokerUrl =
process.env.NODE_ENV === "development"
? `ws://localhost:3000`
: `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
setMyRole(role());
const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
} finally {
setConnecting(false);
}
};
return ( return (
<Show <Show
when={!autoJoined() || error()} when={!autoJoined() || error()}
fallback={ fallback={
<p class="text-sm text-gray-500 text-center"> <p class="text-sm text-gray-500 text-center">
{connecting() ? "正在加入会话…" : "已加入!"} {busy() ? "正在加入会话…" : "已加入!"}
</p> </p>
} }
> >
<div class="w-full max-w-sm space-y-3"> <div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center"> <p class="text-sm text-gray-600 text-center">
请输入你的名字和角色来加入会话。 输入你的名字和角色,选择或新建一个会话。
<br /> <br />
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400">
连接到 {window.location.host} 连接到 {window.location.host}
@@ -86,7 +118,7 @@ export const ConnectDialog: Component = () => {
type="text" type="text"
value={playerName()} value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)} onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()} onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="你的名字" placeholder="你的名字"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus autofocus
@@ -106,14 +138,52 @@ export const ConnectDialog: Component = () => {
</button> </button>
))} ))}
</div> </div>
{/* Session list */}
<div class="space-y-1">
<p class="text-xs text-gray-500">会话</p>
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<button <button
onClick={handleConnect} onClick={() => handleJoin(id)}
disabled={connecting() || !playerName().trim()} disabled={busy()}
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium class="w-full text-left px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50"
hover:bg-blue-700 disabled:opacity-50"
> >
{connecting() ? "连接中..." : "加入"} {meta.name || id}
</button> </button>
)}
</For>
<Show when={Object.keys(manifest().sessions).length === 0}>
<p class="px-2 py-2 text-xs text-gray-400">
{stream.connected ? "还没有会话" : "正在加载会话…"}
</p>
</Show>
</div>
</div>
{/* Create a new session and join it (GM only) */}
<Show when={role() === "gm"}>
<div class="flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="新会话名称"
class="flex-1 border border-gray-300 rounded px-2 py-1.5 text-sm"
/>
<button
onClick={handleCreate}
disabled={busy() || !newSessionName().trim()}
class="bg-blue-600 text-white rounded px-3 py-1.5 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
>
新建并加入
</button>
</div>
</Show>
<Show when={error()}> <Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p> <p class="text-red-500 text-sm text-center">{error()}</p>
</Show> </Show>
-148
View File
@@ -1,148 +0,0 @@
/**
* ConnectionBar — session picker, player info (connected state only)
*/
import { Component, createSignal, Show, For } from "solid-js";
import {
hydrateFromServer,
useJournalStream,
sessions,
createSession,
deleteSession,
disconnectStream,
setSessionId,
} from "../stores/journalStream";
import { journalSetState } from "../stores/journalStream";
export const ConnectionBar: Component = () => {
const stream = useJournalStream();
const [newSessionName, setNewSessionName] = createSignal("");
const [showNewSession, setShowNewSession] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const manifest = sessions;
const handleCreateSession = () => {
const name = newSessionName().trim();
if (!name) return;
const id = createSession(name);
if (id) {
setSessionId(id);
hydrateFromServer(id).catch(() => {});
}
setNewSessionName("");
setShowNewSession(false);
};
const handleDeleteSession = (id: string) => {
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
deleteSession(id);
}
};
const handleSessionSelect = async (id: string) => {
try {
await hydrateFromServer(id);
setSessionId(id);
} catch (e) {
setError("无法加载会话");
}
};
const handleDisconnect = () => {
disconnectStream();
};
return (
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
{/* Status row */}
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-500">已连接</span>
<Show when={stream.sessionId}>
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
{stream.sessionName || stream.sessionId}
</span>
</Show>
<div class="flex-1" />
<Show when={stream.myName !== "gm"}>
<span class="text-xs text-gray-400">扮演 {stream.myName}</span>
</Show>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500"
title="断开连接"
>
⏻
</button>
</div>
{/* GM session management */}
<Show when={stream.myName === "gm"}>
<div class="space-y-1">
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">会话列表:</span>
<button
onClick={() => setShowNewSession((v) => !v)}
class="text-xs text-blue-600 hover:underline"
>
+ 新建
</button>
</div>
<Show when={showNewSession()}>
<div class="flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)}
placeholder="会话名称"
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
/>
<button
onClick={handleCreateSession}
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
>
Create Create
</button>
</div>
</Show>
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<div
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
id === stream.sessionId
? "bg-blue-100 text-blue-800"
: "hover:bg-gray-100"
}`}
>
<span
class="flex-1 truncate"
onClick={() => handleSessionSelect(id)}
>
{meta.name || id}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(id);
}}
class="text-gray-400 hover:text-red-500 text-xs"
title="删除会话"
>
✕
</button>
</div>
)}
</For>
</div>
</Show>
<Show when={error()}>
<p class="text-red-500 text-xs">{error()}</p>
</Show>
</div>
);
};
@@ -2,7 +2,7 @@
* CreateSessionDialog — modal for naming a new session * CreateSessionDialog — modal for naming a new session
*/ */
import { Component, createSignal, onMount } from "solid-js"; import { Component, createSignal, onMount } from "solid-js";
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream"; import { createSession, joinSession } from "../stores/journalStream";
interface CreateSessionDialogProps { interface CreateSessionDialogProps {
onClose: () => void; onClose: () => void;
@@ -19,15 +19,16 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
const trimmed = name().trim(); const trimmed = name().trim();
if (!trimmed) return; if (!trimmed) return;
setCreating(true); setCreating(true);
const id = createSession(trimmed); try {
if (id) { const id = await createSession(trimmed);
setSessionId(id); if (id) await joinSession(id);
// Hydrate the new (empty) session so the UI reflects it immediately } catch (e) {
await hydrateFromServer(id).catch(() => {}); console.error("[journal] failed to create session:", e);
} } finally {
setName(""); setName("");
setCreating(false); setCreating(false);
props.onClose(); props.onClose();
}
}; };
onMount(() => inputRef?.focus()); onMount(() => inputRef?.focus());
+11 -9
View File
@@ -1,8 +1,8 @@
/** /**
* JournalHeader — status dot, player name, session dropdown, disconnect & close * JournalHeader — status dot, player name, session dropdown, leave & close
*/ */
import { Component, Show } from "solid-js"; import { Component, Show } from "solid-js";
import { useJournalStream, disconnectStream } from "../stores/journalStream"; import { useJournalStream, leaveSession } from "../stores/journalStream";
import { SessionDropdown } from "./SessionDropdown"; import { SessionDropdown } from "./SessionDropdown";
interface JournalHeaderProps { interface JournalHeaderProps {
@@ -13,7 +13,7 @@ interface JournalHeaderProps {
export const JournalHeader: Component<JournalHeaderProps> = (props) => { export const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream(); const stream = useJournalStream();
const handleDisconnect = () => disconnectStream(); const handleLeave = () => leaveSession();
return ( return (
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0"> <div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
@@ -32,7 +32,9 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
<Show <Show
when={stream.connected && stream.sessionId} when={stream.connected && stream.sessionId}
fallback={ fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2> <h2 class="text-sm font-semibold text-gray-700 truncate">
{stream.connected ? "选择会话" : "未连接"}
</h2>
} }
> >
<div class="min-w-0"> <div class="min-w-0">
@@ -58,13 +60,13 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
</Show> </Show>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}> <Show when={stream.sessionId}>
<button <button
onClick={handleDisconnect} onClick={handleLeave}
class="text-xs text-gray-400 hover:text-red-500 px-1" class="text-xs text-gray-400 hover:text-blue-500 px-1"
title="断开连接" title="返回会话列表"
> >
⏻ ←
</button> </button>
</Show> </Show>
<button <button
+2 -2
View File
@@ -59,7 +59,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
/> />
{/* Player list tabs */} {/* Player list tabs */}
<Show when={stream.connected}> <Show when={stream.connected && stream.sessionId}>
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0"> <div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
<For each={playerEntries()}> <For each={playerEntries()}>
{([name]) => ( {([name]) => (
@@ -79,7 +79,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
</Show> </Show>
<Show <Show
when={stream.connected} when={stream.connected && stream.sessionId}
fallback={ fallback={
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto"> <div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
<ConnectDialog /> <ConnectDialog />
+4 -4
View File
@@ -4,8 +4,7 @@
import { Component, createSignal, For, Show } from "solid-js"; import { Component, createSignal, For, Show } from "solid-js";
import { import {
sessions, sessions,
setSessionId, joinSession,
hydrateFromServer,
useJournalStream, useJournalStream,
} from "../stores/journalStream"; } from "../stores/journalStream";
@@ -25,8 +24,9 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
return; return;
} }
setDropdownOpen(false); setDropdownOpen(false);
setSessionId(id); joinSession(id).catch((e) => {
hydrateFromServer(id).catch(() => {}); console.error("[journal] failed to switch session:", e);
});
}; };
// Close dropdown on outside click // Close dropdown on outside click
+5 -3
View File
@@ -14,9 +14,12 @@ export {
} from "./registry"; } from "./registry";
export type { MessageTypeDef, StreamMessage } from "./registry"; export type { MessageTypeDef, StreamMessage } from "./registry";
export { export {
connectStream, connectBroker,
joinSession,
switchSession,
leaveSession,
disconnectStream, disconnectStream,
hydrateFromServer, resolveBrokerUrl,
sendMessage, sendMessage,
revertLatest, revertLatest,
canRevert, canRevert,
@@ -42,7 +45,6 @@ export { ConnectDialog } from "./ConnectDialog";
export { InviteDialog } from "./InviteDialog"; export { InviteDialog } from "./InviteDialog";
export { SessionDropdown } from "./SessionDropdown"; export { SessionDropdown } from "./SessionDropdown";
export { CreateSessionDialog } from "./CreateSessionDialog"; export { CreateSessionDialog } from "./CreateSessionDialog";
export { ConnectionBar } from "./ConnectionBar";
export { StreamView } from "./StreamView"; export { StreamView } from "./StreamView";
export { StreamMessageCard } from "./StreamMessage"; export { StreamMessageCard } from "./StreamMessage";
export { ComposePanel } from "./ComposePanel"; export { ComposePanel } from "./ComposePanel";
+45 -38
View File
@@ -282,29 +282,25 @@ export function setMyRole(role: "gm" | "player" | "observer"): void {
saveRole(role); saveRole(role);
} }
export function setSessionId(id: string | null): void { /**
setState("sessionId", id); * Resolve the broker URL for the current environment. In dev the MQTT broker
if (id) { * lives on the CLI server (port 3000); in production the CLI serves everything.
saveSessionId(id); */
syncUrlParam("session", id); export function resolveBrokerUrl(): string {
} else { return process.env.NODE_ENV === "development"
removeUrlParam("session"); ? "ws://localhost:3000"
setState("sessionName", null); : `ws://${window.location.host}`;
}
} }
export async function connectStream( /** Connect to the broker and enter the lobby (no active session). */
sessionId: string, export async function connectBroker(brokerUrl: string): Promise<void> {
brokerUrl: string,
): Promise<void> {
const api = getWorkerAPI(); const api = getWorkerAPI();
setState("connectionStatus", "connecting"); setState("connectionStatus", "connecting");
setState("connectionError", null); setState("connectionError", null);
try { try {
await api.connect(sessionId, brokerUrl, state.myName, state.myRole); await api.connect(brokerUrl, state.myName, state.myRole);
saveBrokerUrl(brokerUrl); saveBrokerUrl(brokerUrl);
saveSessionId(sessionId);
} catch (err) { } catch (err) {
setState("connectionStatus", "error"); setState("connectionStatus", "error");
setState( setState(
@@ -315,6 +311,32 @@ export async function connectStream(
} }
} }
/**
* Join (or switch to) a session. The broker connection is reused, so switching
* sessions is a subscription swap rather than a reconnect.
*/
export async function joinSession(
sessionId: string,
identity?: { name?: string; role?: "gm" | "player" | "observer" },
): Promise<void> {
const api = getWorkerAPI();
await api.joinSession(sessionId, identity?.name, identity?.role);
saveSessionId(sessionId);
syncUrlParam("session", sessionId);
}
/** Alias for `joinSession` — reading `switchSession` at call sites is clearer. */
export const switchSession = joinSession;
/** Leave the active session, returning to the lobby (broker stays connected). */
export function leaveSession(): void {
const api = getWorkerAPI();
api.leaveSession();
removeUrlParam("session");
setState("sessionId", null);
setState("sessionName", null);
}
export function sendMessage<T>( export function sendMessage<T>(
type: string, type: string,
payload: T, payload: T,
@@ -365,28 +387,21 @@ export function revertLatest():
export function disconnectStream(): void { export function disconnectStream(): void {
const api = getWorkerAPI(); const api = getWorkerAPI();
api.disconnect(); api.disconnect();
removeUrlParam("session");
removeUrlParam("autojoin"); removeUrlParam("autojoin");
} }
export function createSession( export async function createSession(
name: string, name: string,
players: string[] = [], players: string[] = [],
): string | null { ): Promise<string | null> {
const api = getWorkerAPI(); return getWorkerAPI().createSession(name, players);
// This is sync in the worker but async over Comlink.
// We generate the ID locally using the same algorithm.
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
api.createSession(name, players);
return id;
} }
export function deleteSession(sessionId: string): void { export async function deleteSession(sessionId: string): Promise<void> {
const api = getWorkerAPI(); await getWorkerAPI().deleteSession(sessionId);
api.deleteSession(sessionId); // Deleting the active session drops you back to the lobby.
if (state.sessionId === sessionId) leaveSession();
} }
export function canRevert(): boolean { export function canRevert(): boolean {
@@ -408,11 +423,3 @@ export function useJournalStream() {
} }
export { state as journalStreamState }; export { state as journalStreamState };
/**
* Hydrate from server — now handled by the worker during connect().
* Kept for API compatibility; does nothing.
*/
export async function hydrateFromServer(_sessionId: string): Promise<void> {
// Hydration is now handled by the worker during connect()
}
+154 -31
View File
@@ -139,11 +139,50 @@ async function hydrateFromServer(sessionId: string): Promise<void> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MQTT Connect // MQTT Connect — the broker connection is independent of the active session
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function connectStream( function subscribeSession(
client: import("mqtt").MqttClient,
sessionId: string, sessionId: string,
): void {
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[worker] stream sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
JSON.stringify({ name: state.myName, role: state.myRole }),
{ qos: 1, retain: true },
);
}
function unsubscribeSession(
client: import("mqtt").MqttClient,
sessionId: string,
): void {
// Clear own retained presence before dropping the subscription.
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
"",
{ qos: 1, retain: true },
);
client.unsubscribe(
[
`ttrpg/${sessionId}/stream`,
`ttrpg/${sessionId}/meta`,
`ttrpg/${sessionId}/presence/+`,
],
(err) => {
if (err) console.error("[worker] unsubscribe err:", err);
},
);
}
async function connectBroker(
brokerUrl: string, brokerUrl: string,
name: string, name: string,
role: "gm" | "player" | "observer", role: "gm" | "player" | "observer",
@@ -172,22 +211,13 @@ async function connectStream(
state.connectionError = null; state.connectionError = null;
state.brokerUrl = brokerUrl; state.brokerUrl = brokerUrl;
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => { // Broker-level subscription: the session list is available in the lobby.
if (err) console.error("[worker] stream sub err:", err);
});
client.subscribe($SESSIONS, { qos: 1 }, (err) => { client.subscribe($SESSIONS, { qos: 1 }, (err) => {
if (err) console.error("[worker] sessions sub err:", err); if (err) console.error("[worker] sessions sub err:", err);
}); });
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained) // Restore the active session's subscriptions after an auto-reconnect.
const presenceData = JSON.stringify({ name, role }); if (state.sessionId) subscribeSession(client, state.sessionId);
client.publish(
`ttrpg/${sessionId}/presence/${name}`,
presenceData,
{ qos: 1, retain: true },
);
notifySubscribers({ type: "connectionStatus", status: "connected" }); notifySubscribers({ type: "connectionStatus", status: "connected" });
broadcastFullState(); broadcastFullState();
@@ -229,6 +259,10 @@ async function connectStream(
return; return;
} }
// Ignore session-scoped traffic that isn't for the active session, so a
// late message from the previous session can't leak into the new one.
if (parts[1] !== state.sessionId) return;
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") { if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
return; return;
} }
@@ -268,6 +302,63 @@ async function connectStream(
}); });
} }
// ---------------------------------------------------------------------------
// Session lifecycle — join/leave without reconnecting the broker
// ---------------------------------------------------------------------------
async function joinSession(
sessionId: string,
name?: string,
role?: "gm" | "player" | "observer",
): Promise<void> {
const client = _mqttClient;
if (!client || !_mqttConnected) {
throw new Error("Not connected to broker");
}
if (sessionId === state.sessionId) {
if (name) state.myName = name;
if (role) state.myRole = role;
return;
}
// Leave the old session under the old identity before applying any change.
if (state.sessionId) unsubscribeSession(client, state.sessionId);
if (name) state.myName = name;
if (role) state.myRole = role;
// Reset session-scoped state before hydrating (a 404 leaves it empty).
state.sessionId = sessionId;
state.sessionName = sessionList.sessions[sessionId]?.name ?? null;
state.messages = [];
state.senderSeq = {};
state.players = {};
await hydrateFromServer(sessionId);
subscribeSession(client, sessionId);
notifySubscribers({ type: "sessionName", name: state.sessionName });
notifySubscribers({ type: "players", players: {} });
broadcastFullState();
}
function leaveSession(): void {
const client = _mqttClient;
if (client && _mqttConnected && state.sessionId) {
unsubscribeSession(client, state.sessionId);
}
state.sessionId = null;
state.sessionName = null;
state.messages = [];
state.senderSeq = {};
state.players = {};
notifySubscribers({ type: "sessionName", name: null });
notifySubscribers({ type: "players", players: {} });
broadcastFullState();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Receive // Receive
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -376,10 +467,9 @@ function revertLatest():
function disconnectStream(): void { function disconnectStream(): void {
if (_mqttClient) { if (_mqttClient) {
const sessionId = state.sessionId; if (state.sessionId) {
if (sessionId) {
_mqttClient.publish( _mqttClient.publish(
`ttrpg/${sessionId}/presence/${state.myName}`, `ttrpg/${state.sessionId}/presence/${state.myName}`,
"", "",
{ qos: 1, retain: true }, { qos: 1, retain: true },
); );
@@ -390,6 +480,10 @@ function disconnectStream(): void {
} }
state.connected = false; state.connected = false;
state.connectionStatus = "disconnected"; state.connectionStatus = "disconnected";
state.sessionId = null;
state.sessionName = null;
state.messages = [];
state.senderSeq = {};
state.players = {}; state.players = {};
notifySubscribers({ type: "connectionStatus", status: "disconnected" }); notifySubscribers({ type: "connectionStatus", status: "disconnected" });
notifySubscribers({ type: "players", players: {} }); notifySubscribers({ type: "players", players: {} });
@@ -399,8 +493,11 @@ function disconnectStream(): void {
// Session lifecycle // Session lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createSession(name: string, players: string[] = []): string | null { function createSession(
if (!_mqttClient || !_mqttConnected) return null; name: string,
players: string[] = [],
): Promise<string | null> {
if (!_mqttClient || !_mqttConnected) return Promise.resolve(null);
const id = const id =
name name
@@ -410,17 +507,33 @@ function createSession(name: string, players: string[] = []): string | null {
const meta: SessionMeta = { name, created: Date.now(), players }; const meta: SessionMeta = { name, created: Date.now(), players };
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), { return new Promise((resolve) => {
qos: 1, _mqttClient!.publish(
retain: true, `ttrpg/${id}/meta`,
JSON.stringify(meta),
{ qos: 1, retain: true },
(err) => {
if (err) {
console.error("[worker] create session publish error:", err);
resolve(null);
} else {
resolve(id);
}
},
);
}); });
return id;
} }
function deleteSession(sessionId: string): void { function deleteSession(sessionId: string): Promise<void> {
if (!_mqttClient || !_mqttConnected) return; if (!_mqttClient || !_mqttConnected) return Promise.resolve();
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true }); return new Promise((resolve) => {
_mqttClient!.publish(
`ttrpg/${sessionId}/meta`,
"",
{ qos: 1, retain: true },
() => resolve(),
);
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -445,13 +558,23 @@ function visibleMessages(): StreamMessage[] {
const api = { const api = {
async connect( async connect(
sessionId: string,
brokerUrl: string, brokerUrl: string,
name: string, name: string,
role: "gm" | "player" | "observer", role: "gm" | "player" | "observer",
): Promise<void> { ): Promise<void> {
await hydrateFromServer(sessionId); await connectBroker(brokerUrl, name, role);
await connectStream(sessionId, brokerUrl, name, role); },
async joinSession(
sessionId: string,
name?: string,
role?: "gm" | "player" | "observer",
): Promise<void> {
await joinSession(sessionId, name, role);
},
leaveSession(): void {
leaveSession();
}, },
disconnect(): void { disconnect(): void {
@@ -471,7 +594,7 @@ const api = {
}, },
deleteSession(sessionId: string) { deleteSession(sessionId: string) {
deleteSession(sessionId); return deleteSession(sessionId);
}, },
getState(): WorkerState { getState(): WorkerState {