@vdoninja/mcp 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/SKILL.md +8 -6
  4. package/assets/browser-agent/audio-worklet.js +17 -0
  5. package/assets/browser-agent/client.js +124 -0
  6. package/assets/browser-agent/index.html +50 -0
  7. package/assets/cloudflare/worker-proxy.js +3 -0
  8. package/docs/.nojekyll +0 -0
  9. package/docs/404.html +28 -0
  10. package/docs/advanced-diagnostics.html +101 -0
  11. package/docs/assets/apple-touch-icon.png +0 -0
  12. package/docs/assets/favicon.svg +7 -0
  13. package/docs/assets/how-it-works.svg +62 -0
  14. package/docs/assets/icon-192.png +0 -0
  15. package/docs/assets/icon-512.png +0 -0
  16. package/docs/assets/icon-maskable-512.png +0 -0
  17. package/docs/assets/icons.svg +42 -0
  18. package/docs/assets/logo.svg +7 -0
  19. package/docs/assets/og-image.png +0 -0
  20. package/docs/assets/site.css +735 -0
  21. package/docs/assets/site.js +289 -0
  22. package/docs/browser-agent.html +122 -0
  23. package/docs/browser-worker.html +165 -0
  24. package/docs/client-config-examples.html +254 -0
  25. package/docs/direction.html +175 -0
  26. package/docs/favicon.ico +0 -0
  27. package/docs/guide.html +872 -0
  28. package/docs/http-relay.html +154 -0
  29. package/docs/index.html +834 -0
  30. package/docs/mcp-tool-contract.html +226 -0
  31. package/docs/quickstart-and-compat.html +222 -0
  32. package/docs/site.webmanifest +16 -0
  33. package/docs/sitemap.xml +63 -0
  34. package/docs/ssapp-integration.html +594 -0
  35. package/docs/vdo-ninja-p2p-data-notes.html +225 -0
  36. package/package.json +67 -11
  37. package/references/advanced-diagnostics.md +6 -4
  38. package/references/browser-agent.md +53 -0
  39. package/references/browser-worker.md +142 -0
  40. package/references/client-config-examples.md +253 -0
  41. package/references/direction.md +88 -0
  42. package/references/http-relay.md +107 -0
  43. package/references/mcp-tool-contract.md +16 -6
  44. package/references/quickstart-and-compat.md +18 -6
  45. package/references/ssapp-integration.md +371 -0
  46. package/references/vdo-ninja-p2p-data-notes.md +3 -1
  47. package/scripts/demo-file-workflow.js +25 -6
  48. package/scripts/demo-message-workflow.js +25 -6
  49. package/scripts/install-mcp.js +55 -4
  50. package/scripts/lib/bridge-session.js +143 -26
  51. package/scripts/lib/bridge-utils.js +3 -0
  52. package/scripts/lib/browser-agent.js +255 -0
  53. package/scripts/lib/browser-media.js +79 -0
  54. package/scripts/lib/browser-routes.js +119 -0
  55. package/scripts/lib/browser-rpc.js +214 -0
  56. package/scripts/lib/browser-setup.js +133 -0
  57. package/scripts/lib/browser-worker.js +216 -0
  58. package/scripts/lib/fake-network-sdk.js +3 -0
  59. package/scripts/lib/http-relay-transport.js +294 -0
  60. package/scripts/lib/load-vdo-sdk.js +3 -0
  61. package/scripts/lib/mcp-stdio-parser.js +3 -0
  62. package/scripts/lib/ssapp-worker.js +198 -0
  63. package/scripts/lib/tool-profiles.js +3 -0
  64. package/scripts/vdo-data-bridge.js +3 -0
  65. package/scripts/vdo-mcp-browser-agent.js +132 -0
  66. package/scripts/vdo-mcp-browser.js +218 -0
  67. package/scripts/vdo-mcp-relay.js +207 -0
  68. package/scripts/vdo-mcp-server.js +87 -8
  69. package/scripts/vdo-mcp-ssapp.js +208 -0
  70. package/scripts/vdo-mcp-streamable-http.js +23 -4
  71. package/server.json +26 -10
@@ -0,0 +1,216 @@
1
+ // Copyright (c) 2026 Steve Seguin
2
+ // SPDX-License-Identifier: MIT
3
+ 'use strict';
4
+
5
+ const crypto = require('node:crypto');
6
+ const path = require('node:path');
7
+ const { spawn } = require('node:child_process');
8
+ const { McpStdioParser } = require('./mcp-stdio-parser');
9
+
10
+ class ChromeMcp {
11
+ constructor(entry, endpoint) {
12
+ this.pending = new Map();
13
+ this.nextId = 0;
14
+ this.failed = false;
15
+ this.child = spawn(process.execPath, [entry, `--ws-endpoint=${endpoint}`, '--page-id-routing=true',
16
+ '--javascript-evaluation=false', '--file-navigations=false', '--no-usage-statistics', '--no-performance-crux'], {
17
+ windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
18
+ env: { ...process.env, UV_THREADPOOL_SIZE: '2', CHROME_DEVTOOLS_MCP_NO_CONFIG_DISCOVERY: '1', CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: '1' }
19
+ });
20
+ const parser = new McpStdioParser({ maxMessageBytes: 4 * 1024 * 1024 });
21
+ this.child.stdout.on('data', (chunk) => {
22
+ parser.push(chunk);
23
+ if (parser.buffer.length > 4 * 1024 * 1024) this.fail(new Error('Browser MCP response too large'));
24
+ });
25
+ // The child may log page details: keep stdout exclusively for the outer MCP protocol.
26
+ this.child.stderr.on('data', () => {});
27
+ parser.on('error', () => this.fail(new Error('Invalid browser MCP response')));
28
+ parser.on('message', ({ message }) => {
29
+ const pending = this.pending.get(message.id);
30
+ if (!pending) return;
31
+ clearTimeout(pending.timer);
32
+ this.pending.delete(message.id);
33
+ if (message.error) pending.reject(new Error(message.error.message));
34
+ else pending.resolve(message.result);
35
+ });
36
+ this.child.on('error', (error) => this.fail(error));
37
+ this.child.on('exit', () => this.fail(new Error('Browser MCP stopped; restart the worker')));
38
+ this.child.stdin.on('error', (error) => this.fail(error));
39
+ }
40
+
41
+ fail(error) {
42
+ this.failed = true;
43
+ for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(error); }
44
+ this.pending.clear();
45
+ }
46
+
47
+ request(method, params = {}) {
48
+ if (this.failed) return Promise.reject(new Error('Browser backend unavailable; restart the worker'));
49
+ const id = ++this.nextId;
50
+ return new Promise((resolve, reject) => {
51
+ const timer = setTimeout(() => {
52
+ // Do not start another action while a timed-out command may still be running.
53
+ this.fail(new Error('Browser operation outcome unknown; restart the worker'));
54
+ this.child.kill();
55
+ }, 20000);
56
+ this.pending.set(id, { resolve, reject, timer });
57
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
58
+ });
59
+ }
60
+
61
+ async start() {
62
+ await this.request('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'ninja-browser-worker', version: '0.4.2' } });
63
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
64
+ const { tools } = await this.request('tools/list');
65
+ for (const name of ['list_pages', 'take_snapshot', 'take_screenshot', 'navigate_page', 'click', 'fill', 'press_key']) {
66
+ const tool = tools.find((entry) => entry.name === name);
67
+ if (!tool || (name !== 'list_pages' && !tool.inputSchema.required.includes('pageId'))) throw new Error(`Incompatible Chrome MCP tool: ${name}`);
68
+ }
69
+ }
70
+
71
+ call(name, args) { return this.request('tools/call', { name, arguments: args }); }
72
+ close() { this.fail(new Error('Browser worker closed')); this.child.kill(); }
73
+ }
74
+
75
+ function exactKeys(value, allowed) {
76
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !allowed.includes(key))) throw new Error('invalid_arguments');
77
+ }
78
+
79
+ class BrowserController {
80
+ constructor(backend, pages = [1]) {
81
+ this.backend = backend;
82
+ this.pages = new Set(pages);
83
+ this.leases = new Map();
84
+ this.snapshots = new Map();
85
+ this.generation = 0;
86
+ }
87
+
88
+ revoke(pageId) {
89
+ this.leases.delete(pageId);
90
+ this.invalidate(pageId);
91
+ }
92
+
93
+ invalidate(pageId) {
94
+ for (const [id, snapshot] of this.snapshots) if (snapshot.pageId === pageId) this.snapshots.delete(id);
95
+ }
96
+
97
+ async execute(client, role, op, args) {
98
+ if (op === 'tabs') {
99
+ exactKeys(args, []);
100
+ const raw = await this.backend.call('list_pages', {});
101
+ const result = { content: [{ type: 'text', text: (raw.content || []).filter((item) => item.type === 'text').map((item) => item.text.split('\n').filter((line) => {
102
+ const match = /^(\d+): /.exec(line);
103
+ return match && this.pages.has(Number(match[1]));
104
+ }).join('\n')).join('\n') }], isError: raw.isError };
105
+ return { result, leases: Array.from(this.leases, ([pageId, lease]) => ({ pageId, owner: lease.owner, generation: lease.generation, expires: lease.expires })).filter((lease) => lease.expires > Date.now()) };
106
+ }
107
+ if (!Number.isSafeInteger(args.pageId) || args.pageId < 0) throw new Error('pageId_required');
108
+ const pageId = args.pageId;
109
+ if (!this.pages.has(pageId)) throw new Error('page_not_shared');
110
+ if (op === 'observe') {
111
+ exactKeys(args, ['pageId', 'screenshot']);
112
+ if (args.screenshot !== undefined && typeof args.screenshot !== 'boolean') throw new Error('invalid_screenshot');
113
+ const result = await this.backend.call(args.screenshot ? 'take_screenshot' : 'take_snapshot', args.screenshot
114
+ ? { pageId, format: 'jpeg', quality: 55, fullPage: false }
115
+ : { pageId });
116
+ if (result.isError) return { result };
117
+ const snapshot = crypto.randomUUID();
118
+ // Chrome MCP's accessibility UIDs belong to its latest snapshot for this tab.
119
+ if (!args.screenshot) {
120
+ this.invalidate(pageId);
121
+ if (this.snapshots.size >= 128) this.snapshots.delete(this.snapshots.keys().next().value);
122
+ this.snapshots.set(snapshot, { client, pageId, expires: Date.now() + 30000 });
123
+ }
124
+ return { result, snapshot: args.screenshot ? null : snapshot, captured_at: new Date().toISOString() };
125
+ }
126
+ if (role !== 'controller') throw new Error('observer_cannot_control');
127
+ const lease = this.leases.get(pageId);
128
+ if (op === 'claim') {
129
+ exactKeys(args, ['pageId', 'ttl_ms', 'lease']);
130
+ const ttl = args.ttl_ms === undefined ? 60000 : args.ttl_ms;
131
+ if (!Number.isInteger(ttl) || ttl < 1000 || ttl > 120000) throw new Error('ttl_ms_must_be_1000_to_120000');
132
+ if (lease && lease.expires > Date.now()) {
133
+ if (lease.owner !== client || args.lease !== lease.token) throw new Error('tab_already_claimed');
134
+ lease.expires = Date.now() + ttl;
135
+ return { lease: lease.token, generation: lease.generation, expires: lease.expires };
136
+ }
137
+ if (this.leases.size >= 128) {
138
+ for (const [id, value] of this.leases) if (value.expires <= Date.now()) this.leases.delete(id);
139
+ if (this.leases.size >= 128) throw new Error('too_many_leases');
140
+ }
141
+ this.invalidate(pageId);
142
+ const next = { owner: client, token: crypto.randomUUID(), generation: ++this.generation, expires: Date.now() + ttl };
143
+ this.leases.set(pageId, next);
144
+ return { lease: next.token, generation: next.generation, expires: next.expires };
145
+ }
146
+ if (!lease || lease.owner !== client || lease.token !== args.lease || lease.expires <= Date.now()) throw new Error('valid_tab_lease_required');
147
+ if (op === 'release') {
148
+ exactKeys(args, ['pageId', 'lease']);
149
+ this.revoke(pageId);
150
+ return { released: true };
151
+ }
152
+ if (op !== 'call') throw new Error('unknown_browser_operation');
153
+ exactKeys(args, ['pageId', 'lease', 'snapshot', 'action', 'url', 'uid', 'value', 'key']);
154
+ const allowed = { navigate: ['url'], click: ['uid'], fill: ['uid', 'value'], key: ['key'] };
155
+ if (!Object.hasOwn(allowed, args.action)) throw new Error('action_not_allowed');
156
+ exactKeys(args, ['pageId', 'lease', 'snapshot', 'action', ...allowed[args.action]]);
157
+ for (const field of allowed[args.action]) if (typeof args[field] !== 'string' || args[field].length > 16000) throw new Error(`invalid_${field}`);
158
+ if (args.action !== 'navigate') {
159
+ const snapshot = this.snapshots.get(args.snapshot);
160
+ if (!snapshot || snapshot.client !== client || snapshot.pageId !== pageId || snapshot.expires < Date.now()) throw new Error('fresh_snapshot_required');
161
+ }
162
+ let tool, params = { pageId };
163
+ if (args.action === 'navigate') {
164
+ const url = new URL(args.url);
165
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('http_or_https_url_required');
166
+ tool = 'navigate_page'; params.url = url.href; params.timeout = 15000;
167
+ } else if (args.action === 'click') { tool = 'click'; params.uid = args.uid; }
168
+ else if (args.action === 'fill') { tool = 'fill'; params.uid = args.uid; params.value = args.value; }
169
+ else {
170
+ // Do not expose browser shortcuts (address bar, DevTools, file picker).
171
+ if (!['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(args.key)) throw new Error('key_not_allowed');
172
+ tool = 'press_key'; params.key = args.key;
173
+ }
174
+ this.invalidate(pageId);
175
+ return { result: await this.backend.call(tool, params), observed_after_action: false };
176
+ }
177
+ }
178
+
179
+ async function launchBrowser(config) {
180
+ // Optional peers keep browser dependencies out of ordinary room-only installations.
181
+ const roots = [config.dependencies || process.cwd(), path.join(__dirname, '..', '..')];
182
+ const resolve = (name) => require.resolve(name, { paths: roots });
183
+ const chromePackage = resolve('chrome-devtools-mcp/package.json');
184
+ const version = require(chromePackage).version;
185
+ if (version !== '1.11.0') throw new Error('Install chrome-devtools-mcp@1.11.0 for this worker');
186
+ const puppeteer = require(resolve('puppeteer-core'));
187
+ const captureTitle = `ninja_capture_${crypto.randomBytes(24).toString('hex')}`;
188
+ const mediaEnabled = !!(config.media && config.media.enabled === true);
189
+ const browser = await puppeteer.launch({
190
+ ...(config.executable ? { executablePath: config.executable } : { channel: 'chrome' }),
191
+ headless: true, defaultViewport: { width: 1280, height: 720 },
192
+ ...(mediaEnabled ? { ignoreDefaultArgs: ['--mute-audio'] } : {}),
193
+ args: ['--renderer-process-limit=2', '--num-raster-threads=2', ...(mediaEnabled ? [`--auto-select-tab-capture-source-by-title=${captureTitle}`, '--autoplay-policy=no-user-gesture-required'] : [])],
194
+ env: { ...process.env, UV_THREADPOOL_SIZE: '2' }
195
+ });
196
+ let backend, media;
197
+ try {
198
+ // The endpoint is loopback-only and never appears in remote results.
199
+ backend = new ChromeMcp(path.join(path.dirname(chromePackage), 'build/src/bin/chrome-devtools-mcp.js'), browser.wsEndpoint());
200
+ await backend.start();
201
+ const listed = await backend.call('list_pages', {});
202
+ const pages = (listed.content || []).filter((item) => item.type === 'text').flatMap((item) => Array.from(item.text.matchAll(/^(\d+): /gm), (match) => Number(match[1])));
203
+ if (pages.length !== 1) throw new Error('Expected one isolated browser tab');
204
+ const target = (await browser.pages())[0];
205
+ let mediaError;
206
+ if (mediaEnabled) {
207
+ try { media = await require('./browser-media').startBrowserMedia(browser, target, captureTitle, config.media); }
208
+ catch (error) { mediaError = error.message; }
209
+ }
210
+ return { browser, backend, pages, target, media,
211
+ mediaStatus: async () => media ? media.status() : { enabled: false, ...(mediaError ? { error: mediaError, action: 'Use browser_observe screenshots or restart the worker after restoring WebRTC media connectivity.' } : {}) },
212
+ close: async () => { backend.close(); try { if (media) await media.close(); } finally { await browser.close(); } } };
213
+ } catch (error) { if (backend) backend.close(); if (media) await media.close(); await browser.close(); throw error; }
214
+ }
215
+
216
+ module.exports = { ChromeMcp, BrowserController, launchBrowser };
@@ -1,3 +1,6 @@
1
+ // Copyright (c) 2026 Steve Seguin
2
+ // SPDX-License-Identifier: MIT
3
+
1
4
  'use strict';
2
5
 
3
6
  const { randomUUID } = require('./bridge-utils');
@@ -0,0 +1,294 @@
1
+ // Copyright (c) 2026 Steve Seguin
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ 'use strict';
5
+
6
+ const crypto = require('node:crypto');
7
+ const http = require('node:http');
8
+ const https = require('node:https');
9
+ const EventEmitter = require('node:events');
10
+
11
+ function validateRelayConfig(config) {
12
+ let url;
13
+ try { url = new URL(config.relayUrl); } catch (_) { throw new Error('relay_url must be a valid HTTPS origin'); }
14
+ const loopback = ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname);
15
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
16
+ throw new Error('relay_url requires HTTPS except on loopback');
17
+ }
18
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') {
19
+ throw new Error('relay_url must be an origin without credentials, path, query, or fragment');
20
+ }
21
+ if (typeof config.relayToken !== 'string' || config.relayToken.length < 32 || /[^\x21-\x7e]/.test(config.relayToken)) {
22
+ throw new Error('relay_token requires at least 32 printable non-space characters');
23
+ }
24
+ if (typeof config.relayKey !== 'string' || !/^[a-fA-F0-9]{64}$/.test(config.relayKey)) {
25
+ throw new Error('relay_key requires 32 random bytes encoded as 64 hexadecimal characters');
26
+ }
27
+ return url;
28
+ }
29
+
30
+ // Implements the bridge's small SDK event/send interface without loading WebRTC.
31
+ // POST acceptance is a relay receipt, not confirmation that a peer consumed data.
32
+ class HttpRelayTransport extends EventEmitter {
33
+ constructor(config) {
34
+ super();
35
+ const origin = validateRelayConfig(config);
36
+ const secret = Buffer.from(config.relayKey, 'hex');
37
+ this.roomId = crypto.createHmac('sha256', secret).update(`ninjamcp-room-v1:${config.room}`).digest('hex');
38
+ this.key = Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.from(this.roomId), Buffer.from('ninjamcp-relay-v1'), 32));
39
+ this.url = new URL(`/v1/rooms/${this.roomId}/events`, origin);
40
+ this.token = config.relayToken;
41
+ this.uuid = crypto.randomUUID();
42
+ this.streamID = null;
43
+ this.connected = false;
44
+ this.closed = false;
45
+ this.cursor = null;
46
+ this.peers = new Map();
47
+ this.seen = new Map();
48
+ this.requests = new Set();
49
+ this.pending = [];
50
+ this.pendingBytes = 0;
51
+ this.sending = false;
52
+ this.heartbeat = null;
53
+ this.retryTimer = null;
54
+ this.retryResolve = null;
55
+ }
56
+
57
+ addEventListener(name, handler) { this.on(name, handler); }
58
+ removeEventListener(name, handler) { this.off(name, handler); }
59
+ event(name, detail) { this.emit(name, { detail }); }
60
+
61
+ request(method, search, body) {
62
+ if (this.closed) return Promise.reject(new Error('relay transport stopped'));
63
+ const url = new URL(this.url);
64
+ url.search = search || '';
65
+ const encoded = body ? JSON.stringify(body) : null;
66
+ return new Promise((resolve, reject) => {
67
+ const headers = { authorization: `Bearer ${this.token}` };
68
+ if (encoded) {
69
+ headers['content-type'] = 'application/json';
70
+ headers['content-length'] = Buffer.byteLength(encoded);
71
+ }
72
+ const req = (url.protocol === 'https:' ? https : http).request(url, { method, headers }, (res) => {
73
+ const chunks = [];
74
+ let size = 0;
75
+ res.on('data', (chunk) => {
76
+ size += chunk.length;
77
+ if (size > 2 * 1024 * 1024) { res.destroy(new Error('relay response too large')); return; }
78
+ chunks.push(chunk);
79
+ });
80
+ res.on('error', reject);
81
+ res.on('end', () => {
82
+ let data;
83
+ try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch (_) {
84
+ reject(new Error('invalid relay response')); return;
85
+ }
86
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve(data);
87
+ else {
88
+ const error = new Error(`relay HTTP ${res.statusCode}`);
89
+ error.status = res.statusCode;
90
+ error.response = data;
91
+ reject(error);
92
+ }
93
+ });
94
+ });
95
+ this.requests.add(req);
96
+ req.once('close', () => this.requests.delete(req));
97
+ req.on('error', reject);
98
+ req.setTimeout(30000, () => req.destroy(new Error('relay request timed out')));
99
+ req.end(encoded);
100
+ });
101
+ }
102
+
103
+ seal(kind, data, target) {
104
+ const id = crypto.randomUUID();
105
+ const iv = crypto.randomBytes(12);
106
+ const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv);
107
+ cipher.setAAD(Buffer.from(`${this.roomId}:${id}`));
108
+ const plaintext = JSON.stringify({ v: 1, from: this.uuid, stream: this.streamID, ts: Date.now(), kind, target: target || null, data });
109
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
110
+ return { id, box: Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64') };
111
+ }
112
+
113
+ open(event) {
114
+ if (!event || typeof event.id !== 'string' || typeof event.box !== 'string') throw new Error('invalid relay event');
115
+ const box = Buffer.from(event.box, 'base64');
116
+ if (box.length < 29 || box.length > 768 * 1024) throw new Error('invalid relay ciphertext');
117
+ const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, box.subarray(0, 12));
118
+ decipher.setAuthTag(box.subarray(12, 28));
119
+ decipher.setAAD(Buffer.from(`${this.roomId}:${event.id}`));
120
+ const data = JSON.parse(Buffer.concat([decipher.update(box.subarray(28)), decipher.final()]).toString('utf8'));
121
+ if (data.v !== 1 || typeof data.from !== 'string' || !/^[a-f0-9-]{36}$/.test(data.from) ||
122
+ typeof data.stream !== 'string' || data.stream.length > 256 || !Number.isFinite(data.ts) ||
123
+ data.ts < Date.now() - 300000 || data.ts > Date.now() + 60000 ||
124
+ !['hello', 'data', 'binary'].includes(data.kind) ||
125
+ (data.target !== null && typeof data.target !== 'string')) throw new Error('invalid relay message');
126
+ return data;
127
+ }
128
+
129
+ async connect() {
130
+ const result = await this.request('GET');
131
+ if (this.closed) return;
132
+ if (!result || typeof result.cursor !== 'string') throw new Error('invalid relay cursor');
133
+ this.cursor = result.cursor;
134
+ this.connected = true;
135
+ this.event('connected', { transport: 'http' });
136
+ }
137
+
138
+ async joinRoom() {}
139
+
140
+ async announce(options) {
141
+ if (typeof options.streamID !== 'string' || !options.streamID || options.streamID.length > 256) {
142
+ throw new Error('HTTP stream_id requires 1 to 256 characters');
143
+ }
144
+ this.streamID = options.streamID;
145
+ await this.request('POST', '', this.seal('hello', { request_reply: true }));
146
+ if (this.closed) return;
147
+ this.heartbeat = setInterval(() => {
148
+ if (this.connected) this.enqueue(this.seal('hello', null));
149
+ for (const [uuid, peer] of this.peers) {
150
+ if (Date.now() - peer.seen > 45000) {
151
+ this.peers.delete(uuid);
152
+ this.event('peerDisconnected', { uuid, streamID: peer.streamID });
153
+ }
154
+ }
155
+ }, 10000);
156
+ this.readLoop().catch(() => { if (!this.closed) this.event('error', { error: 'relay reader stopped' }); });
157
+ }
158
+
159
+ // Every holder of the room key participates. The existing bridge applies peer
160
+ // allowlists and join-token policy before delivering application messages.
161
+ async view() {}
162
+
163
+ hasOpenDataChannel(uuid) { return this.connected && (uuid ? this.peers.has(uuid) : this.peers.size > 0); }
164
+ sendPing() { return this.connected; }
165
+
166
+ sendData(data, target) {
167
+ const uuid = typeof target === 'string' ? target : target && target.uuid;
168
+ if (!this.hasOpenDataChannel(uuid)) return false;
169
+ if (target && typeof target === 'object' && !uuid) return false;
170
+ const binary = Buffer.isBuffer(data) || data instanceof ArrayBuffer;
171
+ const event = this.seal(binary ? 'binary' : 'data', binary ? Buffer.from(data).toString('base64') : data, uuid);
172
+ const ok = this.enqueue(event, !(data && data.__vdo_mcp));
173
+ this.lastMessageId = ok ? event.id : null;
174
+ return ok;
175
+ }
176
+
177
+ enqueue(event, receipt = false) {
178
+ const bytes = Buffer.byteLength(JSON.stringify(event));
179
+ if (this.closed || bytes > 768 * 1024 || this.pending.length >= 128 || this.pendingBytes + bytes > 8 * 1024 * 1024) return false;
180
+ this.pending.push({ event, bytes, receipt });
181
+ this.pendingBytes += bytes;
182
+ this.flush().catch(() => { if (!this.closed) this.event('error', { error: 'relay sender stopped' }); });
183
+ return true;
184
+ }
185
+
186
+ async flush() {
187
+ if (this.sending) return;
188
+ this.sending = true;
189
+ try {
190
+ while (!this.closed && this.pending.length) {
191
+ const item = this.pending[0];
192
+ let accepted = false;
193
+ for (let attempt = 0; attempt < 3 && !this.closed; attempt += 1) {
194
+ try {
195
+ const result = await this.request('POST', '', item.event);
196
+ if (!result || result.accepted !== true) throw new Error('invalid relay receipt');
197
+ accepted = true;
198
+ break;
199
+ } catch (error) {
200
+ if (error.status && error.status < 500) break;
201
+ }
202
+ }
203
+ if (this.closed) break;
204
+ this.pending.shift();
205
+ this.pendingBytes -= item.bytes;
206
+ if (item.receipt) this.event('relayReceipt', { message_id: item.event.id, stage: accepted ? 'relay_accepted' : 'delivery_unknown' });
207
+ if (!accepted) this.event('error', { error: 'relay send failed; delivery unknown', message_id: item.event.id });
208
+ }
209
+ } finally { this.sending = false; }
210
+ }
211
+
212
+ receive(event) {
213
+ if (this.seen.has(event.id)) return;
214
+ let message;
215
+ try { message = this.open(event); } catch (_) {
216
+ this.event('relayRejected', { reason: 'invalid_encrypted_message' });
217
+ return;
218
+ }
219
+ this.seen.set(event.id, Date.now());
220
+ while (this.seen.size > 4096) this.seen.delete(this.seen.keys().next().value);
221
+ if (message.from === this.uuid) return;
222
+ let peer = this.peers.get(message.from);
223
+ if (peer && peer.streamID !== message.stream) return;
224
+ if (!peer) {
225
+ if (message.kind !== 'hello' || this.peers.size >= 128) return;
226
+ peer = { streamID: message.stream, seen: Date.now() };
227
+ this.peers.set(message.from, peer);
228
+ // Reply once to discovery; repeated heartbeats do not create a reply loop.
229
+ this.enqueue(this.seal('hello', null));
230
+ this.event('peerConnected', { uuid: message.from, streamID: message.stream });
231
+ this.event('dataChannelOpen', { uuid: message.from, streamID: message.stream, transport: 'http' });
232
+ } else if (message.kind === 'hello' && message.data && message.data.request_reply === true) {
233
+ this.enqueue(this.seal('hello', null));
234
+ }
235
+ peer.seen = Date.now();
236
+ if (message.target && message.target !== this.uuid) return;
237
+ if (message.kind === 'data' || (message.kind === 'binary' && typeof message.data === 'string')) {
238
+ this.event('dataReceived', {
239
+ uuid: message.from, streamID: message.stream, fallback: true, message_id: event.id,
240
+ data: message.kind === 'binary' ? Buffer.from(message.data, 'base64') : message.data
241
+ });
242
+ }
243
+ }
244
+
245
+ async readLoop() {
246
+ let failures = 0;
247
+ while (!this.closed) {
248
+ try {
249
+ const result = await this.request('GET', `?after=${encodeURIComponent(this.cursor)}&wait_ms=25000`);
250
+ if (this.closed) return;
251
+ if (!result || typeof result.cursor !== 'string' || !Array.isArray(result.events)) throw new Error('invalid relay events');
252
+ if (!this.connected) this.event('relayConnection', { connected: true });
253
+ this.connected = true;
254
+ if (failures) this.enqueue(this.seal('hello', { request_reply: true }));
255
+ failures = 0;
256
+ for (const event of result.events) this.receive(event);
257
+ this.cursor = result.cursor;
258
+ } catch (error) {
259
+ if (this.closed) return;
260
+ if (this.connected) this.event('relayConnection', { connected: false });
261
+ this.connected = false;
262
+ failures += 1;
263
+ const response = error.response;
264
+ if (error.status === 409 && response && response.error === 'relay_history_lost' && typeof response.cursor === 'string') {
265
+ this.cursor = response.cursor;
266
+ for (const uuid of this.peers.keys()) this.event('peerDisconnected', { uuid });
267
+ this.peers.clear();
268
+ this.event('relayGap', { reason: 'relay_history_lost' });
269
+ this.enqueue(this.seal('hello', { request_reply: true }));
270
+ } else if (failures === 1) this.event('error', { error: error.message });
271
+ await new Promise((resolve) => {
272
+ this.retryResolve = resolve;
273
+ this.retryTimer = setTimeout(resolve, Math.min(1000 * failures, 10000));
274
+ });
275
+ this.retryResolve = null;
276
+ this.retryTimer = null;
277
+ }
278
+ }
279
+ }
280
+
281
+ disconnect() {
282
+ this.closed = true;
283
+ this.connected = false;
284
+ clearInterval(this.heartbeat);
285
+ clearTimeout(this.retryTimer);
286
+ if (this.retryResolve) this.retryResolve();
287
+ for (const req of this.requests) req.destroy(new Error('relay transport stopped'));
288
+ this.pending = [];
289
+ this.pendingBytes = 0;
290
+ this.peers.clear();
291
+ }
292
+ }
293
+
294
+ module.exports = { HttpRelayTransport, validateRelayConfig };
@@ -1,3 +1,6 @@
1
+ // Copyright (c) 2026 Steve Seguin
2
+ // SPDX-License-Identifier: MIT
3
+
1
4
  'use strict';
2
5
 
3
6
  function loadVDONinjaSDK() {
@@ -1,3 +1,6 @@
1
+ // Copyright (c) 2026 Steve Seguin
2
+ // SPDX-License-Identifier: MIT
3
+
1
4
  'use strict';
2
5
 
3
6
  const EventEmitter = require('node:events');