diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 67c15a8458a..a268fb441c4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -849,6 +849,37 @@ jobs: # flutter config --enable-linux-desktop # flutter test integration_test/basics_test.dart -d linux + test-viewer: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - name: Set node version + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - name: Reuse npm cache folder + uses: actions/cache@v5 + env: + cache-name: cache-node-modules + with: + path: | + ~/.npm + ./node_modules + key: ${{ runner.os }}-npm-viewer-x4-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-npm-viewer-x4- + + - name: install npm dependencies + run: npm install + + - name: build + run: npm run build + + - name: test viewer plugin + run: npm run transpile && cross-env DEFAULT_STORAGE=memory mocha --config ./config/.mocharc.cjs ./test_tmp/unit/viewer.test.js --timeout 20000 + test-tutorials: runs-on: ubuntu-22.04 timeout-minutes: 30 diff --git a/docs-src/docs/viewer.md b/docs-src/docs/viewer.md new file mode 100644 index 00000000000..4fc8f68004e --- /dev/null +++ b/docs-src/docs/viewer.md @@ -0,0 +1,139 @@ +--- +title: RxDB Viewer - Remote Database Inspection +slug: viewer.html +description: Inspect and manage your RxDB database remotely with the Viewer plugin. Browse collections, run queries, edit documents and observe live changes through a WebRTC connection. +--- + +import {Steps} from '@site/src/components/steps'; + +# RxDB Viewer + +The RxDB Viewer plugin provides a browser-based UI to inspect and manage any RxDB database remotely. It connects to your running application over WebRTC, so no additional server infrastructure is required. Data flows directly between your app and the viewer in a peer-to-peer connection. + +## Features + +- Browse all collections with document counts +- View collection schemas +- Run Mango queries with sorting +- Observe queries for live-updating results via RxDB's reactive system +- Create, edit and delete documents +- Export collections as JSON +- Syntax-highlighted JSON document viewer + +## How it Works + +The viewer plugin starts a lightweight WebRTC signaling process inside your application. When you open the viewer HTML page in a browser and paste the connection parameters, a direct peer-to-peer connection is established. All database operations (queries, writes, deletes) are sent as messages over this WebRTC data channel. + +Because it uses WebRTC, the viewer works across different machines on the same network, across the internet (via the signaling server), and even between different JavaScript runtimes (Node.js, browser, React Native). + +## Installation + +```ts +import { + getDatabaseConnectionParams +} from 'rxdb/plugins/viewer'; +``` + +## Usage + + + +## Get connection parameters + +Call `getDatabaseConnectionParams()` with your database instance. The viewer server starts lazily on the first call and is cached for subsequent calls. + +```ts +import { getDatabaseConnectionParams } from 'rxdb/plugins/viewer'; + +const connectionParams = getDatabaseConnectionParams(myRxDatabase); +console.log(JSON.stringify(connectionParams, null, 2)); +// { +// "topic": "rxdb-viewer-abc123...", +// "signalingServerUrl": "wss://signaling.rxdb.info/", +// "databaseName": "mydb" +// } +``` + +You can pass options to customize the signaling server or topic: + +```ts +const connectionParams = getDatabaseConnectionParams(myRxDatabase, { + signalingServerUrl: 'wss://my-signaling-server.com/', + topic: 'my-custom-topic' +}); +``` + +## Open the viewer + +The viewer is a standalone HTML page bundled with the plugin. You can serve it from your application or open it directly. It is located at `node_modules/rxdb/dist/plugins/viewer/viewer.html`. + +## Connect + +Paste the connection parameters JSON into the viewer's connection form and click **Connect**. The viewer establishes a WebRTC peer-to-peer connection to your application and loads the database structure. + + + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `signalingServerUrl` | `string` | `wss://signaling.rxdb.info/` | WebSocket URL of the signaling server used to establish the WebRTC connection | +| `topic` | `string` | Auto-generated | A unique room identifier. Both sides must use the same topic to connect. | +| `webSocketConstructor` | `WebSocket` constructor | Global `WebSocket` | Custom WebSocket implementation, useful in Node.js environments. | + +## Automatic Lifecycle + +The viewer server is managed automatically: + +- **Lazy start**: The server is created on the first call to `getDatabaseConnectionParams()` and cached per database. +- **Auto-close**: When the database is closed (via `database.close()`), the viewer server shuts down automatically, closing all WebRTC connections and cleaning up subscriptions. +- **Shared instance**: Multiple calls to `getDatabaseConnectionParams()` for the same database return the same connection parameters. + +## Using `startRxDBViewer()` directly + +If you need access to the `ViewerState` object (for example, to manually close the viewer before the database closes), you can use `startRxDBViewer()`: + +```ts +import { startRxDBViewer } from 'rxdb/plugins/viewer'; + +const viewerState = await startRxDBViewer(myRxDatabase, { + signalingServerUrl: 'wss://signaling.rxdb.info/' +}); + +// The connection params are available on the state +console.log(viewerState.connectionParams); + +// Manually close the viewer +await viewerState.close(); +``` + +## Viewer Capabilities + +Once connected, the viewer UI supports these operations: + +**Browsing**: The left sidebar lists all collections with document counts. Click a collection to load its documents in a table view. Click any row to see the full document JSON in the detail panel. + +**Querying**: Enter a [Mango query](./rx-query.md) in the query bar and press Run. Click column headers to sort. Example queries: + +```json +{ "selector": { "age": { "$gt": 18 } } } +``` + +```json +{ "selector": { "name": { "$regex": "^A" } }, "limit": 10 } +``` + +**Observing**: Click **Observe** to start a live query subscription. The Query Observer panel at the bottom shows all active subscriptions with their result counts and last update times. Results update in real-time as documents change in the database. + +**Editing**: Click **+ Add** to create a new document (pre-filled from the collection schema). Select a document and click **Edit** to modify it. Click **Delete** to remove it. + +**Exporting**: Click **Export** to download all documents in the current collection as a JSON file. + +## Security Considerations + +The viewer grants full read and write access to the connected database. Keep the connection parameters private. Anyone with the topic and signaling server URL can connect to your database. + +For production use, consider: +- Using a private signaling server instead of the default public one +- Generating unique topics per session +- Only enabling the viewer in development or debug builds diff --git a/docs-src/refactor-charts.js b/docs-src/refactor-charts.js index e1209456ddc..35bbe33307b 100644 --- a/docs-src/refactor-charts.js +++ b/docs-src/refactor-charts.js @@ -3,8 +3,8 @@ const path = require('path'); function walkDir(dir, callback) { fs.readdirSync(dir).forEach(f => { - let dirPath = path.join(dir, f); - let isDirectory = fs.statSync(dirPath).isDirectory(); + const dirPath = path.join(dir, f); + const isDirectory = fs.statSync(dirPath).isDirectory(); if (isDirectory) { walkDir(dirPath, callback); } else if (f.endsWith('.md') || f.endsWith('.mdx')) { @@ -42,10 +42,10 @@ walkDir(path.join(__dirname, 'docs'), (filePath) => { // Prepare new imports let newImports = ''; if (needsBrowser) { - newImports += "import PerformanceBrowser from '@site/src/components/performance-browser';\n"; + newImports += 'import PerformanceBrowser from \'@site/src/components/performance-browser\';\n'; } if (needsNode) { - newImports += "import PerformanceNode from '@site/src/components/performance-node';\n"; + newImports += 'import PerformanceNode from \'@site/src/components/performance-node\';\n'; } // Insert new imports after frontmatter diff --git a/docs-src/replace-charts.js b/docs-src/replace-charts.js index 9c671b3e5a6..d47b4fcfc71 100644 --- a/docs-src/replace-charts.js +++ b/docs-src/replace-charts.js @@ -67,8 +67,8 @@ const nData = ` function walkDir(dir, callback) { fs.readdirSync(dir).forEach(f => { - let dirPath = path.join(dir, f); - let isDirectory = fs.statSync(dirPath).isDirectory(); + const dirPath = path.join(dir, f); + const isDirectory = fs.statSync(dirPath).isDirectory(); if (isDirectory) { walkDir(dirPath, callback); } else if (f.endsWith('.md')) { diff --git a/docs-src/sidebars.js b/docs-src/sidebars.js index 5b9ad80d47f..c5fee855289 100644 --- a/docs-src/sidebars.js +++ b/docs-src/sidebars.js @@ -487,6 +487,11 @@ const sidebars = { id: 'webmcp', label: 'WebMCP' }, + { + type: 'doc', + id: 'viewer', + label: 'Viewer' + }, { type: 'doc', id: 'third-party-plugins', diff --git a/docs-src/src/components/performance-chart-impl.tsx b/docs-src/src/components/performance-chart-impl.tsx index 3ce764c1edd..5e97c8dfa26 100644 --- a/docs-src/src/components/performance-chart-impl.tsx +++ b/docs-src/src/components/performance-chart-impl.tsx @@ -17,7 +17,7 @@ type PerformanceDataPoint = { type PerformanceChartImplProps = { data: PerformanceDataPoint[]; - metrics: { key: string; name: string; color: string }[]; + metrics: { key: string; name: string; color: string; }[]; title?: string; }; diff --git a/docs-src/src/components/performance-chart.tsx b/docs-src/src/components/performance-chart.tsx index 1bff9ab376c..5937daeafdc 100644 --- a/docs-src/src/components/performance-chart.tsx +++ b/docs-src/src/components/performance-chart.tsx @@ -1,6 +1,6 @@ import React, { Suspense } from 'react'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; -import { PERFORMANCE_METRICS, PERFORMANCE_DATA_BROWSER, PERFORMANCE_DATA_NODE } from './performance-data'; +// performance-data exports available: PERFORMANCE_METRICS, PERFORMANCE_DATA_BROWSER, PERFORMANCE_DATA_NODE // Lazy load the chart implementation so recharts isn't in the main bundle const PerformanceChartImpl = React.lazy(() => import('./performance-chart-impl')); @@ -12,7 +12,7 @@ type PerformanceDataPoint = { type PerformanceChartProps = { data: PerformanceDataPoint[]; - metrics?: { key: string; name: string; color: string }[]; + metrics?: { key: string; name: string; color: string; }[]; title?: string; }; diff --git a/docs-src/src/components/performance-node.tsx b/docs-src/src/components/performance-node.tsx index 04b2b7252ef..4b03c3f8c3e 100644 --- a/docs-src/src/components/performance-node.tsx +++ b/docs-src/src/components/performance-node.tsx @@ -6,14 +6,14 @@ import { browserMetrics, PerformanceData } from './performance-browser'; const nodeData: PerformanceData[] = [ { name: 'MongoDB', - "time-to-first-insert": 276.906, - "insert-documents-500": 47.497, - "find-by-ids-3000": 57.31, - "serial-inserts-50": 209.467, - "serial-find-by-id-50": 23.09, - "find-by-query": 42.315, - "find-by-query-parallel-4": 38.854, - "4x-count": 6.898 + 'time-to-first-insert': 276.906, + 'insert-documents-500': 47.497, + 'find-by-ids-3000': 57.31, + 'serial-inserts-50': 209.467, + 'serial-find-by-id-50': 23.09, + 'find-by-query': 42.315, + 'find-by-query-parallel-4': 38.854, + '4x-count': 6.898 }, { name: 'Memory', diff --git a/docs-src/update-colors.js b/docs-src/update-colors.js index fb6e847669a..23994ae2d5a 100644 --- a/docs-src/update-colors.js +++ b/docs-src/update-colors.js @@ -3,8 +3,8 @@ const path = require('path'); function walkDir(dir, callback) { fs.readdirSync(dir).forEach(f => { - let dirPath = path.join(dir, f); - let isDirectory = fs.statSync(dirPath).isDirectory(); + const dirPath = path.join(dir, f); + const isDirectory = fs.statSync(dirPath).isDirectory(); if (isDirectory) { walkDir(dirPath, callback); } else if (f.endsWith('.md')) { 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..4007ffc7764 --- /dev/null +++ b/src/plugins/viewer/viewer-server.ts @@ -0,0 +1,409 @@ +import type { Subscription } from 'rxjs'; +import type { RxCollection, RxDatabase } from '../../types/index.d.ts'; +import { + ensureNotFalsy, + randomToken +} from '../../plugins/utils/index.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) }; + } +} + +function closeSocket(ws: WebSocket | undefined) { + if (!ws) { + return; + } + // Remove handlers to prevent reconnection, but keep onerror + // as a no-op to catch async errors emitted by ws.terminate() + ws.onclose = null; + ws.onopen = null; + ws.onmessage = null; + ws.onerror = () => { /* absorb async errors from terminate/close */ }; + if (typeof (ws as any).removeAllListeners === 'function') { + (ws as any).removeAllListeners('error'); + (ws as any).on('error', () => { /* absorb */ }); + } + try { + if (typeof (ws as any).terminate === 'function') { + (ws as any).terminate(); + } else { + ws.close(); + } + } catch (_e) { + // Socket may already be closed or in an invalid state + } +} + +function startViewerServer( + database: RxDatabase, + options: ViewerServerOptions = {} +): ViewerState { + 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.onerror = () => { /* handled */ }; + socket.onclose = () => { + if (!closed) { + 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) { + if (closed) { + return; + } + 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); + } + if (!closed) { + 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() { + if (closed) { + return; + } + closed = true; + clearInterval(pingInterval); + peerSubscriptions.forEach(peerSubs => { + peerSubs.forEach(sub => sub.unsubscribe()); + }); + peerSubscriptions.clear(); + peers.forEach(peer => { + try { peer.destroy(); } catch (_e) { /* */ } + }); + peers.clear(); + closeSocket(socket); + socket = undefined; + VIEWER_STATE_BY_DATABASE.delete(database); + } + }; + + VIEWER_STATE_BY_DATABASE.set(database, state); + + // Auto-close when the database closes + database.onClose.push(() => state.close()); + + return state; +} + +/** + * Get the connection parameters for a database's viewer server. + * Lazily starts the viewer server on first call and caches it. + * The server is automatically closed when the database is closed. + */ +export function getDatabaseConnectionParams( + database: RxDatabase, + options?: ViewerServerOptions +): ViewerConnectionParams { + let state = VIEWER_STATE_BY_DATABASE.get(database); + if (!state) { + state = startViewerServer(database, options); + } + return state.connectionParams; +} + +/** + * Start the viewer server explicitly. + * Prefer using getDatabaseConnectionParams() which handles + * lazy initialization and caching automatically. + */ +export async function startRxDBViewer( + database: RxDatabase, + options: ViewerServerOptions = {} +): Promise { + let state = VIEWER_STATE_BY_DATABASE.get(database); + if (state) { + return state; + } + state = startViewerServer(database, options); + return state; +} 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..59e6300ddc9 --- /dev/null +++ b/src/plugins/viewer/viewer.html @@ -0,0 +1,1738 @@ + + + + + + RxDB Viewer + + + + + + +
+ + + diff --git a/test/unit.test.ts b/test/unit.test.ts index d5ab0bc7f4d..a9516c0d544 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -63,6 +63,7 @@ import './unit/attachments.test.ts'; import './unit/attachments-compression.test.ts'; import './unit/migration-storage.test.ts'; import './unit/webmcp.test.ts'; +import './unit/viewer.test.ts'; import './unit/crdt.test.ts'; import './unit/population.test.ts'; import './unit/leader-election.test.ts'; diff --git a/test/unit/viewer.test.ts b/test/unit/viewer.test.ts new file mode 100644 index 00000000000..35e6128b5a5 --- /dev/null +++ b/test/unit/viewer.test.ts @@ -0,0 +1,194 @@ +import assert from 'assert'; +import { + createRxDatabase, + randomToken, +} from '../../plugins/core/index.mjs'; +import { + getDatabaseConnectionParams, + startRxDBViewer, + VIEWER_DEFAULT_SIGNALING_SERVER, +} from '../../plugins/viewer/index.mjs'; +import config from './config.ts'; +import { + schemas, + isNode, + isDeno +} from '../../plugins/test-utils/index.mjs'; + +describe('viewer.test.ts', function () { + this.timeout(1000 * 20); + + if (isDeno) { + // WebRTC/WebSocket polyfills not available in Deno + return; + } + + let webSocketConstructor: any; + describe('init', () => { + it('import WebSocket polyfill on Node.js', async () => { + if (isNode) { + const wsModule = await import('ws'); + webSocketConstructor = wsModule.WebSocket; + } + }); + }); + + function getOptions() { + return webSocketConstructor + ? { webSocketConstructor, signalingServerUrl: 'ws://localhost:18006' } + : { signalingServerUrl: 'ws://localhost:18006' }; + } + + async function createTestDatabase() { + const db = await createRxDatabase({ + name: randomToken(10), + storage: config.storage.getStorage(), + }); + await db.addCollections({ + humans: { + schema: schemas.human, + }, + }); + return db; + } + + describe('getDatabaseConnectionParams()', () => { + it('should lazily create a viewer server and return connection params', async () => { + const db = await createTestDatabase(); + const params = getDatabaseConnectionParams(db, getOptions()); + + assert.ok(params); + assert.ok(typeof params.topic === 'string'); + assert.ok(params.topic.length > 0); + assert.ok(typeof params.signalingServerUrl === 'string'); + assert.strictEqual(params.databaseName, db.name); + + await db.close(); + }); + + it('should return the same params on subsequent calls (cached)', async () => { + const db = await createTestDatabase(); + const params1 = getDatabaseConnectionParams(db, getOptions()); + const params2 = getDatabaseConnectionParams(db, getOptions()); + + assert.strictEqual(params1.topic, params2.topic); + assert.strictEqual(params1.signalingServerUrl, params2.signalingServerUrl); + assert.strictEqual(params1.databaseName, params2.databaseName); + + await db.close(); + }); + + it('should use custom signaling server URL when provided', async () => { + const db = await createTestDatabase(); + const customUrl = 'ws://localhost:18006'; + const params = getDatabaseConnectionParams(db, { + ...getOptions(), + signalingServerUrl: customUrl, + }); + + assert.strictEqual(params.signalingServerUrl, customUrl); + + await db.close(); + }); + + it('should use custom topic when provided', async () => { + const db = await createTestDatabase(); + const customTopic = 'my-custom-topic-' + randomToken(6); + const params = getDatabaseConnectionParams(db, { + ...getOptions(), + topic: customTopic, + }); + + assert.strictEqual(params.topic, customTopic); + + await db.close(); + }); + + it('should create different topics for different databases', async () => { + const db1 = await createTestDatabase(); + const db2 = await createTestDatabase(); + + const params1 = getDatabaseConnectionParams(db1, getOptions()); + const params2 = getDatabaseConnectionParams(db2, getOptions()); + + assert.notStrictEqual(params1.topic, params2.topic); + + await db1.close(); + await db2.close(); + }); + }); + + describe('startRxDBViewer()', () => { + it('should return a ViewerState with connectionParams and close()', async () => { + const db = await createTestDatabase(); + const state = await startRxDBViewer(db, getOptions()); + + assert.ok(state); + assert.ok(state.connectionParams); + assert.ok(typeof state.connectionParams.topic === 'string'); + assert.ok(typeof state.close === 'function'); + + await db.close(); + }); + + it('should return the same state if called multiple times', async () => { + const db = await createTestDatabase(); + const state1 = await startRxDBViewer(db, getOptions()); + const state2 = await startRxDBViewer(db, getOptions()); + + assert.strictEqual(state1.connectionParams.topic, state2.connectionParams.topic); + + await db.close(); + }); + + it('should share state with getDatabaseConnectionParams()', async () => { + const db = await createTestDatabase(); + const state = await startRxDBViewer(db, getOptions()); + const params = getDatabaseConnectionParams(db); + + assert.strictEqual(state.connectionParams.topic, params.topic); + + await db.close(); + }); + + it('should be closable manually', async () => { + const db = await createTestDatabase(); + const state = await startRxDBViewer(db, getOptions()); + + // Close the viewer manually + await state.close(); + + // After closing, getDatabaseConnectionParams creates a new server + const newParams = getDatabaseConnectionParams(db, getOptions()); + assert.notStrictEqual(state.connectionParams.topic, newParams.topic); + + await db.close(); + }); + }); + + describe('auto-close on database close', () => { + it('should clean up viewer when database is closed', async () => { + const db = await createTestDatabase(); + const params = getDatabaseConnectionParams(db, getOptions()); + + assert.ok(params.topic); + + // Close the database - this should auto-close the viewer + await db.close(); + + // Create a new database and verify a new viewer would get new params + const db2 = await createTestDatabase(); + const params2 = getDatabaseConnectionParams(db2, getOptions()); + assert.notStrictEqual(params.topic, params2.topic); + + await db2.close(); + }); + }); + + describe('VIEWER_DEFAULT_SIGNALING_SERVER', () => { + it('should export the default signaling server URL', () => { + assert.ok(typeof VIEWER_DEFAULT_SIGNALING_SERVER === 'string'); + assert.ok(VIEWER_DEFAULT_SIGNALING_SERVER.startsWith('wss://')); + }); + }); +});