feat: simplify journal connection via WebSocket upgrade
Refactor the journal server to run over WebSockets on the existing HTTP server instead of a separate MQTT TCP port. This removes the need for a separate port configuration and simplifies the connection flow by auto-connecting to the current host. - Remove `--mqtt-port` CLI option - Attach Aedes broker to the HTTP server via WebSocket upgrade - Update UI to remove manual broker URL and player name inputs - Add "Content Source" access from the sidebar - Update JournalPanel to auto-connect to the local server
This commit is contained in:
parent
53c33587fb
commit
862ba9c01b
|
|
@ -29,6 +29,7 @@
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"three-3mf-exporter": "^45.1.0",
|
"three-3mf-exporter": "^45.1.0",
|
||||||
|
"ws": "^8.21.0",
|
||||||
"yarn-spinner-loader": "^0.1.0",
|
"yarn-spinner-loader": "^0.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|
@ -46,6 +47,7 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.19.13",
|
"@types/node": "^22.19.13",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
|
@ -10693,9 +10695,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"three-3mf-exporter": "^45.1.0",
|
"three-3mf-exporter": "^45.1.0",
|
||||||
|
"ws": "^8.21.0",
|
||||||
"yarn-spinner-loader": "^0.1.0",
|
"yarn-spinner-loader": "^0.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|
@ -66,6 +67,7 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.19.13",
|
"@types/node": "^22.19.13",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
|
|
||||||
24
src/App.tsx
24
src/App.tsx
|
|
@ -57,13 +57,18 @@ const App: Component = () => {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
{/* 桌面端固定侧边栏 */}
|
{/* 桌面端固定侧边栏 */}
|
||||||
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
|
<DesktopSidebar
|
||||||
|
fileTree={fileTree()}
|
||||||
|
pathHeadings={pathHeadings()}
|
||||||
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
|
/>
|
||||||
{/* 移动端抽屉式侧边栏 */}
|
{/* 移动端抽屉式侧边栏 */}
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
isOpen={isSidebarOpen()}
|
isOpen={isSidebarOpen()}
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
/>
|
/>
|
||||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
|
|
@ -79,19 +84,14 @@ const App: Component = () => {
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsJournalOpen((v) => !v)}
|
onClick={() => setIsJournalOpen((v) => !v)}
|
||||||
class={`text-sm px-2 py-1 rounded hover:bg-gray-100 ${
|
class={`w-8 h-8 rounded flex items-center justify-center text-sm ${
|
||||||
isJournalOpen() ? "text-blue-600 font-medium" : "text-gray-500"
|
isJournalOpen()
|
||||||
|
? "bg-blue-100 text-blue-600"
|
||||||
|
: "text-gray-500 hover:bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
title="Journal Stream"
|
title="Journal"
|
||||||
>
|
>
|
||||||
📋 Journal
|
📋
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsDataSourceOpen(true)}
|
|
||||||
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
|
|
||||||
title="Content Source"
|
|
||||||
>
|
|
||||||
📂
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDocOpen(true)}
|
onClick={() => setIsDocOpen(true)}
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,6 @@ export function createContentServer(
|
||||||
port: number,
|
port: number,
|
||||||
distPath: string = distDir,
|
distPath: string = distDir,
|
||||||
host: string = "0.0.0.0",
|
host: string = "0.0.0.0",
|
||||||
mqttPort: number = 1883,
|
|
||||||
): ContentServer {
|
): ContentServer {
|
||||||
let contentIndex: ContentIndex = {};
|
let contentIndex: ContentIndex = {};
|
||||||
|
|
||||||
|
|
@ -275,28 +274,24 @@ export function createContentServer(
|
||||||
// ---- Journal / MQTT broker ----
|
// ---- Journal / MQTT broker ----
|
||||||
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
||||||
|
|
||||||
createJournalServer(contentDir, mqttPort).then((j) => {
|
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
|
||||||
journal = j;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建请求处理器
|
|
||||||
const handleRequest = createRequestHandler(
|
const handleRequest = createRequestHandler(
|
||||||
contentDir,
|
contentDir,
|
||||||
distPath,
|
distPath,
|
||||||
() => contentIndex,
|
() => contentIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 创建 HTTP 服务器
|
|
||||||
const server = createServer(handleRequest);
|
const server = createServer(handleRequest);
|
||||||
|
|
||||||
|
createJournalServer(contentDir, server).then((j) => {
|
||||||
|
journal = j;
|
||||||
|
});
|
||||||
|
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
||||||
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
||||||
console.log(`内容目录:${contentDir}`);
|
console.log(`内容目录:${contentDir}`);
|
||||||
console.log(`静态资源目录:${distPath}`);
|
console.log(`静态资源目录:${distPath}`);
|
||||||
console.log(
|
console.log(`Journal 连接:ws://${displayHost}:${port}\n`);
|
||||||
`MQTT 代理端口:${mqttPort} (connect via tcp://${displayHost}:${mqttPort})\n`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -319,7 +314,6 @@ export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
||||||
const contentDir = resolve(dir);
|
const contentDir = resolve(dir);
|
||||||
const port = parseInt(options.port, 10);
|
const port = parseInt(options.port, 10);
|
||||||
const host = options.host || "0.0.0.0";
|
const host = options.host || "0.0.0.0";
|
||||||
const mqttPort = parseInt(options.mqttPort || "1883", 10);
|
|
||||||
|
|
||||||
createContentServer(contentDir, port, distDir, host, mqttPort);
|
createContentServer(contentDir, port, distDir, host);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ program
|
||||||
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
|
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
|
||||||
.argument("[dir]", "要预览的目录", ".")
|
.argument("[dir]", "要预览的目录", ".")
|
||||||
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
||||||
.option("--mqtt-port <port>", "MQTT 代理端口号", "1883")
|
|
||||||
.option("-h, --host <host>", "主机地址", "0.0.0.0")
|
.option("-h, --host <host>", "主机地址", "0.0.0.0")
|
||||||
.action(serveCommand);
|
.action(serveCommand);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Journal stream persistence — MQTT broker + JSONL append + session manifest
|
* Journal stream persistence — MQTT broker + JSONL append + session manifest
|
||||||
*
|
*
|
||||||
* Starts an aedes MQTT broker and connects to itself to:
|
* Runs an aedes MQTT broker over WebSocket, attached to the existing
|
||||||
* 1. Persist all stream messages to .ttrpg/sessions/{id}/stream.jsonl
|
* HTTP server via the upgrade event. No separate TCP port.
|
||||||
* 2. Manage session lifecycle via retained meta topics
|
|
||||||
*
|
*
|
||||||
* No HTTP routes — the JSONL files are served by the existing static server.
|
* Persistence uses aedes's internal subscribe/publish API, so the
|
||||||
|
* server process doesn't need to connect to itself as an MQTT client.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
unlinkSync,
|
unlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
|
import type { Server as HttpServer } from "http";
|
||||||
|
import type { AedesPublishPacket } from "aedes";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -34,13 +36,8 @@ interface SessionManifest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JournalServer {
|
export interface JournalServer {
|
||||||
/** MQTT broker instance */
|
|
||||||
broker: import("aedes").Aedes;
|
broker: import("aedes").Aedes;
|
||||||
/** TCP server the broker is attached to */
|
wsServer: import("ws").WebSocketServer;
|
||||||
tcpServer: import("net").Server;
|
|
||||||
/** Persistence client (server subscribes to itself) */
|
|
||||||
persistenceClient: import("mqtt").MqttClient | null;
|
|
||||||
/** Clean shutdown */
|
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,10 +63,6 @@ function streamPath(dataRoot: string, id: string): string {
|
||||||
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Manifest
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function loadManifest(dataRoot: string): SessionManifest {
|
function loadManifest(dataRoot: string): SessionManifest {
|
||||||
const p = manifestPath(dataRoot);
|
const p = manifestPath(dataRoot);
|
||||||
if (!existsSync(p)) return { sessions: {} };
|
if (!existsSync(p)) return { sessions: {} };
|
||||||
|
|
@ -84,10 +77,6 @@ function saveManifest(dataRoot: string, m: SessionManifest): void {
|
||||||
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
|
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// JSONL persistence
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function appendStream(dataRoot: string, id: string, line: string): void {
|
function appendStream(dataRoot: string, id: string, line: string): void {
|
||||||
const sd = sessionDir(dataRoot, id);
|
const sd = sessionDir(dataRoot, id);
|
||||||
mkdirSync(sd, { recursive: true });
|
mkdirSync(sd, { recursive: true });
|
||||||
|
|
@ -98,114 +87,109 @@ function appendStream(dataRoot: string, id: string, line: string): void {
|
||||||
// Server factory
|
// Server factory
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||||
|
|
||||||
export async function createJournalServer(
|
export async function createJournalServer(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
mqttPort: number,
|
httpServer: HttpServer,
|
||||||
): Promise<JournalServer> {
|
): Promise<JournalServer> {
|
||||||
const root = dataDir(contentDir);
|
const root = dataDir(contentDir);
|
||||||
console.log(`[journal] data dir: ${root}`);
|
console.log(`[journal] data dir: ${root}`);
|
||||||
|
|
||||||
// ---- MQTT Broker ----
|
// ---- MQTT Broker ----
|
||||||
const { Aedes: AedesFactory } = await import("aedes");
|
const { Aedes: AedesFactory } = await import("aedes");
|
||||||
const { createServer } = await import("net");
|
|
||||||
|
|
||||||
const broker = new AedesFactory();
|
const broker = new AedesFactory();
|
||||||
const tcpServer = createServer(broker.handle.bind(broker));
|
|
||||||
|
|
||||||
tcpServer.listen(mqttPort, "0.0.0.0", () => {
|
// ---- WebSocket server (attached to HTTP server) ----
|
||||||
console.log(`[journal] MQTT broker on port ${mqttPort}`);
|
const { WebSocketServer } = await import("ws");
|
||||||
});
|
const wsServer = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
// ---- Persistence + Manifest client ----
|
httpServer.on("upgrade", (req, socket, head) => {
|
||||||
let client: import("mqtt").MqttClient | null = null;
|
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
broker.handle(ws as unknown as import("net").Socket);
|
||||||
const { default: mqtt } = await import("mqtt");
|
|
||||||
client = mqtt.connect(`mqtt://127.0.0.1:${mqttPort}`, {
|
|
||||||
clientId: "journal-server",
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("connect", () => {
|
|
||||||
// 1. Persistence: append every stream message to JSONL
|
|
||||||
client!.subscribe("ttrpg/+/stream", { qos: 1 }, (err) => {
|
|
||||||
if (err) console.error("[journal] stream sub err:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Session meta: manage manifest from retained meta topics
|
|
||||||
client!.subscribe("ttrpg/+/meta", { qos: 1 }, (err) => {
|
|
||||||
if (err) console.error("[journal] meta sub err:", err);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("message", (topic, payload) => {
|
// ---- Persistence (internal subscribe, no separate MQTT client) ----
|
||||||
const parts = topic.split("/");
|
broker.subscribe(
|
||||||
if (parts.length < 3) return;
|
"ttrpg/+/stream",
|
||||||
const sessionId = parts[1];
|
(packet, cb) => {
|
||||||
const subtopic = parts[2];
|
const sessionId = extractSessionId(packet.topic);
|
||||||
|
if (sessionId) {
|
||||||
if (subtopic === "stream") {
|
try {
|
||||||
// Persist every stream message
|
appendStream(root, sessionId, packet.payload.toString());
|
||||||
try {
|
} catch (e) {
|
||||||
appendStream(root, sessionId, payload.toString());
|
console.error(`[journal] stream append err for ${sessionId}:`, e);
|
||||||
} catch (e) {
|
}
|
||||||
console.error(`[journal] stream append err for ${sessionId}:`, e);
|
|
||||||
}
|
}
|
||||||
} else if (subtopic === "meta") {
|
cb();
|
||||||
// Session lifecycle
|
},
|
||||||
handleMetaChange(root, sessionId, client!, payload.toString());
|
() => {},
|
||||||
}
|
);
|
||||||
});
|
|
||||||
|
broker.subscribe(
|
||||||
|
"ttrpg/+/meta",
|
||||||
|
(packet, cb) => {
|
||||||
|
const sessionId = extractSessionId(packet.topic);
|
||||||
|
if (sessionId) {
|
||||||
|
handleMetaChange(root, sessionId, broker, packet.payload.toString());
|
||||||
|
}
|
||||||
|
cb();
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
console.log("[journal] persistence listener active");
|
console.log("[journal] persistence listener active");
|
||||||
|
console.log("[journal] broker available at ws://<host>:<port>");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
broker,
|
broker,
|
||||||
tcpServer,
|
wsServer,
|
||||||
persistenceClient: client,
|
|
||||||
async close() {
|
async close() {
|
||||||
console.log("[journal] shutting down...");
|
console.log("[journal] shutting down...");
|
||||||
client?.end(true);
|
await new Promise<void>((r) => wsServer.close(() => r()));
|
||||||
await new Promise<void>((r) => broker.close(() => r()));
|
await new Promise<void>((r) => broker.close(() => r()));
|
||||||
await new Promise<void>((r) => tcpServer.close(() => r()));
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function extractSessionId(topic: string): string | null {
|
||||||
|
const parts = topic.split("/");
|
||||||
|
return parts.length >= 3 ? parts[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
|
||||||
|
|
||||||
function handleMetaChange(
|
function handleMetaChange(
|
||||||
root: string,
|
root: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
client: import("mqtt").MqttClient,
|
broker: import("aedes").Aedes,
|
||||||
rawPayload: string,
|
rawPayload: string,
|
||||||
): void {
|
): void {
|
||||||
const manifest = loadManifest(root);
|
const manifest = loadManifest(root);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(rawPayload) as SessionMeta | null;
|
const meta = JSON.parse(rawPayload) as SessionMeta | null;
|
||||||
|
|
||||||
if (!meta || !meta.name) {
|
if (!meta || !meta.name) {
|
||||||
// Tombstone: empty or invalid payload → delete session
|
|
||||||
if (manifest.sessions[sessionId]) {
|
if (manifest.sessions[sessionId]) {
|
||||||
delete manifest.sessions[sessionId];
|
delete manifest.sessions[sessionId];
|
||||||
|
|
||||||
// Wipe files
|
|
||||||
const sp = streamPath(root, sessionId);
|
const sp = streamPath(root, sessionId);
|
||||||
if (existsSync(sp)) unlinkSync(sp);
|
if (existsSync(sp)) unlinkSync(sp);
|
||||||
const sd = sessionDir(root, sessionId);
|
const sd = sessionDir(root, sessionId);
|
||||||
try {
|
try {
|
||||||
rmdirSync(sd);
|
rmdirSync(sd);
|
||||||
} catch {
|
} catch {
|
||||||
/* not empty, fine */
|
/* */
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[journal] session deleted: ${sessionId}`);
|
console.log(`[journal] session deleted: ${sessionId}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create or update
|
|
||||||
manifest.sessions[sessionId] = {
|
manifest.sessions[sessionId] = {
|
||||||
name: meta.name,
|
name: meta.name,
|
||||||
created: meta.created || Date.now(),
|
created: meta.created || Date.now(),
|
||||||
|
|
@ -215,12 +199,17 @@ function handleMetaChange(
|
||||||
}
|
}
|
||||||
|
|
||||||
saveManifest(root, manifest);
|
saveManifest(root, manifest);
|
||||||
|
broker.publish(
|
||||||
// Republish full manifest as a retained message
|
{
|
||||||
client.publish($SESSIONS, JSON.stringify(manifest), {
|
topic: $SESSIONS,
|
||||||
qos: 1,
|
payload: Buffer.from(JSON.stringify(manifest)),
|
||||||
retain: true,
|
qos: 1,
|
||||||
});
|
retain: true,
|
||||||
|
cmd: "publish" as const,
|
||||||
|
dup: false,
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[journal] meta parse err for ${sessionId}:`, e);
|
console.error(`[journal] meta parse err for ${sessionId}:`, e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export interface ServeOptions {
|
export interface ServeOptions {
|
||||||
port: string;
|
port: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
mqttPort?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompileOptions {
|
export interface CompileOptions {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface SidebarProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
|
onDataSourceOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarContentProps {
|
interface SidebarContentProps {
|
||||||
|
|
@ -16,6 +17,7 @@ interface SidebarContentProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDesktop?: boolean;
|
isDesktop?: boolean;
|
||||||
|
onDataSourceOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,15 +39,24 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
<div class="p-4 border-b border-b-gray-200">
|
<div class="p-4 border-b border-b-gray-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
||||||
<Show when={!props.isDesktop}>
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onDataSourceOpen}
|
||||||
class="text-gray-500 hover:text-gray-700"
|
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
||||||
title="关闭"
|
title="Content Source"
|
||||||
>
|
>
|
||||||
✕
|
📂
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
<Show when={!props.isDesktop}>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-gray-500 hover:text-gray-700"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -121,6 +132,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|
@ -133,6 +145,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
export const DesktopSidebar: Component<{
|
export const DesktopSidebar: Component<{
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
|
onDataSourceOpen?: () => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||||
|
|
@ -158,6 +171,7 @@ export const DesktopSidebar: Component<{
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
isDesktop
|
isDesktop
|
||||||
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,26 @@
|
||||||
/**
|
/**
|
||||||
* ConnectionBar — connect form, session picker, player name
|
* ConnectionBar — session picker, player info (connected state only)
|
||||||
*
|
|
||||||
* Three states: disconnected → connecting → connected
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, createSignal, Show, For, onMount } from "solid-js";
|
import { Component, createSignal, Show, For } from "solid-js";
|
||||||
import {
|
import {
|
||||||
connectStream,
|
|
||||||
hydrateFromServer,
|
hydrateFromServer,
|
||||||
useJournalStream,
|
useJournalStream,
|
||||||
sessions,
|
sessions,
|
||||||
setMyName,
|
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
disconnectStream,
|
||||||
} from "../stores/journalStream";
|
} from "../stores/journalStream";
|
||||||
import type { SessionMeta } from "../stores/journalStream";
|
|
||||||
import { createMemo } from "solid-js";
|
|
||||||
|
|
||||||
// Direct access to the store's setter for session switching
|
|
||||||
import { journalSetState } from "../stores/journalStream";
|
import { journalSetState } from "../stores/journalStream";
|
||||||
|
|
||||||
export const ConnectionBar: Component = () => {
|
export const ConnectionBar: Component = () => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [brokerUrl, setBrokerUrl] = createSignal("");
|
|
||||||
const [newSessionName, setNewSessionName] = createSignal("");
|
const [newSessionName, setNewSessionName] = createSignal("");
|
||||||
const [playerName, setPlayerName] = createSignal("");
|
|
||||||
const [connecting, setConnecting] = createSignal(false);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
const [showNewSession, setShowNewSession] = createSignal(false);
|
const [showNewSession, setShowNewSession] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
onMount(() => {
|
|
||||||
setBrokerUrl(stream.brokerUrl ?? "");
|
|
||||||
setPlayerName(stream.myName);
|
|
||||||
});
|
|
||||||
|
|
||||||
const manifest = sessions;
|
const manifest = sessions;
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
const url = brokerUrl().trim();
|
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
setConnecting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Save name first
|
|
||||||
if (playerName().trim()) {
|
|
||||||
setMyName(playerName().trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect
|
|
||||||
const sessionId = stream.sessionId || "default";
|
|
||||||
await hydrateFromServer(sessionId);
|
|
||||||
await connectStream(sessionId, url);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Connection failed");
|
|
||||||
} finally {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
const handleCreateSession = () => {
|
||||||
const name = newSessionName().trim();
|
const name = newSessionName().trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
@ -75,7 +36,6 @@ export const ConnectionBar: Component = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSessionSelect = async (id: string) => {
|
const handleSessionSelect = async (id: string) => {
|
||||||
if (!stream.connected) return;
|
|
||||||
try {
|
try {
|
||||||
await hydrateFromServer(id);
|
await hydrateFromServer(id);
|
||||||
journalSetState("sessionId", id);
|
journalSetState("sessionId", id);
|
||||||
|
|
@ -84,74 +44,36 @@ export const ConnectionBar: Component = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColor = () =>
|
const handleDisconnect = () => {
|
||||||
stream.connected
|
disconnectStream();
|
||||||
? "bg-green-500"
|
};
|
||||||
: connecting()
|
|
||||||
? "bg-yellow-400"
|
|
||||||
: "bg-gray-400";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="border-b border-gray-200 p-3 space-y-2 text-sm">
|
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
|
||||||
{/* Status + session name */}
|
{/* Status row */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class={`w-2 h-2 rounded-full ${statusColor()}`} />
|
<span class="w-2 h-2 rounded-full bg-green-500" />
|
||||||
<span class="text-xs text-gray-500">
|
<span class="text-xs text-gray-500">connected</span>
|
||||||
{stream.connected
|
|
||||||
? "connected"
|
|
||||||
: connecting()
|
|
||||||
? "connecting..."
|
|
||||||
: "disconnected"}
|
|
||||||
</span>
|
|
||||||
<Show when={stream.sessionId}>
|
<Show when={stream.sessionId}>
|
||||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
||||||
{stream.sessionId}
|
{stream.sessionId}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<Show when={stream.connected}>
|
<Show when={stream.myName !== "gm"}>
|
||||||
<button
|
<span class="text-xs text-gray-400">Playing as {stream.myName}</span>
|
||||||
onClick={() => {} /* disconnect */}
|
|
||||||
class="text-xs text-gray-400 hover:text-red-500"
|
|
||||||
title="Disconnect"
|
|
||||||
>
|
|
||||||
⏻
|
|
||||||
</button>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
class="text-xs text-gray-400 hover:text-red-500"
|
||||||
|
title="Disconnect"
|
||||||
|
>
|
||||||
|
⏻
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disconnected: connect form */}
|
{/* GM session management */}
|
||||||
<Show when={!stream.connected}>
|
<Show when={stream.myName === "gm"}>
|
||||||
<div class="space-y-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={brokerUrl()}
|
|
||||||
onInput={(e) => setBrokerUrl(e.currentTarget.value)}
|
|
||||||
placeholder="MQTT broker (e.g. tcp://192.168.1.5:1883)"
|
|
||||||
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={playerName()}
|
|
||||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
|
||||||
placeholder="Your name (GM or player)"
|
|
||||||
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={connecting() || !brokerUrl().trim()}
|
|
||||||
class="w-full bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{connecting() ? "Connecting..." : "Connect"}
|
|
||||||
</button>
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-xs">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Connected: session management (GM only) */}
|
|
||||||
<Show when={stream.connected && stream.myName === "gm"}>
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500">Sessions:</span>
|
<span class="text-xs text-gray-500">Sessions:</span>
|
||||||
|
|
@ -213,12 +135,8 @@ export const ConnectionBar: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Connected: player info (player only) */}
|
<Show when={error()}>
|
||||||
<Show when={stream.connected && stream.myName !== "gm"}>
|
<p class="text-red-500 text-xs">{error()}</p>
|
||||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
|
||||||
<span>Playing as:</span>
|
|
||||||
<span class="font-medium text-gray-700">{stream.myName}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* JournalPanel — right panel container for the journal stream
|
* JournalPanel — right panel container for the journal stream
|
||||||
*
|
*
|
||||||
* Collapsible, overlays on mobile, pushes content on desktop.
|
* Fixed overlay. Shows stream when connected, connect dialog when not.
|
||||||
|
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Show, createSignal } from "solid-js";
|
import { Component, Show, createSignal, onMount } from "solid-js";
|
||||||
import { useJournalStream } from "../stores/journalStream";
|
import {
|
||||||
|
connectStream,
|
||||||
|
hydrateFromServer,
|
||||||
|
useJournalStream,
|
||||||
|
setMyName,
|
||||||
|
} from "../stores/journalStream";
|
||||||
import { ConnectionBar } from "./ConnectionBar";
|
import { ConnectionBar } from "./ConnectionBar";
|
||||||
import { StreamView } from "./StreamView";
|
import { StreamView } from "./StreamView";
|
||||||
import { ComposePanel } from "./ComposePanel";
|
import { ComposePanel } from "./ComposePanel";
|
||||||
|
|
@ -20,22 +26,25 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
{/* Mobile overlay */}
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/30 z-40 md:hidden"
|
class="fixed inset-0 bg-black/30 z-40 md:bg-transparent"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
class={`
|
class="fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
|
||||||
fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
|
flex flex-col w-full max-w-md md:w-[420px] shadow-lg"
|
||||||
flex flex-col
|
|
||||||
w-full max-w-md md:w-[420px]
|
|
||||||
md:relative md:top-0 md:z-0
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||||
<h2 class="text-sm font-semibold text-gray-700">Journal</h2>
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700">Journal</h2>
|
||||||
|
<Show when={stream.connected}>
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full bg-green-500"
|
||||||
|
title="Connected"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
|
@ -44,18 +53,92 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConnectionBar />
|
<Show
|
||||||
|
when={stream.connected}
|
||||||
{/* Stream */}
|
fallback={
|
||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 flex items-center justify-center p-4">
|
||||||
<StreamView />
|
<ConnectDialog />
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
{/* Compose */}
|
>
|
||||||
<Show when={stream.connected}>
|
<ConnectionBar />
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<StreamView />
|
||||||
|
</div>
|
||||||
<ComposePanel />
|
<ComposePanel />
|
||||||
</Show>
|
</Show>
|
||||||
</aside>
|
</aside>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connect dialog — one input: player name. Broker URL is always current host.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConnectDialog: Component = () => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const [playerName, setPlayerName] = createSignal(stream.myName);
|
||||||
|
const [connecting, setConnecting] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
// Auto-connect if we have a name from localStorage
|
||||||
|
onMount(() => {
|
||||||
|
if (stream.myName && stream.myName !== "gm") {
|
||||||
|
handleConnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
const name = playerName().trim() || stream.myName;
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const brokerUrl = `ws://${window.location.host}`;
|
||||||
|
|
||||||
|
setConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setMyName(name);
|
||||||
|
const sessionId = stream.sessionId || "default";
|
||||||
|
await hydrateFromServer(sessionId);
|
||||||
|
await connectStream(sessionId, brokerUrl);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="w-full max-w-sm space-y-3">
|
||||||
|
<p class="text-sm text-gray-600 text-center">
|
||||||
|
Enter your name to join the session.
|
||||||
|
<br />
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
Connecting to {window.location.host}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playerName()}
|
||||||
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
||||||
|
placeholder="Your name (GM or player)"
|
||||||
|
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connecting() || !playerName().trim()}
|
||||||
|
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
|
||||||
|
hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{connecting() ? "Connecting..." : "Join"}
|
||||||
|
</button>
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue