From f1f40a18adac298f5ad0b0a47207c87e316aa84d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 16:08:33 +0000 Subject: [PATCH 1/5] Add RxDB Viewer plugin for remote database inspection via WebRTC Adds a new viewer plugin that allows developers to connect to any running RxDB database remotely via WebRTC and inspect its data in a browser-based UI. Server-side plugin (src/plugins/viewer/): - startRxDBViewer(database, options) sets up WebRTC listener on signaling server - getDatabaseConnectionParams(database) returns JSON connection params - Uses same signaling server protocol as the replication-webrtc plugin - Handles viewer requests: getDbInfo, query, count, observeQuery, exportCollection, writeDocument, deleteDocument, getCollectionInfo, unobserveQuery Single-file HTML viewer (viewer.html): - Self-contained React app with all CSS inline - Connects via WebRTC using simple-peer (loaded from CDN) - Dark theme matching RxDB website style (colors, gradients, typography) - Features: collection sidebar with doc counts, document table with sortable columns, Mango query bar, document detail panel with JSON syntax highlighting, live query observer, document CRUD (add/edit/delete), collection JSON export, schema viewer, connection status indicator https://claude.ai/code/session_01E4LPySRrjXCCRdV9pVKJom --- package.json | 6 + src/plugins/dev-mode/error-messages.ts | 8 + src/plugins/viewer/index.ts | 19 + src/plugins/viewer/viewer-server.ts | 348 +++++ src/plugins/viewer/viewer-types.ts | 71 ++ src/plugins/viewer/viewer.html | 1610 ++++++++++++++++++++++++ 6 files changed, 2062 insertions(+) create mode 100644 src/plugins/viewer/index.ts create mode 100644 src/plugins/viewer/viewer-server.ts create mode 100644 src/plugins/viewer/viewer-types.ts create mode 100644 src/plugins/viewer/viewer.html diff --git a/package.json b/package.json index 370cf71a48d..d4188856836 100644 --- a/package.json +++ b/package.json @@ -294,6 +294,12 @@ "import": "./dist/esm/plugins/webmcp/index.js", "default": "./dist/esm/plugins/webmcp/index.js" }, + "./plugins/viewer": { + "types": "./dist/types/plugins/viewer/index.d.ts", + "require": "./dist/cjs/plugins/viewer/index.js", + "import": "./dist/esm/plugins/viewer/index.js", + "default": "./dist/esm/plugins/viewer/index.js" + }, "./plugins/attachments-compression": { "types": "./dist/types/plugins/attachments-compression/index.d.ts", "require": "./dist/cjs/plugins/attachments-compression/index.js", diff --git a/src/plugins/dev-mode/error-messages.ts b/src/plugins/dev-mode/error-messages.ts index 3b625a5fcb6..26a7b7d8885 100644 --- a/src/plugins/dev-mode/error-messages.ts +++ b/src/plugins/dev-mode/error-messages.ts @@ -1355,6 +1355,14 @@ export const ERROR_MESSAGES = { * null checks etc. so you do not have to increase the * build size with error message strings. */ + // plugins/viewer + VW1: { + message: 'getDatabaseConnectionParams() called but startRxDBViewer() was not called on this database before.', + cause: 'The viewer server was not started on the database before calling getDatabaseConnectionParams().', + fix: 'Call startRxDBViewer(database) before calling getDatabaseConnectionParams(database).', + docs: '' + }, + SNH: { message: 'This should never happen', cause: 'Should never be thrown. This error code is used for internal things like null-checks etc.', diff --git a/src/plugins/viewer/index.ts b/src/plugins/viewer/index.ts new file mode 100644 index 00000000000..8385f90aa3e --- /dev/null +++ b/src/plugins/viewer/index.ts @@ -0,0 +1,19 @@ +export { + startRxDBViewer, + getDatabaseConnectionParams, + VIEWER_DEFAULT_SIGNALING_SERVER +} from './viewer-server.ts'; + +export type { + ViewerConnectionParams, + ViewerServerOptions, + ViewerState, + ViewerMethod, + ViewerRequest, + ViewerResponse, + ViewerPushMessage, + ViewerDbInfo, + ViewerCollectionInfo, + ViewerSignalingMessage, + ViewerPeerState +} from './viewer-types.ts'; diff --git a/src/plugins/viewer/viewer-server.ts b/src/plugins/viewer/viewer-server.ts new file mode 100644 index 00000000000..3f75629e919 --- /dev/null +++ b/src/plugins/viewer/viewer-server.ts @@ -0,0 +1,348 @@ +import type { Subscription } from 'rxjs'; +import type { RxCollection, RxDatabase } from '../../types/index.d.ts'; +import { + ensureNotFalsy, + randomToken +} from '../../plugins/utils/index.ts'; +import { newRxError } from '../../rx-error.ts'; +import type { + ViewerConnectionParams, + ViewerRequest, + ViewerResponse, + ViewerServerOptions, + ViewerSignalingMessage, + ViewerState +} from './viewer-types.ts'; + +import type { + SimplePeer as PeerConstructor, + Instance as SimplePeerInstance, +} from 'simple-peer'; +import { + default as _Peer + // @ts-ignore +} from 'simple-peer/simplepeer.min.js'; + +const Peer = _Peer as PeerConstructor; + +type ViewerPeer = SimplePeerInstance & { id: string; }; + +export const VIEWER_DEFAULT_SIGNALING_SERVER = 'wss://signaling.rxdb.info/'; +const VIEWER_PING_INTERVAL = 1000 * 60 * 2; + +const VIEWER_STATE_BY_DATABASE = new WeakMap(); + +function sendSocketMessage(ws: WebSocket, msg: ViewerSignalingMessage) { + ws.send(JSON.stringify(msg)); +} + +function sendToPeer(peer: ViewerPeer, msg: ViewerResponse | { type: string; observeId: string; data: any }) { + try { + peer.send(JSON.stringify(msg)); + } catch (_e) { + // peer might be disconnected + } +} + +async function handleViewerRequest( + database: RxDatabase, + peer: ViewerPeer, + request: ViewerRequest, + peerSubscriptions: Map> +): Promise { + try { + switch (request.method) { + case 'getDbInfo': { + const collections = []; + for (const [name, col] of Object.entries(database.collections)) { + const rxCol = col as RxCollection; + const docCount = await rxCol.count().exec(); + collections.push({ + name, + schema: rxCol.schema.jsonSchema, + docCount + }); + } + return { id: request.id, result: { databaseName: database.name, collections } }; + } + + case 'getCollectionInfo': { + const colName = request.params.collection; + const col = (database.collections as any)[colName] as RxCollection; + if (!col) { + return { id: request.id, error: 'Collection not found: ' + colName }; + } + const schema = col.schema.jsonSchema; + const docCount = await col.count().exec(); + const primaryKey = typeof schema.primaryKey === 'string' + ? schema.primaryKey + : (schema.primaryKey as any)?.key; + return { + id: request.id, + result: { name: colName, schema, docCount, primaryKey } + }; + } + + case 'query': { + const { collection, query } = request.params; + const col = (database.collections as any)[collection] as RxCollection; + if (!col) { + return { id: request.id, error: 'Collection not found: ' + collection }; + } + const docs = await col.find(query || {}).exec(); + return { id: request.id, result: docs.map((d: any) => d.toJSON(true)) }; + } + + case 'count': { + const { collection, selector } = request.params; + const col = (database.collections as any)[collection] as RxCollection; + if (!col) { + return { id: request.id, error: 'Collection not found: ' + collection }; + } + const count = await col.count(selector ? { selector } : undefined).exec(); + return { id: request.id, result: count }; + } + + case 'exportCollection': { + const { collection } = request.params; + const col = (database.collections as any)[collection] as RxCollection; + if (!col) { + return { id: request.id, error: 'Collection not found: ' + collection }; + } + const docs = await col.find().exec(); + return { id: request.id, result: docs.map((d: any) => d.toJSON(true)) }; + } + + case 'observeQuery': { + const { observeId, collection, query } = request.params; + const col = (database.collections as any)[collection] as RxCollection; + if (!col) { + return { id: request.id, error: 'Collection not found: ' + collection }; + } + + if (!peerSubscriptions.has(peer.id)) { + peerSubscriptions.set(peer.id, new Map()); + } + const peerSubs = ensureNotFalsy(peerSubscriptions.get(peer.id)); + + // Clean up existing subscription with same id + const existingSub = peerSubs.get(observeId); + if (existingSub) { + existingSub.unsubscribe(); + } + + const sub = col.find(query || {}).$.subscribe((docs: any[]) => { + sendToPeer(peer, { + type: 'observeResult', + observeId, + data: docs.map((d: any) => d.toJSON(true)) + }); + }); + peerSubs.set(observeId, sub); + return { id: request.id, result: { observeId, started: true } }; + } + + case 'unobserveQuery': { + const { observeId } = request.params; + const peerSubs = peerSubscriptions.get(peer.id); + if (peerSubs) { + const sub = peerSubs.get(observeId); + if (sub) { + sub.unsubscribe(); + peerSubs.delete(observeId); + } + } + return { id: request.id, result: { observeId, stopped: true } }; + } + + case 'writeDocument': { + const { collection, document } = request.params; + const col = (database.collections as any)[collection] as RxCollection; + if (!col) { + return { id: request.id, error: 'Collection not found: ' + collection }; + } + const result = await col.upsert(document); + return { id: request.id, result: result.toJSON(true) }; + } + + case 'deleteDocument': { + const { collection, primaryKey } = request.params; + const col = (database.collections as any)[collection] as RxCollection; + if (!col) { + return { id: request.id, error: 'Collection not found: ' + collection }; + } + const doc = await col.findOne(primaryKey).exec(); + if (doc) { + await doc.remove(); + return { id: request.id, result: { deleted: true } }; + } + return { id: request.id, error: 'Document not found' }; + } + + default: + return { id: request.id, error: 'Unknown method: ' + (request as any).method }; + } + } catch (err: any) { + return { id: request.id, error: err.message || String(err) }; + } +} + +export async function startRxDBViewer( + database: RxDatabase, + options: ViewerServerOptions = {} +): Promise { + const topic = options.topic || 'rxdb-viewer-' + randomToken(12); + const signalingServerUrl = options.signalingServerUrl || VIEWER_DEFAULT_SIGNALING_SERVER; + const WebSocketConstructor = options.webSocketConstructor || WebSocket; + + const peerSubscriptions = new Map>(); + const peers = new Map(); + let closed = false; + let ownPeerId = ''; + let socket: WebSocket | undefined; + + function createSocket() { + if (closed) { + return; + } + socket = new WebSocketConstructor(signalingServerUrl); + socket.onclose = () => createSocket(); + socket.onopen = () => { + ensureNotFalsy(socket).onmessage = (msgEvent: any) => { + const msg: ViewerSignalingMessage = JSON.parse(msgEvent.data as any); + switch (msg.type) { + case 'init': + ownPeerId = msg.yourPeerId; + sendSocketMessage(ensureNotFalsy(socket), { + type: 'join', + room: topic + }); + break; + case 'joined': + msg.otherPeerIds.forEach(remotePeerId => { + if (remotePeerId === ownPeerId || peers.has(remotePeerId)) { + return; + } + createPeerConnection(remotePeerId); + }); + break; + case 'signal': { + const peer = peers.get(msg.senderPeerId); + if (peer) { + peer.signal(msg.data); + } + break; + } + } + }; + }; + } + + function createPeerConnection(remotePeerId: string) { + let disconnected = false; + const newPeer: ViewerPeer = new (Peer as any)({ + initiator: remotePeerId > ownPeerId, + trickle: true + }); + newPeer.id = randomToken(10); + peers.set(remotePeerId, newPeer); + + newPeer.on('signal', (signal: any) => { + sendSocketMessage(ensureNotFalsy(socket), { + type: 'signal', + senderPeerId: ownPeerId, + receiverPeerId: remotePeerId, + room: topic, + data: signal + }); + }); + + newPeer.on('data', async (data: any) => { + const request: ViewerRequest = JSON.parse(data.toString()); + const response = await handleViewerRequest(database, newPeer, request, peerSubscriptions); + sendToPeer(newPeer, response); + }); + + newPeer.on('error', () => { + if (!disconnected) { + disconnected = true; + cleanupPeer(remotePeerId, newPeer); + } + }); + + newPeer.on('connect', () => { + // viewer peer connected + }); + + newPeer.on('close', () => { + if (!disconnected) { + disconnected = true; + cleanupPeer(remotePeerId, newPeer); + } + createPeerConnection(remotePeerId); + }); + } + + function cleanupPeer(remotePeerId: string, peer: ViewerPeer) { + const peerSubs = peerSubscriptions.get(peer.id); + if (peerSubs) { + peerSubs.forEach(sub => sub.unsubscribe()); + peerSubscriptions.delete(peer.id); + } + peers.delete(remotePeerId); + } + + createSocket(); + + // Send ping to keep the signaling connection alive + const pingInterval = setInterval(() => { + if (closed) { + clearInterval(pingInterval); + return; + } + if (socket && socket.readyState === WebSocket.OPEN) { + sendSocketMessage(socket, { type: 'ping' }); + } + }, VIEWER_PING_INTERVAL); + + const connectionParams: ViewerConnectionParams = { + topic, + signalingServerUrl, + databaseName: database.name + }; + + const state: ViewerState = { + connectionParams, + async close() { + closed = true; + clearInterval(pingInterval); + peerSubscriptions.forEach(peerSubs => { + peerSubs.forEach(sub => sub.unsubscribe()); + }); + peerSubscriptions.clear(); + peers.forEach(peer => { + try { peer.destroy(); } catch (_e) { /* */ } + }); + peers.clear(); + if (socket) { + try { socket.close(); } catch (_e) { /* */ } + } + VIEWER_STATE_BY_DATABASE.delete(database); + } + }; + + VIEWER_STATE_BY_DATABASE.set(database, state); + return state; +} + +export function getDatabaseConnectionParams( + database: RxDatabase +): ViewerConnectionParams { + const state = VIEWER_STATE_BY_DATABASE.get(database); + if (!state) { + throw newRxError('VW1', { + database: database.name + }); + } + return state.connectionParams; +} diff --git a/src/plugins/viewer/viewer-types.ts b/src/plugins/viewer/viewer-types.ts new file mode 100644 index 00000000000..1b5bdcf2742 --- /dev/null +++ b/src/plugins/viewer/viewer-types.ts @@ -0,0 +1,71 @@ +import type { Subscription } from 'rxjs'; + +export type ViewerConnectionParams = { + topic: string; + signalingServerUrl: string; + databaseName: string; +}; + +export type ViewerServerOptions = { + signalingServerUrl?: string; + topic?: string; + webSocketConstructor?: { new(url: string): WebSocket; }; +}; + +export type ViewerState = { + connectionParams: ViewerConnectionParams; + close: () => Promise; +}; + +export type ViewerMethod = + | 'getDbInfo' + | 'getCollectionInfo' + | 'query' + | 'count' + | 'exportCollection' + | 'observeQuery' + | 'unobserveQuery' + | 'writeDocument' + | 'deleteDocument'; + +export type ViewerRequest = { + id: string; + method: ViewerMethod; + params?: any; +}; + +export type ViewerResponse = { + id: string; + result?: any; + error?: string; +}; + +export type ViewerPushMessage = { + type: 'observeResult'; + observeId: string; + data: any; +}; + +export type ViewerDbInfo = { + databaseName: string; + collections: ViewerCollectionInfo[]; +}; + +export type ViewerCollectionInfo = { + name: string; + schema: any; + docCount: number; + primaryKey?: string; +}; + +export type ViewerSignalingMessage = + | { type: 'init'; yourPeerId: string; } + | { type: 'join'; room: string; } + | { type: 'joined'; otherPeerIds: string[]; } + | { type: 'signal'; room: string; senderPeerId: string; receiverPeerId: string; data: any; } + | { type: 'ping'; }; + +export type ViewerPeerState = { + peerId: string; + observeSubscriptions: Map; +}; diff --git a/src/plugins/viewer/viewer.html b/src/plugins/viewer/viewer.html new file mode 100644 index 00000000000..b36502c350f --- /dev/null +++ b/src/plugins/viewer/viewer.html @@ -0,0 +1,1610 @@ + + + + + + RxDB Viewer + + + + + + +
+ + + From 0561f3a28de64eb06ae9344459d509cbaa84f396 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 19:51:38 +0000 Subject: [PATCH 2/5] Reuse docs-src component patterns in viewer UI Align viewer.html with docs-src design system: - CSS variables now reference docs-src tokens (--color-top, --color-middle, --color-bottom, --bg-color, --bg-color-dark, --bg-color-code) - Button styles match docs-src Button component (gradient primary, border secondary with white inversion on hover) - Added ViewerButton React component mirroring button.tsx behavior including mouse-following radial gradient on primary hover - Tab active indicator uses gradient border-image matching side-tabs pattern - Collection sidebar active state uses gradient left-border indicator from docs-src side-tabs__tab--active - Modal matches docs-src Modal component (no border-radius, bg-color background, close button styling) - Badge/pill shapes use border-radius: 20px matching docs-src Tag component - Transition timing updated to 0.2s matching docs-src standard https://claude.ai/code/session_01E4LPySRrjXCCRdV9pVKJom --- src/plugins/viewer/viewer.html | 244 +++++++++++++++++++++++++-------- 1 file changed, 186 insertions(+), 58 deletions(-) diff --git a/src/plugins/viewer/viewer.html b/src/plugins/viewer/viewer.html index b36502c350f..59e6300ddc9 100644 --- a/src/plugins/viewer/viewer.html +++ b/src/plugins/viewer/viewer.html @@ -9,11 +9,21 @@