Files
bitgate/packages/runtime/src/relayTransport.js
T
thePR0M3TH3AN adeba151b8 feat: rename to BitGate and close the drop-in gap
Renames the project and makes it as easy to adopt as BitLogin, which was the
stated bar and which it did not previously meet.

Rename (ADR 0005): @nostr-governance/* -> @bitgate/*, governance-* elements ->
bitgate-*, createGovernanceRuntime -> createBitGate. "Governance" reads as DAO
voting to this audience and named a category rather than a product; BitGate
names the benefit and joins the bit- family that BitVid and BitRoad compose
with. Domain types keep their names — the product is BitGate, the subject
matter is still governance. The migration plan is left as written; this ADR
records the change rather than rewriting history.

Drop-in distribution (ADR 0006): adopting BitGate meant supplying a transport,
authoring a policy, and wiring every element in JavaScript. Now:

- createRelayTransport(urls) — WebSocket transport with reconnection, backoff,
  subscription replay, and cross-relay dedupe
- policy presets (social, commerce, admin-only) addressable by name; the engine
  still carries no thresholds of its own
- <bitgate-provider> — configuration in markup, with descendants finding the
  runtime through a bubbling context event rather than DOM walking, which
  breaks across shadow boundaries
- npm run build:widget — a self-contained dist/bitgate.js

That last one came from running it in a browser rather than from a test. Bare
specifiers resolve in Node and vitest but not in a browser, so the widget
source worked everywhere except a plain static page — the one place it exists
to serve. The bundle is not committed: consumers build from reviewed source.

Also found by running it: the provider's ready event fires during element
upgrade, before a page that defines elements after parsing can attach a
listener, so it now exposes `ready` as a promise too.

Four bugs fixed along the way, three found by new tests and one by the browser:
admin state stayed silent when a roster change altered capabilities but no
denials; the relay transport sent every subscription twice on connect; the
commerce preset computed a meaningless visibility verdict at checkout and could
hide a listing from its own seller; <bitgate-status> threw on an unusable
decision.

759 tests; lint, typecheck, and both builds clean.
2026-08-09 19:58:45 -04:00

409 lines
12 KiB
JavaScript

// WebSocket relay transport.
//
// The runtime depends on no particular relay pool, and an application with one
// should keep using it. This exists so that nobody has to write a transport
// just to get started: it is the difference between "supply an adapter" and
// "pass some relay URLs".
//
// Deliberately small. It speaks enough of the Nostr protocol to load governance
// state, subscribe to reports and mute lists, and publish administrative
// events. It is not a general-purpose relay client and does not try to be.
/**
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
* @typedef {import('./interfaces.js').GovernanceTransport} GovernanceTransport
* @typedef {import('./interfaces.js').SubscriptionHandlers} SubscriptionHandlers
*/
/** Milliseconds to wait for a relay's stored events before resolving `list`. */
const DEFAULT_LIST_TIMEOUT = 8_000;
/** Milliseconds between reconnection attempts, before backoff. */
const DEFAULT_RECONNECT_DELAY = 1_000;
/** Ceiling for reconnect backoff. */
const MAX_RECONNECT_DELAY = 30_000;
let subscriptionCounter = 0;
/**
* Create a transport backed by plain WebSockets.
*
* @param {string[]} urls - Relay URLs (`wss://…`)
* @param {Object} [options]
* @param {typeof WebSocket} [options.WebSocketImpl] - Injected for tests or Node
* @param {number} [options.listTimeout] - Milliseconds to wait for stored events
* @param {number} [options.reconnectDelay] - Initial reconnect delay
* @param {(message: string, detail?: unknown) => void} [options.onDiagnostic]
* @returns {GovernanceTransport & { close: () => void, relays: string[] }}
*/
export function createRelayTransport(urls, options = {}) {
const relays = (Array.isArray(urls) ? urls : [])
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
if (relays.length === 0) {
throw new Error("createRelayTransport requires at least one relay URL");
}
const SocketImpl = options.WebSocketImpl ?? globalThis.WebSocket;
if (typeof SocketImpl !== "function") {
throw new Error("No WebSocket implementation available; pass options.WebSocketImpl");
}
const listTimeout = options.listTimeout ?? DEFAULT_LIST_TIMEOUT;
const baseReconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
const diagnostic = options.onDiagnostic ?? (() => {});
/** @type {Map<string, { socket: any, ready: boolean, queue: string[], attempts: number, timer: any }>} */
const connections = new Map();
// OK frames answer a publish, not a subscription, so they are dispatched
// through their own listener set. Keeping them here rather than rebinding a
// socket's onmessage avoids stacking wrappers across repeated publishes.
/** @type {Set<(url: string, frame: any[]) => void>} */
const okListeners = new Set();
/** @type {Map<string, { filters: object[], handlers: SubscriptionHandlers, relays: Set<string> }>} */
const subscriptions = new Map();
let closed = false;
/**
* @param {string} url
* @param {string} payload
*/
function send(url, payload) {
const connection = connections.get(url);
if (!connection) {
return;
}
if (connection.ready) {
try {
connection.socket.send(payload);
} catch (error) {
diagnostic("relay send failed", { url, error: String(error) });
}
return;
}
// REQ frames are never queued: every live subscription is re-issued from
// the subscription map when the socket opens, so queueing one too would
// send it twice. Everything else (EVENT, CLOSE) is queued rather than
// dropped, since nothing else would re-send it.
if (!payload.startsWith('["REQ"')) {
connection.queue.push(payload);
}
}
/** @param {string} url */
function connect(url) {
if (closed) {
return;
}
/** @type {any} */
const existing = connections.get(url) ?? { socket: null, ready: false, queue: [], attempts: 0, timer: null };
connections.set(url, existing);
let socket;
try {
socket = new SocketImpl(url);
} catch (error) {
diagnostic("relay connect threw", { url, error: String(error) });
scheduleReconnect(url);
return;
}
existing.socket = socket;
existing.ready = false;
socket.onopen = () => {
existing.ready = true;
existing.attempts = 0;
// Re-open every live subscription: a reconnect that dropped them would
// leave the UI silently stale rather than visibly broken.
for (const [id, subscription] of subscriptions) {
subscription.relays.add(url);
send(url, JSON.stringify(["REQ", id, ...subscription.filters]));
}
const queued = existing.queue.splice(0);
for (const payload of queued) {
send(url, payload);
}
};
socket.onmessage = (message) => {
let frame;
try {
frame = JSON.parse(typeof message?.data === "string" ? message.data : "");
} catch {
return;
}
if (!Array.isArray(frame)) {
return;
}
const [verb, id, payload] = frame;
if (verb === "OK") {
for (const listener of Array.from(okListeners)) {
listener(url, frame);
}
return;
}
if (verb === "NOTICE") {
diagnostic("relay notice", { url, message: id });
return;
}
const subscription = subscriptions.get(id);
if (!subscription) {
return;
}
if (verb === "EVENT" && payload) {
subscription.handlers.onEvent?.(payload);
} else if (verb === "EOSE") {
subscription.handlers.onEose?.();
} else if (verb === "CLOSED") {
diagnostic("relay closed subscription", { url, id, reason: payload });
}
};
socket.onerror = (error) => {
diagnostic("relay socket error", { url, error: String(error?.message ?? error) });
};
socket.onclose = () => {
existing.ready = false;
for (const subscription of subscriptions.values()) {
subscription.relays.delete(url);
}
scheduleReconnect(url);
};
}
/** @param {string} url */
function scheduleReconnect(url) {
if (closed) {
return;
}
const connection = connections.get(url);
if (!connection || connection.timer) {
return;
}
connection.attempts += 1;
const delay = Math.min(baseReconnectDelay * 2 ** (connection.attempts - 1), MAX_RECONNECT_DELAY);
connection.timer = setTimeout(() => {
connection.timer = null;
connect(url);
}, delay);
// Never let a reconnect timer hold a Node process open.
connection.timer?.unref?.();
}
for (const url of relays) {
connect(url);
}
return {
relays,
/**
* Collect stored events matching the filters.
*
* Resolves on EOSE from every relay, or when the timeout elapses —
* whichever comes first. A single slow relay must not stall a page load,
* and results already received are returned rather than discarded.
*
* @param {object[]} filters
* @param {{ timeout?: number }} [listOptions]
* @returns {Promise<NostrEvent[]>}
*/
async list(filters, listOptions = {}) {
const id = `bg-list-${(subscriptionCounter += 1)}`;
/** @type {Map<string, NostrEvent>} */
const collected = new Map();
return new Promise((resolve) => {
let settled = false;
let pendingEose = relays.length;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
subscriptions.delete(id);
for (const url of relays) {
send(url, JSON.stringify(["CLOSE", id]));
}
resolve([...collected.values()]);
};
const timer = setTimeout(finish, listOptions.timeout ?? listTimeout);
timer?.unref?.();
subscriptions.set(id, {
filters,
relays: new Set(),
handlers: {
onEvent: (event) => {
// Relays overlap heavily; dedupe by id so a caller does not have to.
if (event?.id && !collected.has(event.id)) {
collected.set(event.id, event);
}
},
onEose: () => {
pendingEose -= 1;
if (pendingEose <= 0) {
finish();
}
},
},
});
for (const url of relays) {
send(url, JSON.stringify(["REQ", id, ...filters]));
}
});
},
/**
* Open a live subscription across every relay.
* @param {object[]} filters
* @param {SubscriptionHandlers} handlers
* @returns {{ close: () => void }}
*/
subscribe(filters, handlers) {
const id = `bg-sub-${(subscriptionCounter += 1)}`;
/** @type {Set<string>} */
const seen = new Set();
subscriptions.set(id, {
filters,
relays: new Set(),
handlers: {
onEvent: (event) => {
if (!event?.id || seen.has(event.id)) {
return;
}
seen.add(event.id);
handlers.onEvent?.(event);
},
onEose: handlers.onEose,
onError: handlers.onError,
},
});
const payload = JSON.stringify(["REQ", id, ...filters]);
for (const url of relays) {
send(url, payload);
}
return {
close() {
subscriptions.delete(id);
const closePayload = JSON.stringify(["CLOSE", id]);
for (const url of relays) {
send(url, closePayload);
}
},
};
},
/**
* Publish an event to every relay.
*
* Resolves once every relay has answered or the timeout elapses. Partial
* acceptance is success: one relay accepting means the event exists on the
* network, and the failures stay on the result for diagnostics.
*
* @param {NostrEvent} event
* @param {{ timeout?: number }} [publishOptions]
* @returns {Promise<import('./interfaces.js').PublishResult>}
*/
async publish(event, publishOptions = {}) {
/** @type {string[]} */
const accepted = [];
/** @type {Array<{ relay: string, error: string }>} */
const failed = [];
return new Promise((resolve) => {
let settled = false;
const answered = new Set();
/** @param {string} url @param {any[]} frame */
const listener = (url, frame) => {
if (frame[1] !== event.id || answered.has(url)) {
return;
}
answered.add(url);
if (frame[2] === true) {
accepted.push(url);
} else {
failed.push({ relay: url, error: String(frame[3] ?? "rejected") });
}
if (answered.size >= relays.length) {
finish();
}
};
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
okListeners.delete(listener);
// Relays that never answered are reported as timed out rather than
// silently omitted, so diagnostics show the full picture.
for (const url of relays) {
if (!answered.has(url)) {
failed.push({ relay: url, error: "timeout" });
}
}
resolve({ ok: accepted.length > 0, accepted, failed });
};
const timer = setTimeout(finish, publishOptions.timeout ?? listTimeout);
timer?.unref?.();
okListeners.add(listener);
send(relays[0], JSON.stringify(["EVENT", event]));
for (const url of relays.slice(1)) {
send(url, JSON.stringify(["EVENT", event]));
}
});
},
/** Close every socket and stop reconnecting. */
close() {
closed = true;
subscriptions.clear();
for (const connection of connections.values()) {
if (connection.timer) {
clearTimeout(connection.timer);
connection.timer = null;
}
try {
connection.socket?.close?.();
} catch {
// Closing an already-dead socket is not an error worth surfacing.
}
}
connections.clear();
},
};
}