diff --git a/README.md b/README.md
index b6c1b613..1b2ecc27 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Tree CRDT workspace targeting SQLite/wa-sqlite + WASM bindings with a shared Typ
- `packages/treecrdt-sqlite-node`: TreeCRDT bundled for Node.js use
- `packages/treecrdt-wa-sqlite`: TreeCRDT bunlded for browser use
- `packages/treecrdt-benchmark`: Benchmark utilities
+- `packages/discovery`: bootstrap contract for resolving docs to attachment plans
- `packages/sync/protocol`: sync protocol/runtime core
- `packages/sync/material/sqlite`: SQLite-backed sync adapters and proof-material stores
- `packages/sync/material/postgres`: Postgres-backed sync proof-material stores
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 34d7527f..6fff4caf 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -31,6 +31,8 @@ flowchart TD
%% TypeScript packages (pnpm workspace)
subgraph TS["TypeScript packages (pnpm workspace)"]
iface["@treecrdt/interface"]
+ discovery["@treecrdt/discovery"]
+ discovery_server["@treecrdt/discovery-server-node"]
sync_core["@treecrdt/sync"]
sync_sqlite["@treecrdt/sync-sqlite"]
sync_postgres["@treecrdt/sync-postgres"]
@@ -49,6 +51,7 @@ flowchart TD
%% Runtime dependencies
sync_core --> iface
+ discovery_server --> discovery
sync_core --> riblt_pkg
sync_sqlite --> sync_core
sync_sqlite --> iface
diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md
index 3db3cec1..efa6a855 100644
--- a/docs/BENCHMARKS.md
+++ b/docs/BENCHMARKS.md
@@ -23,6 +23,7 @@ pnpm benchmark:sync:direct
pnpm benchmark:sync:local
pnpm benchmark:sync:prime
pnpm benchmark:sync:remote
+pnpm benchmark:sync:bootstrap
pnpm benchmark:web
pnpm benchmark:wasm
pnpm benchmark:postgres
@@ -36,6 +37,7 @@ pnpm benchmark:postgres
- First view on a new device, with payloads: `benchmark:sync:*` with `sync-balanced-children-payloads-cold-start`
- Re-sync the same subtree on a restarted client that already has that scope locally: `benchmark:sync:*` with `sync-balanced-children-resync` or `sync-balanced-children-payloads-resync`
- Single end-to-end time-to-first-visible-page number: `benchmark:sync:*` with the same balanced workloads plus `--first-view`
+- One-time bootstrap/discovery tax before opening the regional websocket: `benchmark:sync:bootstrap`
- Local render cost after the data is already present: `benchmark:sqlite-node:note-paths -- --benches=read-children-payloads`
- Local mutation cost inside a large existing tree: `benchmark:sqlite-node:note-paths -- --benches=insert-into-large-tree`
- Protocol/storage baselines and worst-case stress: `sync-one-missing`, `sync-all`, `sync-children*`, `sync-root-children-fanout10`
@@ -136,6 +138,8 @@ pnpm benchmark:sync:remote -- \
--server-fixture-cache=reuse
```
+For remote targets, `prime` now records the exact fixture doc ID locally under `tmp/sqlite-node-sync-bench/server-fixtures/`. That means a fresh endpoint can be primed once with `--server-fixture-cache=rebuild`, and later `--server-fixture-cache=reuse` runs on the same machine can reopen that exact remote fixture doc instead of relying on historical deterministic fixture residue.
+
By default, the local sync target runs the Postgres sync server in a spawned child process so local and remote measurements are closer to each other. When you add `--profile-backend`, the local target intentionally switches to the in-process server so per-backend timings are visible inside the benchmark process.
Local server benchmarks now seed the Postgres backend directly before the timer starts. That keeps the measured path honest, because the actual sync to the client still goes through the real websocket server, while avoiding huge protocol-seed setup costs that are not part of the benchmark question.
@@ -213,6 +217,33 @@ pnpm benchmark:sync:remote -- \
--max-ops-per-batch=500
```
+### Bootstrap / Resolve Bench
+
+Use `benchmark:sync:bootstrap` when you want to isolate the one-time discovery
+layer from the steady-state sync path.
+
+The benchmark target can be a standalone bootstrap server such as
+`@treecrdt/discovery-server-node`, not just a colocated sync-server route.
+
+It measures:
+
+- `resolveSamplesMs`: `GET /resolve-doc?docId=...`
+- `connectSamplesMs`: first websocket open after resolve
+- `totalSamplesMs`: resolve + first websocket open
+- `cachedConnectSamplesMs`: direct websocket reconnect using the already resolved attachment
+
+```sh
+TREECRDT_DISCOVERY_URL=https://bootstrap-host \
+pnpm benchmark:sync:bootstrap -- \
+ --iterations=5
+```
+
+This is the benchmark to use when you want to answer:
+
+- how expensive the bootstrap lookup is on cold open
+- how much faster cached reconnects are
+- whether discovery is staying off the steady-state hot path
+
### Backend Call Profiling
Add `--profile-backend` when you want per-backend timings for:
diff --git a/examples/playground/README.md b/examples/playground/README.md
index 25b0c2f8..7e5cbf0e 100644
--- a/examples/playground/README.md
+++ b/examples/playground/README.md
@@ -3,6 +3,7 @@
A small, self-contained demo that exercises the `@treecrdt/wa-sqlite` adapter inside a Vite + React + Tailwind UI. It runs the TreeCRDT SQLite extension in wa-sqlite and lets you insert, move, and delete nodes in an expandable tree while watching the underlying operation log.
## Features
+
- Insert children under any node, reorder siblings (up/down), move nodes back to the root, or delete them (root is protected).
- Collapsible tree with per-node controls and a composer form to target any parent.
- Live CRDT operation log with lamport/counter metadata.
@@ -11,6 +12,7 @@ A small, self-contained demo that exercises the `@treecrdt/wa-sqlite` adapter in
- Optional auth/ACL demo (COSE_Sign1 + CWT subtree capabilities) with invite links, per-op signatures, and a pending-op inspector.
## Running locally
+
```bash
pnpm install --filter @treecrdt/playground
pnpm -C examples/playground dev
@@ -27,6 +29,8 @@ pnpm build
pnpm sync-server:postgres:db:start
# Start the TreeCRDT sync server on ws://localhost:8787 using that Postgres DB.
pnpm sync-server:postgres:local
+# Start the standalone bootstrap server on http://localhost:8788.
+pnpm discovery-server:local
# Start the playground UI.
pnpm -C examples/playground dev
```
@@ -34,9 +38,25 @@ pnpm -C examples/playground dev
Then in the playground:
- Open the `Connections` panel
-- Paste `ws://localhost:8787` into `Remote sync server`
+- Paste `http://localhost:8788` into `Remote sync / bootstrap`
- Leave mode as `Hybrid`, or switch to `Remote server` if you want to disable local tab sync
+## Bootstrap endpoint
+
+If you want to test against a bootstrap endpoint instead of entering the
+websocket sync server directly:
+
+- Open the `Connections` panel
+- Paste the HTTPS bootstrap URL you want to test
+- Use `Hybrid` for browser-local tabs plus remote sync, or `Remote server` for remote-only behavior
+
+The playground will call `/resolve-doc` once, cache the returned websocket
+attachment, and then connect directly to the resolved `wss://.../sync`
+endpoint.
+
+If you want to skip bootstrap entirely, you can still paste a direct websocket
+endpoint such as `ws://localhost:8787`.
+
`pnpm sync-server:postgres:db:start` starts a disposable local Postgres at:
```bash
@@ -83,6 +103,7 @@ pnpm --filter @treecrdt/wa-sqlite-vendor rebuild
The example does not depend on the npm `wa-sqlite` package; it consumes the repo's git submodule build directly via the copy step above.
## Building / deploying to GitHub Pages
+
```bash
pnpm -C examples/playground build # outputs to dist/
pnpm -C examples/playground deploy # pushes dist/ via gh-pages
diff --git a/examples/playground/package.json b/examples/playground/package.json
index 06a351f4..508c825d 100644
--- a/examples/playground/package.json
+++ b/examples/playground/package.json
@@ -8,13 +8,14 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "vite build",
"preview": "vite preview",
- "test:e2e": "pnpm -C ../../packages/sync/protocol run build && pnpm -C ../../packages/sync/server/core run build && pnpm -C ../../packages/treecrdt-auth run build && pnpm -C ../../packages/treecrdt-wa-sqlite-vendor run build && pnpm -C ../../packages/treecrdt-wa-sqlite run build:ts && playwright test",
+ "test:e2e": "pnpm -C ../../packages/discovery run build && pnpm -C ../../packages/sync/protocol run build && pnpm -C ../../packages/sync/server/core run build && pnpm -C ../../packages/treecrdt-auth run build && pnpm -C ../../packages/treecrdt-wa-sqlite-vendor run build && pnpm -C ../../packages/treecrdt-wa-sqlite run build:ts && playwright test",
"deploy": "pnpm run build && gh-pages -d dist"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.13",
"@treecrdt/auth": "workspace:*",
"@treecrdt/crypto": "workspace:*",
+ "@treecrdt/discovery": "workspace:*",
"@treecrdt/interface": "workspace:*",
"@treecrdt/sync": "workspace:*",
"@treecrdt/sync-sqlite": "workspace:*",
diff --git a/examples/playground/src/playground/components/PeersPanel.tsx b/examples/playground/src/playground/components/PeersPanel.tsx
index f0251a2b..8637237b 100644
--- a/examples/playground/src/playground/components/PeersPanel.tsx
+++ b/examples/playground/src/playground/components/PeersPanel.tsx
@@ -1,43 +1,43 @@
-import React from "react";
-import { MdCheckCircle, MdCloudOff, MdCloudQueue, MdErrorOutline, MdSync } from "react-icons/md";
+import React from 'react';
+import { MdCheckCircle, MdCloudOff, MdCloudQueue, MdErrorOutline, MdSync } from 'react-icons/md';
-import type { PeerInfo, RemoteSyncStatus, SyncTransportMode } from "../types";
+import type { PeerInfo, RemoteSyncStatus, SyncTransportMode } from '../types';
function formatPeerId(id: string): string {
- if (id.startsWith("remote:")) return `remote(${id.slice("remote:".length)})`;
+ if (id.startsWith('remote:')) return `remote(${id.slice('remote:'.length)})`;
return id.length > 18 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id;
}
function transportModeButtonClass(active: boolean): string {
return active
- ? "border-accent bg-accent/15 text-white"
- : "border-slate-700 bg-slate-900/70 text-slate-300 hover:border-slate-500 hover:text-white";
+ ? 'border-accent bg-accent/15 text-white'
+ : 'border-slate-700 bg-slate-900/70 text-slate-300 hover:border-slate-500 hover:text-white';
}
function remoteStatusTone(status: RemoteSyncStatus): string {
switch (status.state) {
- case "connected":
- return "border-emerald-500/40 bg-emerald-500/10 text-emerald-100";
- case "connecting":
- return "border-sky-500/40 bg-sky-500/10 text-sky-100";
- case "disabled":
- return "border-slate-700 bg-slate-900/70 text-slate-400";
- case "missing_url":
- return "border-amber-500/30 bg-amber-500/10 text-amber-100";
- case "invalid":
- case "error":
- return "border-rose-500/40 bg-rose-500/10 text-rose-100";
+ case 'connected':
+ return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100';
+ case 'connecting':
+ return 'border-sky-500/40 bg-sky-500/10 text-sky-100';
+ case 'disabled':
+ return 'border-slate-700 bg-slate-900/70 text-slate-400';
+ case 'missing_url':
+ return 'border-amber-500/30 bg-amber-500/10 text-amber-100';
+ case 'invalid':
+ case 'error':
+ return 'border-rose-500/40 bg-rose-500/10 text-rose-100';
}
}
function RemoteStatusIcon({ status }: { status: RemoteSyncStatus }) {
- if (status.state === "connected") {
+ if (status.state === 'connected') {
return ;
}
- if (status.state === "connecting") {
+ if (status.state === 'connecting') {
return ;
}
- if (status.state === "disabled") {
+ if (status.state === 'disabled') {
return ;
}
return ;
@@ -62,7 +62,7 @@ export function PeersPanel({
remoteSyncStatus: RemoteSyncStatus;
peers: PeerInfo[];
}) {
- const requiresRemoteUrl = syncTransportMode !== "local";
+ const requiresRemoteUrl = syncTransportMode !== 'local';
const hasRemoteUrl = syncServerUrl.trim().length > 0;
return (
@@ -72,73 +72,86 @@ export function PeersPanel({
>
-
Connections
+
+ Connections
+
- Choose how this tab syncs. Local tabs use `BroadcastChannel`. Remote server uses a websocket sync endpoint.
+ Choose how this tab syncs. Local tabs use `BroadcastChannel`. Remote transport can use
+ either a direct websocket sync endpoint or a separate HTTP bootstrap endpoint.
setOnline((v) => !v)}
type="button"
- title={online ? "Pause sync activity" : "Resume sync activity"}
+ title={online ? 'Pause sync activity' : 'Resume sync activity'}
>
- {online ? : }
- {online ? "Sync enabled" : "Sync paused"}
+ {online ? (
+
+ ) : (
+
+ )}
+ {online ? 'Sync enabled' : 'Sync paused'}
-
Transport
+
+ Transport
+
setSyncTransportMode("local")}
+ className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === 'local')}`}
+ onClick={() => setSyncTransportMode('local')}
>
Local tabs
setSyncTransportMode("remote")}
+ className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === 'remote')}`}
+ onClick={() => setSyncTransportMode('remote')}
>
Remote server
setSyncTransportMode("hybrid")}
+ className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === 'hybrid')}`}
+ onClick={() => setSyncTransportMode('hybrid')}
>
Hybrid
- {syncTransportMode === "local" && "Only same-origin tabs in this browser will sync."}
- {syncTransportMode === "remote" && "Only the configured websocket sync server will be used."}
- {syncTransportMode === "hybrid" && "Use both same-origin tabs and the configured websocket sync server."}
+ {syncTransportMode === 'local' && 'Only same-origin tabs in this browser will sync.'}
+ {syncTransportMode === 'remote' &&
+ 'Only the configured remote websocket or bootstrap endpoint will be used.'}
+ {syncTransportMode === 'hybrid' &&
+ 'Use both same-origin tabs and the configured remote websocket or bootstrap endpoint.'}
-
Remote sync server
+
+ Remote sync / bootstrap
+
- {remoteSyncStatus.state === "connected" && "Connected"}
- {remoteSyncStatus.state === "connecting" && "Connecting"}
- {remoteSyncStatus.state === "disabled" && "Inactive"}
- {remoteSyncStatus.state === "missing_url" && "Missing URL"}
- {remoteSyncStatus.state === "invalid" && "Invalid URL"}
- {remoteSyncStatus.state === "error" && "Unreachable"}
+ {remoteSyncStatus.state === 'connected' && 'Connected'}
+ {remoteSyncStatus.state === 'connecting' && 'Connecting'}
+ {remoteSyncStatus.state === 'disabled' && 'Inactive'}
+ {remoteSyncStatus.state === 'missing_url' && 'Missing URL'}
+ {remoteSyncStatus.state === 'invalid' && 'Invalid URL'}
+ {remoteSyncStatus.state === 'error' && 'Unreachable'}
@@ -149,17 +162,17 @@ export function PeersPanel({
onChange={(event) => {
const next = event.target.value;
setSyncServerUrl(next);
- if (syncTransportMode === "local" && next.trim().length > 0) {
- setSyncTransportMode("hybrid");
+ if (syncTransportMode === 'local' && next.trim().length > 0) {
+ setSyncTransportMode('hybrid');
}
}}
- placeholder="ws://localhost:8787 or ws://localhost:8787/sync"
+ placeholder="https://bootstrap-host or ws://localhost:8787"
spellCheck={false}
/>
setSyncServerUrl("")}
+ onClick={() => setSyncServerUrl('')}
disabled={syncServerUrl.trim().length === 0}
title="Clear remote sync server URL"
>
@@ -174,14 +187,18 @@ export function PeersPanel({
-
Connected peers
+
+ Connected peers
+
{peers.length}
{peers.map((p) => (
{formatPeerId(p.id)}
- {Math.max(0, Date.now() - p.lastSeen)}ms
+
+ {Math.max(0, Date.now() - p.lastSeen)}ms
+
))}
{peers.length === 0 && (
diff --git a/examples/playground/src/playground/hooks/usePlaygroundSync.ts b/examples/playground/src/playground/hooks/usePlaygroundSync.ts
index 96c0c55a..61b3525d 100644
--- a/examples/playground/src/playground/hooks/usePlaygroundSync.ts
+++ b/examples/playground/src/playground/hooks/usePlaygroundSync.ts
@@ -1,43 +1,54 @@
-import { useEffect, useRef, useState } from "react";
-import type { Operation } from "@treecrdt/interface";
-import { bytesToHex } from "@treecrdt/interface/ids";
+import { useEffect, useRef, useState } from 'react';
+import type { Operation } from '@treecrdt/interface';
+import { bytesToHex } from '@treecrdt/interface/ids';
import {
base64urlDecode,
createTreecrdtCoseCwtAuth,
createTreecrdtIdentityChainCapabilityV1,
createTreecrdtSqliteSubtreeScopeEvaluator,
type TreecrdtIdentityChainV1,
-} from "@treecrdt/auth";
+} from '@treecrdt/auth';
+import {
+ createStringStoreRouteCache,
+ isDiscoveryBootstrapUrl,
+ normalizeDirectSyncWebSocketUrl,
+ resolveWebSocketAttachment,
+ type DiscoveryRouteCache,
+ type ResolveWebSocketAttachmentResult,
+} from '@treecrdt/discovery';
import {
SyncPeer,
deriveOpRefV0,
type Filter,
type SyncSubscription,
-} from "@treecrdt/sync";
+} from '@treecrdt/sync';
import {
createTreecrdtSyncBackendFromClient,
createCapabilityMaterialStore,
createOpAuthStore,
-} from "@treecrdt/sync-sqlite";
-import type { BroadcastPresenceAckMessageV1, BroadcastPresenceMessageV1 } from "@treecrdt/sync/browser";
-import { createBroadcastPresenceMesh, createBrowserWebSocketTransport } from "@treecrdt/sync/browser";
-import { treecrdtSyncV0ProtobufCodec } from "@treecrdt/sync/protobuf";
-import { wrapDuplexTransportWithCodec, type DuplexTransport } from "@treecrdt/sync/transport";
-import type { TreecrdtClient } from "@treecrdt/wa-sqlite/client";
-
+} from '@treecrdt/sync-sqlite';
+import type {
+ BroadcastPresenceAckMessageV1,
+ BroadcastPresenceMessageV1,
+} from '@treecrdt/sync/browser';
import {
- hexToBytes16,
- type AuthGrantMessageV1,
-} from "../../sync-v0";
+ createBroadcastPresenceMesh,
+ createBrowserWebSocketTransport,
+} from '@treecrdt/sync/browser';
+import { treecrdtSyncV0ProtobufCodec } from '@treecrdt/sync/protobuf';
+import { wrapDuplexTransportWithCodec, type DuplexTransport } from '@treecrdt/sync/transport';
+import type { TreecrdtClient } from '@treecrdt/wa-sqlite/client';
+
+import { hexToBytes16, type AuthGrantMessageV1 } from '../../sync-v0';
import {
PLAYGROUND_PEER_TIMEOUT_MS,
PLAYGROUND_REMOTE_SYNC_TIMEOUT_MS,
PLAYGROUND_SYNC_MAX_CODEWORDS,
PLAYGROUND_SYNC_MAX_OPS_PER_BATCH,
ROOT_ID,
-} from "../constants";
-import type { PeerInfo, RemoteSyncStatus, SyncTransportMode, TreeState } from "../types";
-import type { StoredAuthMaterial } from "../../auth";
+} from '../constants';
+import type { PeerInfo, RemoteSyncStatus, SyncTransportMode, TreeState } from '../types';
+import type { StoredAuthMaterial } from '../../auth';
const REMOTE_SYNC_CODEWORDS_PER_MESSAGE = 512;
@@ -52,27 +63,37 @@ function withTimeout
(promise: Promise, ms: number, message: string): Promi
(err) => {
clearTimeout(timer);
reject(err);
- }
+ },
);
});
}
function normalizeSyncServerUrl(raw: string, docId: string): URL {
- let input = raw.trim();
- if (input.length === 0) throw new Error("Sync server URL is empty");
- if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(input)) input = `ws://${input}`;
+ return normalizeDirectSyncWebSocketUrl(raw, docId);
+}
- const url = new URL(input);
- if (url.protocol === "http:") url.protocol = "ws:";
- if (url.protocol === "https:") url.protocol = "wss:";
- if (url.protocol !== "ws:" && url.protocol !== "wss:") {
- throw new Error("Sync server URL must use ws://, wss://, http://, or https://");
+let browserDiscoveryRouteCache: DiscoveryRouteCache | null | undefined;
+
+function getBrowserDiscoveryRouteCache(): DiscoveryRouteCache | undefined {
+ if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') return undefined;
+ if (browserDiscoveryRouteCache === undefined) {
+ browserDiscoveryRouteCache = createStringStoreRouteCache(
+ window.localStorage,
+ 'treecrdt.playground.discovery.',
+ );
}
- if (url.pathname === "/" || url.pathname.length === 0) {
- url.pathname = "/sync";
+ return browserDiscoveryRouteCache ?? undefined;
+}
+
+function previewDiscoveryHost(raw: string): string {
+ let input = raw.trim();
+ if (input.length === 0) throw new Error('Sync server URL is empty');
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(input)) input = `https://${input}`;
+ const url = new URL(input);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error('Discovery endpoint must use http:// or https://');
}
- url.searchParams.set("docId", docId);
- return url;
+ return url.host;
}
function errorMessage(err: unknown): string {
@@ -85,14 +106,62 @@ function isCapabilityRevokedError(err: unknown): boolean {
function formatSyncError(err: unknown): string {
if (isCapabilityRevokedError(err)) {
- return "Access revoked for this capability. Import/update access, then sync again.";
+ return 'Access revoked for this capability. Import/update access, then sync again.';
}
if (/unknown author:/i.test(errorMessage(err))) {
- return "This document contains ops from an author whose capability token is not available here yet. Sync from a peer that has the full author history, or try a fresh doc.";
+ return 'This document contains ops from an author whose capability token is not available here yet. Sync from a peer that has the full author history, or try a fresh doc.';
}
return errorMessage(err);
}
+function isTransientRemoteConnectError(message: string | null): boolean {
+ if (!message) return false;
+ return (
+ message === 'Failed to fetch' ||
+ message === 'Load failed' ||
+ message === 'Network request failed' ||
+ message.startsWith('Remote sync socket error (')
+ );
+}
+
+function formatRemoteConnectedDetail(host: string): string {
+ return `Connected to ${host}`;
+}
+
+function formatRemoteRouteDetail(
+ host: string,
+ opts: {
+ bootstrapHost?: string;
+ } = {},
+): string {
+ const base = formatRemoteConnectedDetail(host);
+ if (!opts.bootstrapHost || opts.bootstrapHost === host) return base;
+ return `${base} via ${opts.bootstrapHost}`;
+}
+
+function formatRemoteConnectDetail(verb: string, host: string, bootstrapHost?: string): string {
+ if (!bootstrapHost || bootstrapHost === host) {
+ return `${verb} ${host}...`;
+ }
+ return `${verb} ${host} via ${bootstrapHost}...`;
+}
+
+function formatRemoteErrorDetail(
+ kind: 'disconnected' | 'could_not_connect' | 'connection_error' | 'could_not_reach',
+ host: string,
+ bootstrapHost?: string,
+): string {
+ const base =
+ kind === 'disconnected'
+ ? `Disconnected from ${host}`
+ : kind === 'could_not_connect'
+ ? `Could not connect to ${host}`
+ : kind === 'connection_error'
+ ? `Connection error talking to ${host}`
+ : `Could not reach ${host}`;
+ if (!bootstrapHost || bootstrapHost === host) return base;
+ return `${base} via ${bootstrapHost}`;
+}
export type PlaygroundSyncApi = {
peers: PeerInfo[];
remoteSyncStatus: RemoteSyncStatus;
@@ -109,13 +178,13 @@ export type PlaygroundSyncApi = {
handleSync: (filter: Filter) => Promise;
handleScopedSync: () => Promise;
postBroadcastMessage: (
- msg: BroadcastPresenceMessageV1 | BroadcastPresenceAckMessageV1 | AuthGrantMessageV1
+ msg: BroadcastPresenceMessageV1 | BroadcastPresenceAckMessageV1 | AuthGrantMessageV1,
) => boolean;
};
export type UsePlaygroundSyncOptions = {
client: TreecrdtClient | null;
- status: "booting" | "ready" | "error";
+ status: 'booting' | 'ready' | 'error';
docId: string;
selfPeerId: string | null;
autoSyncJoin?: boolean;
@@ -154,8 +223,8 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
docId,
selfPeerId,
autoSyncJoin = false,
- syncServerUrl = "",
- transportMode = "local",
+ syncServerUrl = '',
+ transportMode = 'local',
online,
getMaxLamport,
authEnabled,
@@ -183,8 +252,8 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
const [syncError, setSyncError] = useState(null);
const [peers, setPeers] = useState([]);
const [remoteSyncStatus, setRemoteSyncStatus] = useState({
- state: "disabled",
- detail: "Remote server transport is disabled in local tabs mode.",
+ state: 'disabled',
+ detail: 'Remote server transport is disabled in local tabs mode.',
});
const [liveChildrenParents, setLiveChildrenParents] = useState>(() => new Set());
const [liveAllEnabled, setLiveAllEnabled] = useState(false);
@@ -201,7 +270,9 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
const presenceMeshRef = useRef> | null>(null);
const syncPeerRef = useRef | null>(null);
- const syncConnRef = useRef; detach: () => void }>>(new Map());
+ const syncConnRef = useRef; detach: () => void }>>(
+ new Map(),
+ );
const liveChildrenParentsRef = useRef>(new Set());
const liveChildSubsRef = useRef>>(new Map());
const liveAllEnabledRef = useRef(false);
@@ -227,13 +298,18 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
setPeers(merged);
};
- const isRemotePeerId = (peerId: string) => peerId.startsWith("remote:");
+ const isRemotePeerId = (peerId: string) => peerId.startsWith('remote:');
const syncOnceOptionsForPeer = (peerId: string, localCodewordsPerMessage: number) => ({
maxCodewords: PLAYGROUND_SYNC_MAX_CODEWORDS,
maxOpsPerBatch: PLAYGROUND_SYNC_MAX_OPS_PER_BATCH,
- codewordsPerMessage: isRemotePeerId(peerId) ? REMOTE_SYNC_CODEWORDS_PER_MESSAGE : localCodewordsPerMessage,
+ codewordsPerMessage: isRemotePeerId(peerId)
+ ? REMOTE_SYNC_CODEWORDS_PER_MESSAGE
+ : localCodewordsPerMessage,
});
- const syncTimeoutMsForPeer = (peerId: string, opts: { autoSync?: boolean; multipleTargets?: boolean } = {}) => {
+ const syncTimeoutMsForPeer = (
+ peerId: string,
+ opts: { autoSync?: boolean; multipleTargets?: boolean } = {},
+ ) => {
if (isRemotePeerId(peerId)) return PLAYGROUND_REMOTE_SYNC_TIMEOUT_MS;
if (opts.autoSync) return PLAYGROUND_PEER_TIMEOUT_MS;
return opts.multipleTargets ? 8_000 : 15_000;
@@ -293,12 +369,12 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
immediate: true,
intervalMs: 0,
...syncOnceOptionsForPeer(peerId, 1024),
- }
+ },
);
liveAllSubsRef.current.set(peerId, sub);
void sub.done.catch((err) => {
if (!started) return;
- console.error("Live sync(all) failed", err);
+ console.error('Live sync(all) failed', err);
stopLiveAllForPeer(peerId);
setSyncError(formatSyncError(err));
});
@@ -307,7 +383,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
await sub.ready;
started = true;
} catch (err) {
- console.error("Live sync(all) initial catch-up failed", err);
+ console.error('Live sync(all) initial catch-up failed', err);
stopLiveAllForPeer(peerId);
setSyncError(formatSyncError(err));
return;
@@ -336,7 +412,8 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
};
const stopAllLiveChildren = () => {
- for (const peerId of Array.from(liveChildSubsRef.current.keys())) stopLiveChildrenForPeer(peerId);
+ for (const peerId of Array.from(liveChildSubsRef.current.keys()))
+ stopLiveChildrenForPeer(peerId);
};
const startLiveChildren = (peerId: string, parentId: string) => {
@@ -361,13 +438,13 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
immediate: true,
intervalMs: 0,
...syncOnceOptionsForPeer(peerId, 1024),
- }
+ },
);
byParent.set(parentId, sub);
liveChildSubsRef.current.set(peerId, byParent);
void sub.done.catch((err) => {
if (!started) return;
- console.error("Live sync failed", err);
+ console.error('Live sync failed', err);
stopLiveChildren(peerId, parentId);
setSyncError(formatSyncError(err));
});
@@ -376,7 +453,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
await sub.ready;
started = true;
} catch (err) {
- console.error("Live sync(children) initial catch-up failed", err);
+ console.error('Live sync(children) initial catch-up failed', err);
stopLiveChildren(peerId, parentId);
setSyncError(formatSyncError(err));
return;
@@ -419,12 +496,20 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
const remotePeerIds = Array.from(connections.keys()).filter(isRemotePeerId);
if (remotePeerIds.length === 0) continue;
- const liveChildren = Array.from(liveChildrenParentsRef.current).filter((id) => /^[0-9a-f]{32}$/i.test(id));
+ const liveChildren = Array.from(liveChildrenParentsRef.current).filter((id) =>
+ /^[0-9a-f]{32}$/i.test(id),
+ );
const pendingOps = Array.from(remoteLivePushPendingOpsRef.current.values());
const needsFullSync = remoteLivePushNeedsFullSyncRef.current;
remoteLivePushPendingOpsRef.current.clear();
remoteLivePushNeedsFullSyncRef.current = false;
- if (!needsFullSync && pendingOps.length === 0 && !liveAllEnabledRef.current && liveChildren.length === 0) continue;
+ if (
+ !needsFullSync &&
+ pendingOps.length === 0 &&
+ !liveAllEnabledRef.current &&
+ liveChildren.length === 0
+ )
+ continue;
for (const peerId of remotePeerIds) {
const conn = connections.get(peerId);
@@ -436,7 +521,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
maxOpsPerBatch: PLAYGROUND_SYNC_MAX_OPS_PER_BATCH,
}),
syncTimeoutMsForPeer(peerId, { autoSync: true }),
- `live push with ${peerId.slice(0, 8)}… timed out`
+ `live push with ${peerId.slice(0, 8)}… timed out`,
);
continue;
}
@@ -445,7 +530,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
await withTimeout(
peer.syncOnce(conn.transport, { all: {} }, syncOnceOptionsForPeer(peerId, 1024)),
syncTimeoutMsForPeer(peerId, { autoSync: true }),
- `live sync with ${peerId.slice(0, 8)}… timed out`
+ `live sync with ${peerId.slice(0, 8)}… timed out`,
);
continue;
}
@@ -455,14 +540,14 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
peer.syncOnce(
conn.transport,
{ children: { parent: hexToBytes16(parentId) } },
- syncOnceOptionsForPeer(peerId, 1024)
+ syncOnceOptionsForPeer(peerId, 1024),
),
syncTimeoutMsForPeer(peerId, { autoSync: true }),
- `live sync(children ${parentId.slice(0, 8)}…) with ${peerId.slice(0, 8)}… timed out`
+ `live sync(children ${parentId.slice(0, 8)}…) with ${peerId.slice(0, 8)}… timed out`,
);
}
} catch (err) {
- console.error("Remote live sync push failed", err);
+ console.error('Remote live sync push failed', err);
setSyncError(formatSyncError(err));
if (!isCapabilityRevokedError(err)) dropPeerConnection(peerId);
}
@@ -509,17 +594,17 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
const handleSync = async (filter: Filter) => {
if (!onlineRef.current) {
- setSyncError("Offline: toggle Online to sync.");
+ setSyncError('Offline: toggle Online to sync.');
return;
}
const peer = syncPeerRef.current;
if (!peer) {
- setSyncError("Sync peer is not ready yet.");
+ setSyncError('Sync peer is not ready yet.');
return;
}
const connections = syncConnRef.current;
if (connections.size === 0) {
- setSyncError("No peers discovered yet.");
+ setSyncError('No peers discovered yet.');
return;
}
@@ -537,29 +622,31 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
for (const peerId of targets) {
const conn = connections.get(peerId);
if (!conn) continue;
- const perPeerTimeoutMs = syncTimeoutMsForPeer(peerId, { multipleTargets: targets.length > 1 });
+ const perPeerTimeoutMs = syncTimeoutMsForPeer(peerId, {
+ multipleTargets: targets.length > 1,
+ });
try {
await withTimeout(
peer.syncOnce(conn.transport, filter, syncOnceOptionsForPeer(peerId, 2048)),
perPeerTimeoutMs,
- `sync with ${peerId.slice(0, 8)}… timed out`
+ `sync with ${peerId.slice(0, 8)}… timed out`,
);
successes += 1;
} catch (err) {
lastErr = err;
- console.error("Sync failed for peer", peerId, err);
+ console.error('Sync failed for peer', peerId, err);
if (!isCapabilityRevokedError(err)) dropPeerConnection(peerId);
}
}
if (successes === 0) {
if (lastErr) throw lastErr;
- throw new Error("No peers responded to sync.");
+ throw new Error('No peers responded to sync.');
}
await refreshMeta();
await refreshParents(Object.keys(treeStateRef.current.childrenByParent));
await refreshNodeCount();
} catch (err) {
- console.error("Sync failed", err);
+ console.error('Sync failed', err);
setSyncError(formatSyncError(err));
} finally {
setSyncBusy(false);
@@ -574,17 +661,17 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
parentIds.sort();
if (!onlineRef.current) {
- setSyncError("Offline: toggle Online to sync.");
+ setSyncError('Offline: toggle Online to sync.');
return;
}
const peer = syncPeerRef.current;
if (!peer) {
- setSyncError("Sync peer is not ready yet.");
+ setSyncError('Sync peer is not ready yet.');
return;
}
const connections = syncConnRef.current;
if (connections.size === 0) {
- setSyncError("No peers discovered yet.");
+ setSyncError('No peers discovered yet.');
return;
}
@@ -602,35 +689,37 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
for (const peerId of targets) {
const conn = connections.get(peerId);
if (!conn) continue;
- const perPeerTimeoutMs = syncTimeoutMsForPeer(peerId, { multipleTargets: targets.length > 1 });
+ const perPeerTimeoutMs = syncTimeoutMsForPeer(peerId, {
+ multipleTargets: targets.length > 1,
+ });
try {
for (const parentId of parentIds) {
await withTimeout(
peer.syncOnce(
conn.transport,
{ children: { parent: hexToBytes16(parentId) } },
- syncOnceOptionsForPeer(peerId, 2048)
+ syncOnceOptionsForPeer(peerId, 2048),
),
perPeerTimeoutMs,
- `sync(children ${parentId.slice(0, 8)}…) with ${peerId.slice(0, 8)}… timed out`
+ `sync(children ${parentId.slice(0, 8)}…) with ${peerId.slice(0, 8)}… timed out`,
);
}
successes += 1;
} catch (err) {
lastErr = err;
- console.error("Scoped sync failed for peer", peerId, err);
+ console.error('Scoped sync failed for peer', peerId, err);
if (!isCapabilityRevokedError(err)) dropPeerConnection(peerId);
}
}
if (successes === 0) {
if (lastErr) throw lastErr;
- throw new Error("No peers responded to sync.");
+ throw new Error('No peers responded to sync.');
}
await refreshMeta();
await refreshParents(Object.keys(treeStateRef.current.childrenByParent));
await refreshNodeCount();
} catch (err) {
- console.error("Scoped sync failed", err);
+ console.error('Scoped sync failed', err);
setSyncError(formatSyncError(err));
} finally {
setSyncBusy(false);
@@ -638,7 +727,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
};
const postBroadcastMessage = (
- msg: BroadcastPresenceMessageV1 | BroadcastPresenceAckMessageV1 | AuthGrantMessageV1
+ msg: BroadcastPresenceMessageV1 | BroadcastPresenceAckMessageV1 | AuthGrantMessageV1,
) => {
const channel = broadcastChannelRef.current;
if (!channel) return false;
@@ -697,17 +786,17 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
await withTimeout(
peer.syncOnce(conn.transport, { all: {} }, syncOnceOptionsForPeer(peerId, 2048)),
syncTimeoutMsForPeer(peerId, { autoSync: true }),
- `auto sync with ${peerId.slice(0, 8)}… timed out`
+ `auto sync with ${peerId.slice(0, 8)}… timed out`,
);
} else {
await withTimeout(
peer.syncOnce(
conn.transport,
{ children: { parent: hexToBytes16(viewRootId) } },
- syncOnceOptionsForPeer(peerId, 2048)
+ syncOnceOptionsForPeer(peerId, 2048),
),
syncTimeoutMsForPeer(peerId, { autoSync: true }),
- `auto sync(children ${viewRootId.slice(0, 8)}…) with ${peerId.slice(0, 8)}… timed out`
+ `auto sync(children ${viewRootId.slice(0, 8)}…) with ${peerId.slice(0, 8)}… timed out`,
);
}
@@ -718,13 +807,13 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
await refreshNodeCount();
autoSyncDoneRef.current = true;
- if (typeof window !== "undefined") {
+ if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
- url.searchParams.delete("autosync");
- window.history.replaceState({}, "", url);
+ url.searchParams.delete('autosync');
+ window.history.replaceState({}, '', url);
}
} catch (err) {
- console.error("Auto sync failed", err);
+ console.error('Auto sync failed', err);
setSyncError(formatSyncError(err));
autoSyncPeerIdRef.current = null;
if (!isCapabilityRevokedError(err)) dropPeerConnection(peerId);
@@ -788,35 +877,42 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
}, [authCanSyncAll, liveAllEnabled]);
useEffect(() => {
- if (!client || status !== "ready") return;
+ if (!client || status !== 'ready') return;
if (!docId) return;
- const hasBroadcastChannel = typeof BroadcastChannel !== "undefined";
- const wantsLocalMesh = transportMode !== "remote";
- const wantsRemoteSocket = transportMode !== "local";
+ const hasBroadcastChannel = typeof BroadcastChannel !== 'undefined';
+ const wantsLocalMesh = transportMode !== 'remote';
+ const wantsRemoteSocket = transportMode !== 'local';
const configuredRemoteSyncUrl = syncServerUrl.trim();
const hasLocalMesh = wantsLocalMesh && hasBroadcastChannel;
- const remoteSyncUrl = wantsRemoteSocket ? configuredRemoteSyncUrl : "";
+ const remoteSyncUrl = wantsRemoteSocket ? configuredRemoteSyncUrl : '';
if (!wantsRemoteSocket) {
setRemoteSyncStatus({
- state: "disabled",
- detail: "Remote server transport is disabled in local tabs mode.",
+ state: 'disabled',
+ detail: 'Remote server transport is disabled in local tabs mode.',
});
} else if (configuredRemoteSyncUrl.length === 0) {
setRemoteSyncStatus({
- state: "missing_url",
- detail: "Enter a websocket URL to use remote transport.",
+ state: 'missing_url',
+ detail: 'Enter either a websocket sync URL or an HTTPS bootstrap URL.',
});
} else {
try {
- const remoteUrl = normalizeSyncServerUrl(configuredRemoteSyncUrl, docId);
- setRemoteSyncStatus({
- state: "connecting",
- detail: `Preparing connection to ${remoteUrl.host}...`,
- });
+ if (isDiscoveryBootstrapUrl(configuredRemoteSyncUrl)) {
+ setRemoteSyncStatus({
+ state: 'connecting',
+ detail: `Resolving attachment via ${previewDiscoveryHost(configuredRemoteSyncUrl)}...`,
+ });
+ } else {
+ const remoteUrl = normalizeSyncServerUrl(configuredRemoteSyncUrl, docId);
+ setRemoteSyncStatus({
+ state: 'connecting',
+ detail: `Preparing connection to ${remoteUrl.host}...`,
+ });
+ }
} catch (err) {
setRemoteSyncStatus({
- state: "invalid",
+ state: 'invalid',
detail: formatSyncError(err),
});
}
@@ -824,14 +920,14 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
if (!hasLocalMesh && remoteSyncUrl.length === 0) {
if (wantsRemoteSocket && configuredRemoteSyncUrl.length === 0) {
- setSyncError("Remote transport requires a sync server URL.");
+ setSyncError('Remote transport requires a sync server URL.');
return;
}
if (wantsLocalMesh && !hasBroadcastChannel) {
- setSyncError("BroadcastChannel is not available in this environment.");
+ setSyncError('BroadcastChannel is not available in this environment.');
return;
}
- setSyncError("No sync transport is configured.");
+ setSyncError('No sync transport is configured.');
return;
}
@@ -849,7 +945,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
hardRevokedTokenIds: hardRevokedTokenIds.map((id) => hexToBytes16(id)),
cutoverRule: (() => {
if (!revocationCutoverEnabled) return null;
- const tokenIdHex = revocationCutoverTokenId.trim().toLowerCase().replace(/^0x/, "");
+ const tokenIdHex = revocationCutoverTokenId.trim().toLowerCase().replace(/^0x/, '');
if (!/^[0-9a-f]{32}$/.test(tokenIdHex)) return null;
const parsedCounter = Number(revocationCutoverCounter.trim());
if (!Number.isInteger(parsedCounter) || parsedCounter < 0) return null;
@@ -866,35 +962,38 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
if (!authEnabled) {
// If auth is off, clear any auth-gating error strings so the UI doesn't keep telling users to import invites.
setSyncError((prev) =>
- prev && (prev.startsWith("Auth enabled:") || prev.startsWith("Initializing local peer key")) ? null : prev
+ prev && (prev.startsWith('Auth enabled:') || prev.startsWith('Initializing local peer key'))
+ ? null
+ : prev,
);
}
if (authEnabled && !peerAuthConfig) {
const waitingForInvite = joinMode && authMaterial.localTokensB64.length === 0;
- setSyncError(waitingForInvite ? null : authError ?? "Auth enabled: initializing keys/tokens...");
+ setSyncError(
+ waitingForInvite ? null : (authError ?? 'Auth enabled: initializing keys/tokens...'),
+ );
return;
}
if (!selfPeerId) {
- setSyncError("Initializing local peer key...");
+ setSyncError('Initializing local peer key...');
return;
}
setSyncError((prev) =>
prev &&
- (
- prev.includes("initializing keys/tokens") ||
- prev.startsWith("Initializing local peer key") ||
- prev === "Remote transport requires a sync server URL." ||
- prev === "BroadcastChannel is not available in this environment." ||
- prev === "No sync transport is configured."
- )
+ (prev.includes('initializing keys/tokens') ||
+ prev.startsWith('Initializing local peer key') ||
+ prev === 'Remote transport requires a sync server URL.' ||
+ prev === 'BroadcastChannel is not available in this environment.' ||
+ prev === 'No sync transport is configured.')
? null
- : prev
+ : prev,
);
- const debugSync = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("debugSync");
+ const debugSync =
+ typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSync');
const channel = hasLocalMesh ? new BroadcastChannel(`treecrdt-sync-v0:${docId}`) : null;
broadcastChannelRef.current = channel;
@@ -911,7 +1010,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
listOpRefs: async (filter: Filter) => {
const refs = await baseBackend.listOpRefs(filter);
if (debugSync) {
- const name = "all" in filter ? "all" : `children(${bytesToHex(filter.children.parent)})`;
+ const name = 'all' in filter ? 'all' : `children(${bytesToHex(filter.children.parent)})`;
console.debug(`[sync:${selfPeerId}] listOpRefs(${name}) -> ${refs.length}`);
}
return refs;
@@ -941,7 +1040,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
capabilityStore: peerAuthConfig.capabilityStore,
revokedCapabilityTokenIds: peerAuthConfig.hardRevokedTokenIds,
isCapabilityTokenRevoked: (ctx) => {
- if (ctx.stage !== "runtime") return false;
+ if (ctx.stage !== 'runtime') return false;
if (!peerAuthConfig.cutoverRule) return false;
if (ctx.tokenIdHex !== peerAuthConfig.cutoverRule.tokenIdHex) return false;
return ctx.op.meta.id.counter >= peerAuthConfig.cutoverRule.counter;
@@ -1032,16 +1131,16 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
publishPeers();
},
onBroadcastMessage: (data) => {
- if (!data || typeof data !== "object") return;
+ if (!data || typeof data !== 'object') return;
const msg = data as Partial;
- if (msg.t !== "auth_grant_v1") return;
+ if (msg.t !== 'auth_grant_v1') return;
const grant = msg as Partial;
- if (typeof grant.doc_id !== "string") return;
+ if (typeof grant.doc_id !== 'string') return;
if (grant.doc_id !== docId) return;
- if (typeof grant.to_replica_pk_hex !== "string") return;
- if (typeof grant.issuer_pk_b64 !== "string") return;
- if (typeof grant.token_b64 !== "string") return;
+ if (typeof grant.to_replica_pk_hex !== 'string') return;
+ if (typeof grant.issuer_pk_b64 !== 'string') return;
+ if (typeof grant.token_b64 !== 'string') return;
const localReplicaHex = selfPeerId;
if (!localReplicaHex) return;
@@ -1058,82 +1157,126 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
let remotePeerId: string | null = null;
let disposed = false;
let remoteOpened = false;
+ let resolvedRemote: ResolveWebSocketAttachmentResult | null = null;
+ const discoveryRouteCache = getBrowserDiscoveryRouteCache();
if (remoteSyncUrl.length > 0) {
- try {
- const remoteUrl = normalizeSyncServerUrl(remoteSyncUrl, docId);
- setRemoteSyncStatus({
- state: "connecting",
- detail: `Connecting to ${remoteUrl.host}...`,
- });
- remotePeerId = `remote:${remoteUrl.host}`;
- remoteSocket = new WebSocket(remoteUrl.toString());
- remoteSocket.binaryType = "arraybuffer";
-
- remoteSocket.addEventListener("open", () => {
+ void (async () => {
+ try {
+ setSyncError((prev) => (isTransientRemoteConnectError(prev) ? null : prev));
+ const bootstrapHost = isDiscoveryBootstrapUrl(remoteSyncUrl)
+ ? previewDiscoveryHost(remoteSyncUrl)
+ : undefined;
+ resolvedRemote = await resolveWebSocketAttachment({
+ endpoint: remoteSyncUrl,
+ docId,
+ cache: discoveryRouteCache,
+ fetch:
+ typeof window !== 'undefined' && typeof window.fetch === 'function'
+ ? window.fetch.bind(window)
+ : undefined,
+ });
if (disposed || syncConnRef.current !== connections) return;
- if (!remoteSocket || remoteSocket.readyState !== WebSocket.OPEN || !remotePeerId) return;
- remoteOpened = true;
- setSyncError((prev) => (prev === `Remote sync socket error (${remoteUrl.host})` ? null : prev));
+ const remoteUrl = resolvedRemote.url;
+ const connectVerb =
+ resolvedRemote.source === 'network'
+ ? 'Resolved attachment, connecting to'
+ : resolvedRemote.source === 'cache'
+ ? 'Using cached route to'
+ : 'Connecting to';
setRemoteSyncStatus({
- state: "connected",
- detail: `Connected to ${remoteUrl.host}`,
+ state: 'connecting',
+ detail: formatRemoteConnectDetail(connectVerb, remoteUrl.host, bootstrapHost),
});
- const wire = createBrowserWebSocketTransport(remoteSocket);
- const transport = wrapDuplexTransportWithCodec(
- wire,
- treecrdtSyncV0ProtobufCodec as any
- );
- const detach = sharedPeer.attach(transport);
- syncConnRef.current.set(remotePeerId, { transport, detach });
- remotePeerRef.current = { id: remotePeerId, lastSeen: Date.now() };
- publishPeers();
- maybeStartLiveForPeer(remotePeerId);
-
- if (autoSyncJoinInitial && joinMode && !autoSyncDoneRef.current) {
- autoSyncPeerIdRef.current = remotePeerId;
- bumpAutoSyncJoinTick((t) => t + 1);
- }
- });
+ remotePeerId = `remote:${remoteUrl.host}`;
+ remoteSocket = new WebSocket(remoteUrl.toString());
+ remoteSocket.binaryType = 'arraybuffer';
+
+ remoteSocket.addEventListener('open', () => {
+ if (disposed || syncConnRef.current !== connections) return;
+ if (!remoteSocket || remoteSocket.readyState !== WebSocket.OPEN || !remotePeerId)
+ return;
+ remoteOpened = true;
+ setSyncError((prev) => (isTransientRemoteConnectError(prev) ? null : prev));
+ setRemoteSyncStatus({
+ detail: formatRemoteRouteDetail(remoteUrl.host, { bootstrapHost }),
+ state: 'connected',
+ });
+ const wire = createBrowserWebSocketTransport(remoteSocket);
+ const transport = wrapDuplexTransportWithCodec(
+ wire,
+ treecrdtSyncV0ProtobufCodec as any,
+ );
+ const detach = sharedPeer.attach(transport);
+ syncConnRef.current.set(remotePeerId, { transport, detach });
+ remotePeerRef.current = { id: remotePeerId, lastSeen: Date.now() };
+ publishPeers();
+ maybeStartLiveForPeer(remotePeerId);
- remoteSocket.addEventListener("message", () => {
- if (disposed || syncConnRef.current !== connections) return;
- if (!remotePeerId) return;
- remotePeerRef.current = { id: remotePeerId, lastSeen: Date.now() };
- publishPeers();
- });
+ if (autoSyncJoinInitial && joinMode && !autoSyncDoneRef.current) {
+ autoSyncPeerIdRef.current = remotePeerId;
+ bumpAutoSyncJoinTick((t) => t + 1);
+ }
+ });
+
+ remoteSocket.addEventListener('message', () => {
+ if (disposed || syncConnRef.current !== connections) return;
+ if (!remotePeerId) return;
+ remotePeerRef.current = { id: remotePeerId, lastSeen: Date.now() };
+ setRemoteSyncStatus((prev) =>
+ prev.state === 'connected'
+ ? {
+ detail: formatRemoteRouteDetail(remoteUrl.host, { bootstrapHost }),
+ state: 'connected',
+ }
+ : prev,
+ );
+ publishPeers();
+ });
- remoteSocket.addEventListener("close", () => {
- if (syncConnRef.current !== connections) return;
- if (!disposed) {
+ remoteSocket.addEventListener('close', () => {
+ if (syncConnRef.current !== connections) return;
+ if (!disposed) {
+ setRemoteSyncStatus({
+ detail: formatRemoteErrorDetail(
+ remoteOpened ? 'disconnected' : 'could_not_connect',
+ remoteUrl.host,
+ bootstrapHost,
+ ),
+ state: 'error',
+ });
+ }
+ if (!remoteOpened && resolvedRemote?.source === 'cache' && resolvedRemote.cacheKey) {
+ void discoveryRouteCache?.delete(resolvedRemote.cacheKey);
+ }
+ if (!remotePeerId) return;
+ dropPeerConnection(remotePeerId);
+ });
+
+ remoteSocket.addEventListener('error', () => {
+ if (syncConnRef.current !== connections) return;
setRemoteSyncStatus({
- state: "error",
- detail: remoteOpened
- ? `Disconnected from ${remoteUrl.host}`
- : `Could not connect to ${remoteUrl.host}`,
+ detail: formatRemoteErrorDetail(
+ remoteOpened ? 'connection_error' : 'could_not_reach',
+ remoteUrl.host,
+ bootstrapHost,
+ ),
+ state: 'error',
});
- }
- if (!remotePeerId) return;
- dropPeerConnection(remotePeerId);
- });
-
- remoteSocket.addEventListener("error", () => {
- if (syncConnRef.current !== connections) return;
+ if (!remoteOpened && resolvedRemote?.source === 'cache' && resolvedRemote.cacheKey) {
+ void discoveryRouteCache?.delete(resolvedRemote.cacheKey);
+ }
+ setSyncError((prev) => prev ?? `Remote sync socket error (${remoteUrl.host})`);
+ });
+ } catch (err) {
+ if (disposed || syncConnRef.current !== connections) return;
setRemoteSyncStatus({
- state: "error",
- detail: remoteOpened
- ? `Connection error talking to ${remoteUrl.host}`
- : `Could not reach ${remoteUrl.host}`,
+ state: isDiscoveryBootstrapUrl(remoteSyncUrl) ? 'error' : 'invalid',
+ detail: formatSyncError(err),
});
- setSyncError((prev) => prev ?? `Remote sync socket error (${remoteUrl.host})`);
- });
- } catch (err) {
- setRemoteSyncStatus({
- state: "invalid",
- detail: formatSyncError(err),
- });
- setSyncError(formatSyncError(err));
- }
+ setSyncError(formatSyncError(err));
+ }
+ })();
}
return () => {
@@ -1170,8 +1313,8 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn
authMaterial.issuerPkB64,
authMaterial.localPkB64,
authMaterial.localSkB64,
- authMaterial.localTokensB64.join(","),
- hardRevokedTokenIds.join(","),
+ authMaterial.localTokensB64.join(','),
+ hardRevokedTokenIds.join(','),
revocationCutoverEnabled,
revocationCutoverTokenId,
revocationCutoverCounter,
diff --git a/examples/playground/tests/playground.spec.ts b/examples/playground/tests/playground.spec.ts
index be8a36f7..68ba46d9 100644
--- a/examples/playground/tests/playground.spec.ts
+++ b/examples/playground/tests/playground.spec.ts
@@ -8,6 +8,7 @@ import { startWebSocketSyncServer } from "../../../packages/sync/server/core/dis
const ROOT_ID = "00000000000000000000000000000000";
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+const REMOTE_PLACEHOLDER = "https://bootstrap-host or ws://localhost:8787";
type TestSyncServer = {
host: string;
@@ -488,7 +489,7 @@ test("switching remote sync server URL reconnects to the new endpoint", async ({
await waitForReady(page, `/?doc=${encodeURIComponent(doc)}`);
await page.getByRole("button", { name: /Connections/ }).click();
- const remoteInput = page.getByPlaceholder("ws://localhost:8787 or ws://localhost:8787/sync");
+ const remoteInput = page.getByPlaceholder(REMOTE_PLACEHOLDER);
await expect(remoteInput).toBeVisible({ timeout: 30_000 });
await remoteInput.fill(serverA.wsUrl);
@@ -510,7 +511,7 @@ test("switching to remote transport does not auto-fill a default sync URL", asyn
await waitForReady(page, `/?doc=${encodeURIComponent(doc)}`);
await page.getByRole("button", { name: /Connections/ }).click();
- const remoteInput = page.getByPlaceholder("ws://localhost:8787 or ws://localhost:8787/sync");
+ const remoteInput = page.getByPlaceholder(REMOTE_PLACEHOLDER);
await expect(remoteInput).toHaveValue("");
await page.getByRole("button", { name: "Remote server", exact: true }).click();
@@ -539,7 +540,7 @@ test("remote sync settings persist into a shareable URL", async ({ browser, page
await waitForReady(page, `/?doc=${encodeURIComponent(doc)}`);
await page.getByRole("button", { name: /Connections/ }).click();
- const remoteInput = page.getByPlaceholder("ws://localhost:8787 or ws://localhost:8787/sync");
+ const remoteInput = page.getByPlaceholder(REMOTE_PLACEHOLDER);
await expect(remoteInput).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Remote server", exact: true }).click();
@@ -558,7 +559,7 @@ test("remote sync settings persist into a shareable URL", async ({ browser, page
const sharedPage = await sharedContext.newPage();
await waitForReady(sharedPage, page.url());
await sharedPage.getByRole("button", { name: /Connections/ }).click();
- await expect(sharedPage.getByPlaceholder("ws://localhost:8787 or ws://localhost:8787/sync")).toHaveValue(
+ await expect(sharedPage.getByPlaceholder(REMOTE_PLACEHOLDER)).toHaveValue(
server.wsUrl,
{ timeout: 30_000 }
);
@@ -584,7 +585,7 @@ test("invite link preserves auth material and remote sync settings", async ({ br
await waitForLocalAuthTokens(pageA);
await pageA.getByRole("button", { name: /Connections/ }).click();
- const remoteInput = pageA.getByPlaceholder("ws://localhost:8787 or ws://localhost:8787/sync");
+ const remoteInput = pageA.getByPlaceholder(REMOTE_PLACEHOLDER);
await expect(remoteInput).toBeVisible({ timeout: 30_000 });
await pageA.getByRole("button", { name: "Remote server", exact: true }).click();
await remoteInput.fill(server.wsUrl);
@@ -610,7 +611,7 @@ test("invite link preserves auth material and remote sync settings", async ({ br
await joinViaInviteLink(pageB, inviteLink);
await pageB.getByRole("button", { name: /Connections/ }).click();
- await expect(pageB.getByPlaceholder("ws://localhost:8787 or ws://localhost:8787/sync")).toHaveValue(server.wsUrl, {
+ await expect(pageB.getByPlaceholder(REMOTE_PLACEHOLDER)).toHaveValue(server.wsUrl, {
timeout: 30_000,
});
await expect(pageB.getByText(`remote(${server.host})`)).toBeVisible({ timeout: 30_000 });
diff --git a/package.json b/package.json
index 4add38cd..b2a3a21a 100644
--- a/package.json
+++ b/package.json
@@ -39,10 +39,13 @@
"benchmark:sync:upload:remote": "node scripts/run-sync-bench.mjs remote prime",
"benchmark:sync:remote": "node scripts/run-sync-bench.mjs remote",
"benchmark:playground:live-write": "node scripts/bench-playground-live-write.mjs",
+ "benchmark:sync:bootstrap": "node scripts/bench-discovery-connect.mjs",
"benchmark:wasm": "pnpm -C packages/treecrdt-wasm-js run benchmark",
"benchmark:web": "pnpm -C packages/treecrdt-wa-sqlite/e2e run bench",
"test": "pnpm run test:browser && pnpm run test:native-node",
"playground": "pnpm --filter @treecrdt/playground dev",
+ "discovery-server": "pnpm --filter @treecrdt/discovery-server-node dev",
+ "discovery-server:local": "TREECRDT_DISCOVERY_PUBLIC_HTTP_BASE_URL=http://localhost:8788 TREECRDT_DISCOVERY_PUBLIC_WS_BASE_URL=ws://localhost:8787 pnpm --filter @treecrdt/discovery-server-node dev",
"sync-server:postgres:setup": "pnpm --filter @treecrdt/postgres-napi... --filter @treecrdt/sync-server-postgres-node... run build",
"sync-server:postgres": "pnpm --filter @treecrdt/sync-server-postgres-node dev",
"sync-server:postgres:local": "node scripts/run-sync-server-postgres-local.mjs",
@@ -52,6 +55,7 @@
"devDependencies": {
"@bufbuild/buf": "^1.61.0",
"@bufbuild/protoc-gen-es": "^2.10.2",
- "prettier": "^3.8.1"
+ "prettier": "^3.8.1",
+ "ws": "^8.18.3"
}
}
diff --git a/packages/discovery-server-node/README.md b/packages/discovery-server-node/README.md
new file mode 100644
index 00000000..478368bd
--- /dev/null
+++ b/packages/discovery-server-node/README.md
@@ -0,0 +1,37 @@
+# TreeCRDT Discovery Server (Node)
+
+Small standalone HTTP bootstrap service for `resolveDoc`.
+
+It is intentionally separate from the websocket sync server:
+
+- discovery/bootstrap happens once at connect time
+- clients cache the returned attachment plan
+- steady-state sync then talks directly to the resolved `ws://` or `wss://` endpoint
+
+## Run locally
+
+From the repo root:
+
+```sh
+pnpm discovery-server:local
+```
+
+This starts a bootstrap server on `http://localhost:8788` that advertises:
+
+- bootstrap base: `http://localhost:8788`
+- sync websocket base: `ws://localhost:8787`
+
+## Environment
+
+- `HOST` (default: `0.0.0.0`)
+- `PORT` (default: `8788`)
+- `TREECRDT_DISCOVERY_RESOLVE_PATH` (default: `/resolve-doc`)
+- `TREECRDT_DISCOVERY_PUBLIC_HTTP_BASE_URL` (optional absolute HTTP base URL advertised to clients)
+- `TREECRDT_DISCOVERY_PUBLIC_WS_BASE_URL` (optional absolute websocket base URL advertised to clients)
+- `TREECRDT_DISCOVERY_CACHE_TTL_MS` (default: `3600000`)
+
+## Endpoints
+
+- `GET /health`
+- `GET /status`
+- `GET /resolve-doc?docId=YOUR_DOC_ID`
diff --git a/packages/discovery-server-node/package.json b/packages/discovery-server-node/package.json
new file mode 100644
index 00000000..888d2173
--- /dev/null
+++ b/packages/discovery-server-node/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@treecrdt/discovery-server-node",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "files": [
+ "dist",
+ "src",
+ "README.md"
+ ],
+ "scripts": {
+ "dev": "pnpm --filter @treecrdt/discovery-server-node^... run build && tsx src/cli.ts",
+ "build": "tsc -p tsconfig.json",
+ "test": "pnpm -C ../discovery run build && pnpm run build && vitest run"
+ },
+ "dependencies": {
+ "@treecrdt/discovery": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^20.19.25",
+ "tsx": "^4.19.2",
+ "typescript": "^5.9.3",
+ "vitest": "^1.6.0"
+ }
+}
diff --git a/packages/discovery-server-node/src/cli.ts b/packages/discovery-server-node/src/cli.ts
new file mode 100644
index 00000000..b977ee2e
--- /dev/null
+++ b/packages/discovery-server-node/src/cli.ts
@@ -0,0 +1,85 @@
+import fs from 'node:fs';
+
+import { startDiscoveryServer } from './server.js';
+
+function clientHostForBindHost(host: string): string {
+ const trimmed = host.trim();
+ if (trimmed === '0.0.0.0' || trimmed === '::' || trimmed === '[::]') {
+ return 'localhost';
+ }
+ return trimmed;
+}
+
+function parseBooleanEnv(name: string, fallback: boolean): boolean {
+ const raw = process.env[name];
+ if (!raw || raw.trim().length === 0) return fallback;
+ const normalized = raw.trim().toLowerCase();
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
+ throw new Error(`${name} must be a boolean (true/false), got: ${raw}`);
+}
+
+function readPackageVersion(): string | undefined {
+ try {
+ const packageJsonUrl = new URL('../package.json', import.meta.url);
+ const parsed = JSON.parse(fs.readFileSync(packageJsonUrl, 'utf8')) as { version?: unknown };
+ return typeof parsed.version === 'string' && parsed.version.trim().length > 0
+ ? parsed.version.trim()
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+async function main() {
+ const host = process.env.HOST ?? '0.0.0.0';
+ const port = Number(process.env.PORT ?? '8788');
+ const resolveDocPath = process.env.TREECRDT_DISCOVERY_RESOLVE_PATH?.trim() || undefined;
+ const publicHttpBaseUrl =
+ process.env.TREECRDT_DISCOVERY_PUBLIC_HTTP_BASE_URL?.trim() || undefined;
+ const publicWebSocketBaseUrl =
+ process.env.TREECRDT_DISCOVERY_PUBLIC_WS_BASE_URL?.trim() || undefined;
+ const cacheTtlMs = Number(process.env.TREECRDT_DISCOVERY_CACHE_TTL_MS ?? String(60 * 60 * 1000));
+ const packageVersion = readPackageVersion();
+ const gitSha = process.env.TREECRDT_DISCOVERY_GIT_SHA?.trim() || undefined;
+ const gitDirty = parseBooleanEnv('TREECRDT_DISCOVERY_GIT_DIRTY', false);
+ const startedAt = new Date().toISOString();
+
+ if (!Number.isFinite(port) || port <= 0) throw new Error(`invalid PORT: ${process.env.PORT}`);
+ if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
+ throw new Error('invalid TREECRDT_DISCOVERY_CACHE_TTL_MS');
+ }
+
+ const handle = await startDiscoveryServer({
+ host,
+ port,
+ resolveDocPath,
+ publicHttpBaseUrl,
+ publicWebSocketBaseUrl,
+ cacheTtlMs,
+ packageVersion,
+ gitSha,
+ gitDirty,
+ startedAt,
+ });
+ const clientHost = clientHostForBindHost(handle.host);
+ console.log(`TreeCRDT discovery server listening on ${handle.host}:${handle.port}`);
+ console.log(`- bind: http://${handle.host}:${handle.port}`);
+ console.log(`- health: http://${clientHost}:${handle.port}/health`);
+ console.log(`- status: http://${clientHost}:${handle.port}/status`);
+ console.log(
+ `- resolve: http://${clientHost}:${handle.port}${resolveDocPath ?? '/resolve-doc'}?docId=YOUR_DOC_ID`,
+ );
+ if (publicHttpBaseUrl || publicWebSocketBaseUrl) {
+ console.log(
+ `- advertised endpoints: ${publicHttpBaseUrl ?? '(derived from request)'} / ${
+ publicWebSocketBaseUrl ?? '(derived from request)'
+ }`,
+ );
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+});
diff --git a/packages/discovery-server-node/src/index.ts b/packages/discovery-server-node/src/index.ts
new file mode 100644
index 00000000..3693f485
--- /dev/null
+++ b/packages/discovery-server-node/src/index.ts
@@ -0,0 +1 @@
+export * from './server.js';
diff --git a/packages/discovery-server-node/src/server.ts b/packages/discovery-server-node/src/server.ts
new file mode 100644
index 00000000..fe8c81a1
--- /dev/null
+++ b/packages/discovery-server-node/src/server.ts
@@ -0,0 +1,310 @@
+import http from 'node:http';
+
+import type { ResolveDocRequest, ResolveDocResponse } from '@treecrdt/discovery';
+
+type Awaitable = T | Promise;
+
+export type DiscoveryServerResolveContext = {
+ req: http.IncomingMessage;
+ url: URL;
+};
+
+export type DiscoveryServerResolveHandler = (
+ request: ResolveDocRequest,
+ ctx: DiscoveryServerResolveContext,
+) => Awaitable;
+
+export type DiscoveryServerHealthResult =
+ | {
+ ok: true;
+ body?: string;
+ contentType?: string;
+ }
+ | {
+ ok: false;
+ statusCode?: number;
+ body?: string;
+ contentType?: string;
+ };
+
+export type DiscoveryServerOptions = {
+ host?: string;
+ port?: number;
+ healthPath?: string;
+ statusPath?: string;
+ resolveDocPath?: string;
+ publicHttpBaseUrl?: string;
+ publicWebSocketBaseUrl?: string;
+ cacheTtlMs?: number;
+ resolveDoc?: DiscoveryServerResolveHandler;
+ healthCheck?: () => Awaitable;
+ statusInfo?: () => Awaitable>;
+ packageName?: string;
+ packageVersion?: string;
+ gitSha?: string;
+ gitDirty?: boolean;
+ startedAt?: string;
+};
+
+export type DiscoveryServerHandle = {
+ host: string;
+ port: number;
+ close: () => Promise;
+};
+
+function normalizeOptionalAbsoluteUrl(
+ name: string,
+ value: string | undefined,
+ allowedProtocols: readonly string[],
+): string | undefined {
+ if (!value) return undefined;
+ const trimmed = value.trim();
+ if (trimmed.length === 0) return undefined;
+ let url: URL;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ throw new Error(`${name} must be a valid absolute URL`);
+ }
+ if (!allowedProtocols.includes(url.protocol)) {
+ throw new Error(`${name} must use ${allowedProtocols.join(', ')}`);
+ }
+ return url.toString().replace(/\/$/, '');
+}
+
+function normalizePath(name: string, value: string | undefined, fallback: string): string {
+ const trimmed = value?.trim() || fallback;
+ if (!trimmed.startsWith('/')) throw new Error(`${name} must start with "/"`);
+ return trimmed;
+}
+
+function firstForwardedHeader(value: string | string[] | undefined): string | undefined {
+ if (Array.isArray(value)) value = value[0];
+ if (!value) return undefined;
+ const first = value.split(',')[0]?.trim();
+ return first && first.length > 0 ? first : undefined;
+}
+
+function derivePublicBaseUrl(req: http.IncomingMessage, fallbackProtocol: 'http' | 'ws'): string {
+ const host =
+ firstForwardedHeader(req.headers['x-forwarded-host']) ?? req.headers.host ?? 'localhost';
+ const forwardedProto = firstForwardedHeader(req.headers['x-forwarded-proto']);
+ const protocol =
+ forwardedProto && forwardedProto.length > 0
+ ? fallbackProtocol === 'ws'
+ ? forwardedProto === 'https'
+ ? 'wss'
+ : 'ws'
+ : forwardedProto
+ : fallbackProtocol;
+ return `${protocol}://${host}`.replace(/\/$/, '');
+}
+
+function buildDefaultResolveDocHandler(opts: {
+ publicHttpBaseUrl?: string;
+ publicWebSocketBaseUrl?: string;
+ cacheTtlMs: number;
+}): DiscoveryServerResolveHandler {
+ return async (request, ctx) => {
+ const publicHttpBaseUrl = opts.publicHttpBaseUrl ?? derivePublicBaseUrl(ctx.req, 'http');
+ const publicWebSocketBaseUrl =
+ opts.publicWebSocketBaseUrl ?? derivePublicBaseUrl(ctx.req, 'ws');
+ return {
+ docId: request.docId,
+ plan: {
+ topology: 'relay',
+ attachments: [
+ {
+ protocol: 'websocket',
+ role: 'preferred',
+ url: `${publicWebSocketBaseUrl}/sync`,
+ },
+ {
+ protocol: 'https',
+ role: 'bootstrap',
+ url: publicHttpBaseUrl,
+ },
+ ],
+ cacheTtlMs: opts.cacheTtlMs,
+ },
+ };
+ };
+}
+
+export async function startDiscoveryServer(
+ opts: DiscoveryServerOptions = {},
+): Promise {
+ const host = opts.host ?? '0.0.0.0';
+ const port = Number(opts.port ?? 8788);
+ const healthPath = normalizePath('healthPath', opts.healthPath, '/health');
+ const statusPath = normalizePath('statusPath', opts.statusPath, '/status');
+ const resolveDocPath = normalizePath('resolveDocPath', opts.resolveDocPath, '/resolve-doc');
+ const publicHttpBaseUrl = normalizeOptionalAbsoluteUrl(
+ 'publicHttpBaseUrl',
+ opts.publicHttpBaseUrl,
+ ['http:', 'https:'],
+ );
+ const publicWebSocketBaseUrl = normalizeOptionalAbsoluteUrl(
+ 'publicWebSocketBaseUrl',
+ opts.publicWebSocketBaseUrl,
+ ['ws:', 'wss:', 'http:', 'https:'],
+ );
+ const cacheTtlMs = opts.cacheTtlMs == null ? 60 * 60 * 1000 : Number(opts.cacheTtlMs);
+ const packageName = opts.packageName?.trim() || '@treecrdt/discovery-server-node';
+ const packageVersion = opts.packageVersion?.trim() || undefined;
+ const gitSha = opts.gitSha?.trim() || undefined;
+ const gitDirty = Boolean(opts.gitDirty);
+ const startedAt = opts.startedAt?.trim() || new Date().toISOString();
+ const startedAtMs = Date.parse(startedAt);
+
+ if (!Number.isFinite(port) || port < 0) throw new Error(`invalid port: ${opts.port}`);
+ if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
+ throw new Error(`invalid cacheTtlMs: ${opts.cacheTtlMs}`);
+ }
+
+ const resolveDoc =
+ opts.resolveDoc ??
+ buildDefaultResolveDocHandler({
+ publicHttpBaseUrl,
+ publicWebSocketBaseUrl,
+ cacheTtlMs,
+ });
+ const discoveryCorsHeaders = {
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-methods': 'GET,OPTIONS',
+ 'access-control-allow-headers': 'content-type,authorization',
+ };
+
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
+
+ if (url.pathname === healthPath) {
+ void (async () => {
+ try {
+ const result = (await opts.healthCheck?.()) ?? { ok: true as const };
+ if (result.ok) {
+ res.writeHead(200, { 'content-type': result.contentType ?? 'text/plain' });
+ res.end(result.body ?? 'ok');
+ return;
+ }
+ const requestedStatusCode = result.statusCode;
+ const statusCode =
+ typeof requestedStatusCode === 'number' &&
+ Number.isInteger(requestedStatusCode) &&
+ requestedStatusCode >= 400 &&
+ requestedStatusCode <= 599
+ ? requestedStatusCode
+ : 503;
+ res.writeHead(statusCode, { 'content-type': result.contentType ?? 'text/plain' });
+ res.end(result.body ?? 'not ready');
+ } catch {
+ res.writeHead(503, { 'content-type': 'text/plain' });
+ res.end('not ready');
+ }
+ })();
+ return;
+ }
+
+ if (url.pathname === statusPath) {
+ void (async () => {
+ try {
+ const status = {
+ ok: true,
+ service: packageName,
+ version: packageVersion ?? null,
+ gitSha: gitSha ?? null,
+ gitDirty,
+ buildRef: gitSha ? `${gitSha}${gitDirty ? '-dirty' : ''}` : null,
+ startedAt,
+ uptimeMs: Number.isFinite(startedAtMs) ? Math.max(0, Date.now() - startedAtMs) : null,
+ resolveDocPath,
+ publicHttpBaseUrl: publicHttpBaseUrl ?? null,
+ publicWebSocketBaseUrl: publicWebSocketBaseUrl ?? null,
+ cacheTtlMs,
+ ...((await opts.statusInfo?.()) ?? {}),
+ };
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
+ res.end(JSON.stringify(status));
+ } catch {
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
+ res.end(JSON.stringify({ ok: false, error: 'status unavailable' }));
+ }
+ })();
+ return;
+ }
+
+ if (url.pathname === resolveDocPath) {
+ void (async () => {
+ try {
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, discoveryCorsHeaders);
+ res.end();
+ return;
+ }
+ if (req.method && req.method !== 'GET') {
+ res.writeHead(405, {
+ 'content-type': 'application/json; charset=utf-8',
+ ...discoveryCorsHeaders,
+ });
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
+ return;
+ }
+ const docId = url.searchParams.get('docId')?.trim();
+ if (!docId) {
+ res.writeHead(400, {
+ 'content-type': 'application/json; charset=utf-8',
+ ...discoveryCorsHeaders,
+ });
+ res.end(JSON.stringify({ ok: false, error: 'missing docId' }));
+ return;
+ }
+ const response = await resolveDoc({ docId }, { req, url });
+ res.writeHead(200, {
+ 'content-type': 'application/json; charset=utf-8',
+ ...discoveryCorsHeaders,
+ });
+ res.end(JSON.stringify(response));
+ } catch {
+ res.writeHead(500, {
+ 'content-type': 'application/json; charset=utf-8',
+ ...discoveryCorsHeaders,
+ });
+ res.end(JSON.stringify({ ok: false, error: 'resolve failed' }));
+ }
+ })();
+ return;
+ }
+
+ res.writeHead(404, { 'content-type': 'text/plain' });
+ res.end('not found');
+ });
+
+ await new Promise((resolve, reject) => {
+ const onError = (error: Error) => {
+ server.off('listening', onListening);
+ reject(error);
+ };
+ const onListening = () => {
+ server.off('error', onError);
+ resolve();
+ };
+ server.once('error', onError);
+ server.once('listening', onListening);
+ server.listen(port, host);
+ });
+
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ throw new Error('failed to start discovery server');
+ }
+
+ return {
+ host: address.address,
+ port: address.port,
+ close: async () => {
+ await new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ });
+ },
+ };
+}
diff --git a/packages/discovery-server-node/tests/server.test.ts b/packages/discovery-server-node/tests/server.test.ts
new file mode 100644
index 00000000..9256f1c2
--- /dev/null
+++ b/packages/discovery-server-node/tests/server.test.ts
@@ -0,0 +1,137 @@
+import http from 'node:http';
+
+import { expect, test } from 'vitest';
+
+import { startDiscoveryServer } from '../dist/index.js';
+
+async function httpRequest(
+ url: string,
+ opts: { method?: string; headers?: Record } = {},
+): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
+ return await new Promise((resolve, reject) => {
+ const req = http.request(
+ url,
+ {
+ method: opts.method ?? 'GET',
+ headers: opts.headers,
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
+ res.on('end', () => {
+ resolve({
+ status: res.statusCode ?? 0,
+ headers: res.headers,
+ body: Buffer.concat(chunks).toString('utf8'),
+ });
+ });
+ },
+ );
+ req.once('error', reject);
+ req.end();
+ });
+}
+
+test('resolve-doc returns a relay attachment plan', async () => {
+ const server = await startDiscoveryServer({
+ host: '127.0.0.1',
+ port: 0,
+ publicHttpBaseUrl: 'https://bootstrap.example.com',
+ publicWebSocketBaseUrl: 'wss://eu.sync.example.com',
+ cacheTtlMs: 42_000,
+ });
+
+ try {
+ const response = await httpRequest(
+ `http://${server.host}:${server.port}/resolve-doc?docId=abc123`,
+ );
+ expect(response.status).toBe(200);
+ expect(JSON.parse(response.body)).toEqual({
+ docId: 'abc123',
+ plan: {
+ topology: 'relay',
+ attachments: [
+ {
+ protocol: 'websocket',
+ role: 'preferred',
+ url: 'wss://eu.sync.example.com/sync',
+ },
+ {
+ protocol: 'https',
+ role: 'bootstrap',
+ url: 'https://bootstrap.example.com',
+ },
+ ],
+ cacheTtlMs: 42_000,
+ },
+ });
+ } finally {
+ await server.close();
+ }
+});
+
+test('resolve-doc derives public urls from forwarded headers', async () => {
+ const server = await startDiscoveryServer({
+ host: '127.0.0.1',
+ port: 0,
+ });
+
+ try {
+ const response = await httpRequest(
+ `http://${server.host}:${server.port}/resolve-doc?docId=abc123`,
+ {
+ headers: {
+ host: 'internal.local',
+ 'x-forwarded-host': 'sync.emhub.net',
+ 'x-forwarded-proto': 'https',
+ },
+ },
+ );
+ expect(response.status).toBe(200);
+ expect(JSON.parse(response.body)).toEqual({
+ docId: 'abc123',
+ plan: {
+ topology: 'relay',
+ attachments: [
+ {
+ protocol: 'websocket',
+ role: 'preferred',
+ url: 'wss://sync.emhub.net/sync',
+ },
+ {
+ protocol: 'https',
+ role: 'bootstrap',
+ url: 'https://sync.emhub.net',
+ },
+ ],
+ cacheTtlMs: 3_600_000,
+ },
+ });
+ } finally {
+ await server.close();
+ }
+});
+
+test('status endpoint reports configured bootstrap settings', async () => {
+ const server = await startDiscoveryServer({
+ host: '127.0.0.1',
+ port: 0,
+ resolveDocPath: '/bootstrap',
+ publicHttpBaseUrl: 'https://bootstrap.example.com',
+ publicWebSocketBaseUrl: 'wss://us.sync.example.com',
+ });
+
+ try {
+ const response = await httpRequest(`http://${server.host}:${server.port}/status`);
+ expect(response.status).toBe(200);
+ expect(JSON.parse(response.body)).toMatchObject({
+ ok: true,
+ service: '@treecrdt/discovery-server-node',
+ resolveDocPath: '/bootstrap',
+ publicHttpBaseUrl: 'https://bootstrap.example.com',
+ publicWebSocketBaseUrl: 'wss://us.sync.example.com',
+ });
+ } finally {
+ await server.close();
+ }
+});
diff --git a/packages/discovery-server-node/tsconfig.json b/packages/discovery-server-node/tsconfig.json
new file mode 100644
index 00000000..6b1b091e
--- /dev/null
+++ b/packages/discovery-server-node/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "declaration": true,
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/discovery/README.md b/packages/discovery/README.md
new file mode 100644
index 00000000..ab3071ab
--- /dev/null
+++ b/packages/discovery/README.md
@@ -0,0 +1,24 @@
+# @treecrdt/discovery
+
+Connect-time bootstrap contract for resolving a doc to an attachment plan.
+
+This package is intentionally separate from `@treecrdt/sync` and the CRDT core.
+It covers:
+
+- `resolveDoc`: return an attachment plan for a known doc
+- cache helpers for "resolve once, reconnect directly later"
+- shared types used by standalone bootstrap servers such as `@treecrdt/discovery-server-node`
+
+Typical flow:
+
+1. client calls `resolveDoc`
+2. client caches the returned attachment plan
+3. client connects directly to the returned websocket endpoint
+
+This keeps bootstrap out of the steady-state sync hot path.
+
+Out of scope:
+
+- sync protocol details
+- storage or backend implementation
+- regional routing policy
diff --git a/packages/discovery/package.json b/packages/discovery/package.json
new file mode 100644
index 00000000..68a48dbe
--- /dev/null
+++ b/packages/discovery/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@treecrdt/discovery",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "src/index.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./src/index.ts"
+ }
+ },
+ "files": [
+ "dist",
+ "src",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "test": "echo \"TODO: add discovery contract tests\""
+ },
+ "devDependencies": {
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/packages/discovery/src/client.ts b/packages/discovery/src/client.ts
new file mode 100644
index 00000000..8867a717
--- /dev/null
+++ b/packages/discovery/src/client.ts
@@ -0,0 +1,247 @@
+import type {
+ DiscoveryAttachment,
+ DiscoveryAttachmentProtocol,
+ DocAttachmentPlan,
+ DocDiscoveryService,
+ ResolveDocResponse,
+ ResolveDocRequest,
+} from './types.js';
+
+type Awaitable = T | Promise;
+
+export type DiscoveryStringStore = {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem?(key: string): void;
+};
+
+export type CachedResolvedDoc = {
+ resolvedAtMs: number;
+ response: ResolveDocResponse;
+};
+
+export interface DiscoveryRouteCache {
+ get(key: string): Awaitable;
+ set(key: string, entry: CachedResolvedDoc): Awaitable;
+ delete(key: string): Awaitable;
+}
+
+export type ResolveDocHttpClientOptions = {
+ baseUrl: string;
+ resolveDocPath?: string;
+ fetch?: typeof fetch;
+ headers?: Record;
+};
+
+export type ResolveWebSocketAttachmentResult = {
+ url: URL;
+ source: 'direct' | 'cache' | 'network';
+ response?: ResolveDocResponse;
+ cacheKey?: string;
+};
+
+function normalizeBaseUrl(raw: string, defaultProtocol: 'http' | 'https' | 'ws' | 'wss'): URL {
+ let input = raw.trim();
+ if (input.length === 0) throw new Error('Discovery endpoint is empty');
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(input)) input = `${defaultProtocol}://${input}`;
+ return new URL(input);
+}
+
+function normalizeDiscoveryBaseUrl(raw: string): URL {
+ const url = normalizeBaseUrl(raw, 'https');
+ if (url.protocol === 'ws:') url.protocol = 'http:';
+ if (url.protocol === 'wss:') url.protocol = 'https:';
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error('Discovery endpoint must use http://, https://, ws://, or wss://');
+ }
+ if (url.pathname.endsWith('/sync')) {
+ url.pathname = url.pathname.slice(0, -'/sync'.length) || '/';
+ url.search = '';
+ }
+ return url;
+}
+
+export function normalizeDirectSyncWebSocketUrl(raw: string, docId: string): URL {
+ const url = normalizeBaseUrl(raw, 'ws');
+ if (url.protocol === 'http:') url.protocol = 'ws:';
+ if (url.protocol === 'https:') url.protocol = 'wss:';
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
+ throw new Error('Sync server URL must use ws://, wss://, http://, or https://');
+ }
+ if (url.pathname === '/' || url.pathname.length === 0) {
+ url.pathname = '/sync';
+ }
+ url.searchParams.set('docId', docId);
+ return url;
+}
+
+export function isDiscoveryBootstrapUrl(raw: string): boolean {
+ const url = normalizeBaseUrl(raw, 'ws');
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
+ return url.pathname !== '/sync';
+}
+
+export function buildResolveDocUrl(
+ baseUrl: string,
+ docId: string,
+ resolveDocPath = '/resolve-doc',
+): URL {
+ const url = normalizeDiscoveryBaseUrl(baseUrl);
+ url.pathname = resolveDocPath.startsWith('/') ? resolveDocPath : `/${resolveDocPath}`;
+ url.search = '';
+ url.searchParams.set('docId', docId);
+ return url;
+}
+
+export function pickAttachment(
+ plan: DocAttachmentPlan,
+ protocol: DiscoveryAttachmentProtocol,
+ role?: DiscoveryAttachment['role'],
+): DiscoveryAttachment | undefined {
+ const exact = plan.attachments.find(
+ (attachment) => attachment.protocol === protocol && (role == null || attachment.role === role),
+ );
+ if (exact) return exact;
+ return plan.attachments.find((attachment) => attachment.protocol === protocol);
+}
+
+export function pickWebSocketAttachment(plan: DocAttachmentPlan): DiscoveryAttachment | undefined {
+ return pickAttachment(plan, 'websocket', 'preferred') ?? pickAttachment(plan, 'websocket');
+}
+
+export function createDiscoveryCacheKey(baseUrl: string, docId: string): string {
+ const normalized = normalizeDiscoveryBaseUrl(baseUrl);
+ return `${normalized.origin}${normalized.pathname}|${docId}`;
+}
+
+export function isCachedResolvedDocFresh(entry: CachedResolvedDoc, nowMs = Date.now()): boolean {
+ const ttlMs = entry.response.plan.cacheTtlMs;
+ if (ttlMs == null) return true;
+ return entry.resolvedAtMs + ttlMs > nowMs;
+}
+
+export function createStringStoreRouteCache(
+ store: DiscoveryStringStore,
+ prefix = 'treecrdt.discovery.',
+): DiscoveryRouteCache {
+ return {
+ get(key) {
+ const raw = store.getItem(`${prefix}${key}`);
+ if (!raw) return undefined;
+ try {
+ return JSON.parse(raw) as CachedResolvedDoc;
+ } catch {
+ store.removeItem?.(`${prefix}${key}`);
+ return undefined;
+ }
+ },
+ set(key, entry) {
+ store.setItem(`${prefix}${key}`, JSON.stringify(entry));
+ },
+ delete(key) {
+ store.removeItem?.(`${prefix}${key}`);
+ },
+ };
+}
+
+export function createHttpDocDiscoveryClient(
+ opts: ResolveDocHttpClientOptions,
+): Pick {
+ const fetchImpl = opts.fetch ?? fetch;
+ if (typeof fetchImpl !== 'function') {
+ throw new Error('fetch is not available; pass options.fetch explicitly');
+ }
+
+ const resolveDocPath = opts.resolveDocPath ?? '/resolve-doc';
+
+ const requestJson = async (url: URL, init: RequestInit): Promise => {
+ const res = await fetchImpl(url, {
+ ...init,
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ ...opts.headers,
+ ...(init.headers as Record | undefined),
+ },
+ });
+ if (!res.ok) {
+ throw new Error(`Discovery request failed (${res.status} ${res.statusText})`);
+ }
+ return (await res.json()) as T;
+ };
+
+ return {
+ async resolveDoc(request: ResolveDocRequest): Promise {
+ const url = buildResolveDocUrl(opts.baseUrl, request.docId, resolveDocPath);
+ return await requestJson(url, { method: 'GET' });
+ },
+ };
+}
+
+export async function resolveDocWithCache(opts: {
+ baseUrl: string;
+ docId: string;
+ client?: Pick;
+ cache?: DiscoveryRouteCache;
+ forceRefresh?: boolean;
+ fetch?: typeof fetch;
+ resolveDocPath?: string;
+}): Promise<{ response: ResolveDocResponse; source: 'cache' | 'network'; cacheKey: string }> {
+ const cacheKey = createDiscoveryCacheKey(opts.baseUrl, opts.docId);
+ if (!opts.forceRefresh && opts.cache) {
+ const cached = await opts.cache.get(cacheKey);
+ if (cached && isCachedResolvedDocFresh(cached)) {
+ return { response: cached.response, source: 'cache', cacheKey };
+ }
+ }
+
+ const client =
+ opts.client ??
+ createHttpDocDiscoveryClient({
+ baseUrl: opts.baseUrl,
+ fetch: opts.fetch,
+ resolveDocPath: opts.resolveDocPath,
+ });
+ const response = await client.resolveDoc({ docId: opts.docId });
+ await opts.cache?.set(cacheKey, {
+ resolvedAtMs: Date.now(),
+ response,
+ });
+ return { response, source: 'network', cacheKey };
+}
+
+export async function resolveWebSocketAttachment(opts: {
+ endpoint: string;
+ docId: string;
+ cache?: DiscoveryRouteCache;
+ fetch?: typeof fetch;
+ resolveDocPath?: string;
+ forceRefresh?: boolean;
+}): Promise {
+ if (!isDiscoveryBootstrapUrl(opts.endpoint)) {
+ return {
+ url: normalizeDirectSyncWebSocketUrl(opts.endpoint, opts.docId),
+ source: 'direct',
+ };
+ }
+
+ const { response, source, cacheKey } = await resolveDocWithCache({
+ baseUrl: opts.endpoint,
+ docId: opts.docId,
+ cache: opts.cache,
+ fetch: opts.fetch,
+ resolveDocPath: opts.resolveDocPath,
+ forceRefresh: opts.forceRefresh,
+ });
+ const attachment = pickWebSocketAttachment(response.plan);
+ if (!attachment) {
+ throw new Error(`Resolved doc ${opts.docId} does not include a websocket attachment`);
+ }
+ const url = normalizeDirectSyncWebSocketUrl(attachment.url, opts.docId);
+ return {
+ url,
+ source,
+ response,
+ cacheKey,
+ };
+}
diff --git a/packages/discovery/src/index.ts b/packages/discovery/src/index.ts
new file mode 100644
index 00000000..87d18788
--- /dev/null
+++ b/packages/discovery/src/index.ts
@@ -0,0 +1,2 @@
+export * from './types.js';
+export * from './client.js';
diff --git a/packages/discovery/src/types.ts b/packages/discovery/src/types.ts
new file mode 100644
index 00000000..832abb49
--- /dev/null
+++ b/packages/discovery/src/types.ts
@@ -0,0 +1,38 @@
+export type DiscoveryTopology = 'relay';
+
+export type DiscoveryAttachmentProtocol = 'websocket' | 'https';
+
+export type DiscoveryAttachment = {
+ protocol: DiscoveryAttachmentProtocol;
+ url: string;
+ role?: 'preferred' | 'bootstrap';
+};
+
+/**
+ * Attachment plan returned by the bootstrap layer.
+ */
+export type DocAttachmentPlan = {
+ topology: DiscoveryTopology;
+ attachments: DiscoveryAttachment[];
+ cacheTtlMs?: number;
+};
+
+export type ResolveDocRequest = {
+ docId: string;
+};
+
+export type ResolveDocResponse = {
+ docId: string;
+ plan: DocAttachmentPlan;
+};
+
+/**
+ * Connect-time bootstrap contract for document routing and attachment planning.
+ *
+ * This service is intentionally separate from the sync protocol hot path:
+ * clients should resolve once, cache the returned attachment plan, and then
+ * connect directly to the chosen data-plane endpoint for live traffic.
+ */
+export interface DocDiscoveryService {
+ resolveDoc(request: ResolveDocRequest): Promise;
+}
diff --git a/packages/discovery/tsconfig.json b/packages/discovery/tsconfig.json
new file mode 100644
index 00000000..6b1b091e
--- /dev/null
+++ b/packages/discovery/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "declaration": true,
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/sync/server/postgres-node/README.md b/packages/sync/server/postgres-node/README.md
index c42dabbe..c20af6b7 100644
--- a/packages/sync/server/postgres-node/README.md
+++ b/packages/sync/server/postgres-node/README.md
@@ -80,3 +80,4 @@ WebSocket endpoint:
- One `docId` per WebSocket connection.
- The backend module must export `createPostgresNapiSyncBackendFactory(url)`.
- Multi-instance fanout uses Postgres LISTEN/NOTIFY on the configured channel.
+- Run `pnpm discovery-server:local` when you want a separate local bootstrap endpoint for `resolveDoc`.
diff --git a/packages/treecrdt-engine-conformance/src/index.ts b/packages/treecrdt-engine-conformance/src/index.ts
index 9126805e..0b684e29 100644
--- a/packages/treecrdt-engine-conformance/src/index.ts
+++ b/packages/treecrdt-engine-conformance/src/index.ts
@@ -13,6 +13,7 @@ import {
type TreecrdtScopeEvaluator,
} from '@treecrdt/auth';
import { createInMemoryConnectedPeers } from '@treecrdt/sync/in-memory';
+import { makeQueuedSyncBackend, type FlushableSyncBackend } from '@treecrdt/sync/in-memory';
import { treecrdtSyncV0ProtobufCodec } from '@treecrdt/sync/protobuf';
import { createOpAuthStore, createPendingOpsStore } from '@treecrdt/sync-sqlite';
@@ -53,6 +54,24 @@ export function conformanceDocId(prefix: string, scenarioName: string): string {
return `${prefix}-${conformanceSlugify(scenarioName) || 'scenario'}`;
}
+async function waitUntil(
+ predicate: () => Promise | boolean,
+ opts: { timeoutMs?: number; intervalMs?: number; message?: string } = {},
+): Promise {
+ const timeoutMs = opts.timeoutMs ?? 2_000;
+ const intervalMs = opts.intervalMs ?? 10;
+ const start = Date.now();
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ const ok = await predicate();
+ if (ok) return;
+ if (Date.now() - start > timeoutMs) {
+ throw new Error(opts.message ?? `waitUntil timeout after ${timeoutMs}ms`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+}
+
function trackConformanceEngine(engine: TreecrdtEngine, engines: TreecrdtEngine[]): TreecrdtEngine {
const originalClose = engine.close.bind(engine);
let closed = false;
@@ -1105,17 +1124,22 @@ function makeCapabilityTokenV1(opts: {
});
}
-function createEngineSyncBackend(engine: TreecrdtEngine): SyncBackend {
- return {
+function maxLamportFromOps(ops: Operation[]): number {
+ return ops.reduce((max, op) => Math.max(max, op.meta.lamport), 0);
+}
+
+function createEngineSyncBackend(engine: TreecrdtEngine): FlushableSyncBackend {
+ return makeQueuedSyncBackend({
docId: engine.docId,
- maxLamport: async () => BigInt(await engine.meta.headLamport()),
+ initialMaxLamport: 0,
+ maxLamportFromOps,
listOpRefs: async (filter: Filter) => {
if ('all' in filter) return engine.opRefs.all();
return engine.opRefs.children(bytesToHex(filter.children.parent));
},
getOpsByOpRefs: async (opRefs: OpRef[]) => engine.ops.get(opRefs),
applyOps: async (ops: Operation[]) => engine.ops.appendMany(ops),
- };
+ });
}
async function findDepthBfs(opts: {
@@ -1247,19 +1271,23 @@ async function scenarioSyncAuthSignedOps(ctx: TreecrdtEngineConformanceContext):
{ all: {} },
{ maxCodewords: 10_000, codewordsPerMessage: 256 },
);
+ await Promise.all([backendA.flush(), backendB.flush()]);
+ await waitUntil(
+ async () => {
+ const [aRefs, bRefs] = await Promise.all([a.opRefs.all(), b.opRefs.all()]);
+ const aSet = new Set(aRefs.map((r) => bytesToHex(r)));
+ const bSet = new Set(bRefs.map((r) => bytesToHex(r)));
+ if (aSet.size !== bSet.size) return false;
+ return Array.from(aSet).every((r) => bSet.has(r));
+ },
+ {
+ timeoutMs: 15_000,
+ message: 'sync auth conformance: expected peers to converge',
+ },
+ );
} finally {
detach();
}
-
- const deadline = Date.now() + 5_000;
- while (true) {
- const [aRefs, bRefs] = await Promise.all([a.opRefs.all(), b.opRefs.all()]);
- const aSet = new Set(aRefs.map((r) => bytesToHex(r)));
- const bSet = new Set(bRefs.map((r) => bytesToHex(r)));
- if (aSet.size === bSet.size && Array.from(aSet).every((r) => bSet.has(r))) return;
- if (Date.now() > deadline) throw new Error('sync auth conformance: expected peers to converge');
- await new Promise((resolve) => setTimeout(resolve, 10));
- }
}
async function scenarioSyncAuthScopedTokenRejectsAllFilter(
diff --git a/packages/treecrdt-sqlite-node/scripts/bench-sync.ts b/packages/treecrdt-sqlite-node/scripts/bench-sync.ts
index d92d7c5c..37dbad78 100644
--- a/packages/treecrdt-sqlite-node/scripts/bench-sync.ts
+++ b/packages/treecrdt-sqlite-node/scripts/bench-sync.ts
@@ -109,6 +109,7 @@ type PreparedServerFixture = {
docId: string;
cacheKey?: string;
cacheStatus: 'disabled' | 'hit' | 'miss' | 'rebuild' | 'assumed';
+ cacheStatus: 'disabled' | 'hit' | 'miss' | 'rebuild' | 'assumed' | 'manifest';
seedUploadMs?: number;
seedAllReadyMs?: number;
filterReadyMs?: number;
@@ -123,6 +124,7 @@ type SyncBenchConnection = {
type SyncBenchTargetRuntime = {
id: Exclude;
serverProcess: 'child-process' | 'in-process' | 'remote';
+ fixtureCacheScope?: string;
connect: (docId: string) => Promise;
seedOps?: (docId: string, ops: Operation[]) => Promise;
inspectDoc?: (docId: string) => Promise<{ allCount: number; maxLamport: number }>;
@@ -240,6 +242,14 @@ const SYNC_BENCH_POST_SEED_WAIT_MS = Math.max(0, envInt('SYNC_BENCH_POST_SEED_WA
const DEFAULT_SERVER_FIXTURE_CACHE_MODE: ServerFixtureCacheMode = 'reuse';
const SYNC_BENCH_SERVER_FIXTURE_CACHE_VERSION = '2026-03-21-v1';
const SERVER_READY_POLL_MS = 100;
+const BENCH_REPO_ROOT = repoRootFromImportMeta(import.meta.url, 3);
+const SERVER_FIXTURE_MANIFEST_VERSION = 1;
+const SERVER_FIXTURE_MANIFEST_DIR = path.join(
+ BENCH_REPO_ROOT,
+ 'tmp',
+ 'sqlite-node-sync-bench',
+ 'server-fixtures',
+);
function envInt(name: string): number | undefined {
const raw = process.env[name];
@@ -1162,6 +1172,7 @@ async function createLocalPostgresSyncServerTarget(
id: 'local-postgres-sync-server',
serverProcess: 'in-process',
connect: async (docId) => await connectToSyncServer(`ws://127.0.0.1:${server.port}`, docId),
+ fixtureCacheScope: 'local-postgres-sync-server',
seedOps,
inspectDoc,
resetDoc,
@@ -1220,6 +1231,7 @@ async function createLocalPostgresSyncServerTarget(
id: 'local-postgres-sync-server',
serverProcess: 'child-process',
connect: async (docId) => await connectToSyncServer(`ws://127.0.0.1:${port}`, docId),
+ fixtureCacheScope: 'local-postgres-sync-server',
seedOps,
inspectDoc,
resetDoc,
@@ -1328,6 +1340,7 @@ function createRemoteSyncServerTarget(baseUrl: string): SyncBenchTargetRuntime {
id: 'remote-sync-server',
serverProcess: 'remote',
connect: async (docId) => await connectToSyncServer(baseUrl, docId, { client: 'builtin' }),
+ fixtureCacheScope: baseUrl,
close: async () => {},
};
}
@@ -1655,6 +1668,71 @@ function createServerFixtureCacheKey(bench: ReturnType {
+ try {
+ const raw = JSON.parse(await fs.readFile(fixtureManifestPath(runtime, cacheKey), 'utf8')) as {
+ version?: number;
+ runtimeId?: string;
+ scope?: string;
+ cacheKey?: string;
+ docId?: string;
+ };
+ if (
+ raw.version !== SERVER_FIXTURE_MANIFEST_VERSION ||
+ raw.runtimeId !== runtime.id ||
+ raw.scope !== fixtureCacheScopeForRuntime(runtime) ||
+ raw.cacheKey !== cacheKey ||
+ typeof raw.docId !== 'string' ||
+ raw.docId.length === 0
+ ) {
+ return undefined;
+ }
+ return raw.docId;
+ } catch {
+ return undefined;
+ }
+}
+
+async function writeServerFixtureManifest(
+ runtime: SyncBenchTargetRuntime,
+ cacheKey: string,
+ docId: string,
+): Promise {
+ await fs.mkdir(SERVER_FIXTURE_MANIFEST_DIR, { recursive: true });
+ await fs.writeFile(
+ fixtureManifestPath(runtime, cacheKey),
+ JSON.stringify(
+ {
+ version: SERVER_FIXTURE_MANIFEST_VERSION,
+ runtimeId: runtime.id,
+ scope: fixtureCacheScopeForRuntime(runtime),
+ cacheKey,
+ docId,
+ writtenAt: new Date().toISOString(),
+ },
+ null,
+ 2,
+ ),
+ );
+}
+
async function prepareServerFixture(
runtime: SyncBenchTargetRuntime,
bench: ReturnType,
@@ -1665,12 +1743,18 @@ async function prepareServerFixture(
const prepareStartedAt = performance.now();
const cacheKey = cacheMode === 'off' ? undefined : createServerFixtureCacheKey(bench);
const hasResettableFixture = runtime.resetDoc != null;
+ const manifestDocId =
+ cacheMode === 'reuse' && cacheKey != null && !runtime.inspectDoc
+ ? await readServerFixtureManifest(runtime, cacheKey)
+ : undefined;
const docId =
cacheMode === 'off'
? `sqlite-node-sync-bench-${runtime.id}-fixture-${crypto.randomUUID()}`
- : cacheMode === 'rebuild' && !hasResettableFixture
- ? `sqlite-node-sync-bench-${runtime.id}-fixture-${cacheKey}-${crypto.randomUUID()}`
- : `sqlite-node-sync-bench-${runtime.id}-fixture-${cacheKey}`;
+ : cacheMode === 'reuse' && manifestDocId != null
+ ? manifestDocId
+ : cacheMode === 'rebuild' && !hasResettableFixture
+ ? `sqlite-node-sync-bench-${runtime.id}-fixture-${cacheKey}-${crypto.randomUUID()}`
+ : `sqlite-node-sync-bench-${runtime.id}-fixture-${cacheKey}`;
if (cacheMode === 'reuse' && runtime.inspectDoc) {
try {
const current = await runtime.inspectDoc(docId);
@@ -1689,7 +1773,7 @@ async function prepareServerFixture(
return {
docId,
cacheKey,
- cacheStatus: 'assumed',
+ cacheStatus: manifestDocId != null ? 'manifest' : 'assumed',
};
}
if (cacheMode !== 'off') {
@@ -1719,6 +1803,9 @@ async function prepareServerFixture(
);
}
const filterReadyMs = performance.now() - filterReadyStartedAt;
+ if (cacheKey != null && !runtime.inspectDoc) {
+ await writeServerFixtureManifest(runtime, cacheKey, docId);
+ }
return {
docId,
cacheKey,
@@ -2276,7 +2363,7 @@ async function primeServerFixtureCase(
async function main() {
const argv = process.argv.slice(2);
- const repoRoot = repoRootFromImportMeta(import.meta.url, 3);
+ const repoRoot = BENCH_REPO_ROOT;
const iterationsOverride = parseIterationsOverride(argv);
const warmupIterationsOverride = parseWarmupIterationsOverride(argv);
diff --git a/packages/treecrdt-wa-sqlite-vendor/wa-sqlite b/packages/treecrdt-wa-sqlite-vendor/wa-sqlite
index 536e4369..3b3fe0ca 160000
--- a/packages/treecrdt-wa-sqlite-vendor/wa-sqlite
+++ b/packages/treecrdt-wa-sqlite-vendor/wa-sqlite
@@ -1 +1 @@
-Subproject commit 536e4369cf1e8145a2f9e72e7a17dfd10f40b4ad
+Subproject commit 3b3fe0cabf7e8edb60adfb6eac04a855561516e2
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 232a0b96..a8e58155 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
prettier:
specifier: ^3.8.1
version: 3.8.1
+ ws:
+ specifier: ^8.18.3
+ version: 8.19.0
examples/playground:
dependencies:
@@ -29,6 +32,9 @@ importers:
'@treecrdt/crypto':
specifier: workspace:*
version: link:../../packages/treecrdt-crypto
+ '@treecrdt/discovery':
+ specifier: workspace:*
+ version: link:../../packages/discovery
'@treecrdt/interface':
specifier: workspace:*
version: link:../../packages/treecrdt-ts
@@ -85,6 +91,31 @@ importers:
specifier: ^7.2.7
version: 7.2.7(@types/node@20.19.25)(jiti@1.21.7)(tsx@4.21.0)
+ packages/discovery:
+ devDependencies:
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
+ packages/discovery-server-node:
+ dependencies:
+ '@treecrdt/discovery':
+ specifier: workspace:*
+ version: link:../discovery
+ devDependencies:
+ '@types/node':
+ specifier: ^20.19.25
+ version: 20.19.25
+ tsx:
+ specifier: ^4.19.2
+ version: 4.21.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^1.6.0
+ version: 1.6.1(@types/node@20.19.25)
+
packages/sync/material/postgres:
dependencies:
'@treecrdt/interface':
@@ -291,6 +322,25 @@ importers:
specifier: ^1.6.0
version: 1.6.1(@types/node@20.19.25)
+ packages/treecrdt-engine-conformance:
+ dependencies:
+ '@treecrdt/auth':
+ specifier: workspace:*
+ version: link:../treecrdt-auth
+ '@treecrdt/interface':
+ specifier: workspace:*
+ version: link:../treecrdt-ts
+ '@treecrdt/sync':
+ specifier: workspace:*
+ version: link:../sync/protocol
+ '@treecrdt/sync-sqlite':
+ specifier: workspace:*
+ version: link:../sync/material/sqlite
+ devDependencies:
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
packages/treecrdt-postgres-napi:
dependencies:
'@treecrdt/interface':
@@ -331,25 +381,6 @@ importers:
specifier: ^1.6.0
version: 1.6.1(@types/node@20.19.25)
- packages/treecrdt-engine-conformance:
- dependencies:
- '@treecrdt/auth':
- specifier: workspace:*
- version: link:../treecrdt-auth
- '@treecrdt/interface':
- specifier: workspace:*
- version: link:../treecrdt-ts
- '@treecrdt/sync':
- specifier: workspace:*
- version: link:../sync/protocol
- '@treecrdt/sync-sqlite':
- specifier: workspace:*
- version: link:../sync/material/sqlite
- devDependencies:
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
packages/treecrdt-sqlite-node:
dependencies:
'@treecrdt/interface':
@@ -368,12 +399,12 @@ importers:
'@treecrdt/benchmark':
specifier: workspace:*
version: link:../treecrdt-benchmark
- '@treecrdt/postgres-napi':
- specifier: workspace:*
- version: link:../treecrdt-postgres-napi
'@treecrdt/engine-conformance':
specifier: workspace:*
version: link:../treecrdt-engine-conformance
+ '@treecrdt/postgres-napi':
+ specifier: workspace:*
+ version: link:../treecrdt-postgres-napi
'@treecrdt/sync':
specifier: workspace:*
version: link:../sync/protocol
@@ -1192,67 +1223,56 @@ packages:
resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.3':
resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.3':
resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.3':
resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.3':
resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.3':
resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.3':
resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.3':
resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.3':
resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.3':
resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.3':
resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.3':
resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
diff --git a/scripts/bench-discovery-connect.mjs b/scripts/bench-discovery-connect.mjs
new file mode 100644
index 00000000..912f76e0
--- /dev/null
+++ b/scripts/bench-discovery-connect.mjs
@@ -0,0 +1,316 @@
+import fs from 'node:fs/promises';
+import http from 'node:http';
+import https from 'node:https';
+import path from 'node:path';
+import process from 'node:process';
+import { createRequire } from 'node:module';
+import { fileURLToPath } from 'node:url';
+import { performance } from 'node:perf_hooks';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, '..');
+const require = createRequire(import.meta.url);
+const WebSocketImpl =
+ typeof globalThis.WebSocket === 'function' ? globalThis.WebSocket : require('ws');
+
+function usage() {
+ console.log(`Usage:
+ node scripts/bench-discovery-connect.mjs [options]
+
+Options:
+ --bootstrap-url=https://sync.emhub.net Discovery/bootstrap base URL (default: env TREECRDT_DISCOVERY_URL)
+ --doc-id=bootstrap-probe Known doc id to resolve/connect (default: bootstrap-probe)
+ --host-map=host=ip[,host=ip...] Optional hostname override(s) for resolve/connect
+ --iterations=N Measured samples (default: 5)
+ --warmup=N Warmup samples (default: 1)
+ --out=path.json Optional output path under benchmarks/
+`);
+}
+
+function getArg(name) {
+ const prefix = `--${name}=`;
+ const found = process.argv.find((arg) => arg.startsWith(prefix));
+ return found ? found.slice(prefix.length) : null;
+}
+
+function parseIntArg(name, defaultValue) {
+ const raw = getArg(name);
+ if (raw == null || raw.length === 0) return defaultValue;
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) throw new Error(`invalid --${name}: ${raw}`);
+ return parsed;
+}
+
+function parseHostMap(raw) {
+ if (!raw || raw.trim().length === 0) return new Map();
+ const entries = raw
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ .map((entry) => {
+ const [host, ip] = entry.split('=', 2).map((part) => part?.trim() ?? '');
+ if (!host || !ip) throw new Error(`invalid host map entry: ${entry}`);
+ return [host.toLowerCase(), ip];
+ });
+ return new Map(entries);
+}
+
+function median(values) {
+ if (values.length === 0) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+}
+
+function percentile(values, p) {
+ if (values.length === 0) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
+ return sorted[index];
+}
+
+function normalizeBootstrapUrl(raw) {
+ if (!raw || raw.trim().length === 0) throw new Error('missing bootstrap url');
+ let input = raw.trim();
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(input)) input = `https://${input}`;
+ const url = new URL(input);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error('bootstrap url must use http:// or https://');
+ }
+ url.pathname = '/';
+ url.search = '';
+ url.hash = '';
+ return url;
+}
+
+function normalizeDocId(raw) {
+ const value = raw?.trim() ?? '';
+ return value.length > 0 ? value : 'bootstrap-probe';
+}
+
+function attachDocId(rawUrl, docId) {
+ const url = new URL(rawUrl);
+ if (url.protocol === 'http:') url.protocol = 'ws:';
+ if (url.protocol === 'https:') url.protocol = 'wss:';
+ url.searchParams.set('docId', docId);
+ return url;
+}
+
+function createLookup(hostMap) {
+ return (hostname, options, callback) => {
+ const cb = typeof options === 'function' ? options : callback;
+ const opts = typeof options === 'function' ? {} : (options ?? {});
+ if (typeof cb !== 'function') {
+ throw new Error('lookup callback missing');
+ }
+ const mapped = hostMap.get(String(hostname).toLowerCase());
+ if (mapped) {
+ const family = mapped.includes(':') ? 6 : 4;
+ if (opts?.all) {
+ cb(null, [{ address: mapped, family }]);
+ } else {
+ cb(null, mapped, family);
+ }
+ return;
+ }
+ cb(new Error(`no host-map entry for ${hostname}`));
+ };
+}
+
+async function requestJson(url, hostMap) {
+ const startedAt = performance.now();
+ if (hostMap.size === 0) {
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(`resolve-doc failed (${res.status} ${res.statusText})`);
+ }
+ return {
+ durationMs: performance.now() - startedAt,
+ json: await res.json(),
+ };
+ }
+
+ const transport = url.protocol === 'https:' ? https : http;
+ const hostname = url.hostname.toLowerCase();
+ const mappedIp = hostMap.get(hostname);
+ if (!mappedIp) throw new Error(`missing host-map entry for ${hostname}`);
+
+ const json = await new Promise((resolve, reject) => {
+ const req = transport.request(
+ {
+ protocol: url.protocol,
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: `${url.pathname}${url.search}`,
+ method: 'GET',
+ lookup: createLookup(hostMap),
+ servername: url.hostname,
+ headers: {
+ accept: '*/*',
+ 'user-agent': 'treecrdt-bench-discovery/1.0',
+ },
+ },
+ (res) => {
+ if ((res.statusCode ?? 500) < 200 || (res.statusCode ?? 500) >= 300) {
+ reject(new Error(`resolve-doc failed (${res.statusCode ?? 500})`));
+ res.resume();
+ return;
+ }
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ body += chunk;
+ });
+ res.on('end', () => {
+ try {
+ resolve(JSON.parse(body));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ },
+ );
+ req.on('error', reject);
+ req.end();
+ });
+
+ return {
+ durationMs: performance.now() - startedAt,
+ json,
+ };
+}
+
+async function connectWebSocket(url, hostMap) {
+ const startedAt = performance.now();
+ const lookup = hostMap.size > 0 ? createLookup(hostMap) : undefined;
+ const WsCtor = lookup ? require('ws') : WebSocketImpl;
+ const ws = lookup
+ ? new WsCtor(url.toString(), {
+ lookup,
+ headers: { Host: url.host },
+ servername: url.hostname,
+ })
+ : new WsCtor(url.toString());
+ await new Promise((resolve, reject) => {
+ if (typeof ws.once === 'function') {
+ ws.once('open', resolve);
+ ws.once('error', reject);
+ return;
+ }
+ const cleanup = () => {
+ ws.removeEventListener?.('open', onOpen);
+ ws.removeEventListener?.('error', onError);
+ };
+ const onOpen = () => {
+ cleanup();
+ resolve();
+ };
+ const onError = (event) => {
+ cleanup();
+ reject(event?.error ?? new Error('websocket error'));
+ };
+ ws.addEventListener?.('open', onOpen);
+ ws.addEventListener?.('error', onError);
+ });
+ const durationMs = performance.now() - startedAt;
+ ws.close();
+ await new Promise((resolve) => {
+ if (typeof ws.once === 'function') {
+ ws.once('close', resolve);
+ return;
+ }
+ const onClose = () => {
+ ws.removeEventListener?.('close', onClose);
+ resolve();
+ };
+ ws.addEventListener?.('close', onClose);
+ });
+ return durationMs;
+}
+
+async function resolveDoc(bootstrapUrl, docId, hostMap) {
+ const url = new URL('/resolve-doc', bootstrapUrl);
+ url.searchParams.set('docId', docId);
+ const { durationMs, json } = await requestJson(url, hostMap);
+ const attachment =
+ json?.plan?.attachments?.find?.(
+ (entry) => entry.protocol === 'websocket' && entry.role === 'preferred',
+ ) ?? json?.plan?.attachments?.find?.((entry) => entry.protocol === 'websocket');
+ if (!attachment?.url) {
+ throw new Error('resolve-doc response did not include a websocket attachment');
+ }
+ return { durationMs, response: json, attachmentUrl: attachment.url };
+}
+
+async function main() {
+ if (process.argv.includes('--help')) {
+ usage();
+ return;
+ }
+
+ const bootstrapUrl = normalizeBootstrapUrl(
+ getArg('bootstrap-url') ?? process.env.TREECRDT_DISCOVERY_URL ?? '',
+ );
+ const docId = normalizeDocId(getArg('doc-id') ?? process.env.TREECRDT_DISCOVERY_DOC_ID ?? '');
+ const hostMap = parseHostMap(getArg('host-map') ?? process.env.TREECRDT_BENCH_HOST_MAP ?? '');
+ const iterations = parseIntArg('iterations', 5);
+ const warmup = parseIntArg('warmup', 1);
+ const totalRuns = warmup + iterations;
+ const resolveSamplesMs = [];
+ const connectSamplesMs = [];
+ const totalSamplesMs = [];
+ const cachedConnectSamplesMs = [];
+
+ for (let i = 0; i < totalRuns; i += 1) {
+ const resolved = await resolveDoc(bootstrapUrl, docId, hostMap);
+ const connectUrl = attachDocId(resolved.attachmentUrl, docId);
+ const connectMs = await connectWebSocket(connectUrl, hostMap);
+ const cachedConnectMs = await connectWebSocket(connectUrl, hostMap);
+
+ if (i >= warmup) {
+ resolveSamplesMs.push(resolved.durationMs);
+ connectSamplesMs.push(connectMs);
+ totalSamplesMs.push(resolved.durationMs + connectMs);
+ cachedConnectSamplesMs.push(cachedConnectMs);
+ }
+ }
+
+ const result = {
+ benchmark: 'discovery-bootstrap-connect',
+ bootstrapUrl: bootstrapUrl.toString().replace(/\/$/, ''),
+ docId,
+ hostMap: Object.fromEntries(hostMap),
+ iterations,
+ warmup,
+ resolveSamplesMs,
+ connectSamplesMs,
+ totalSamplesMs,
+ cachedConnectSamplesMs,
+ resolveMedianMs: median(resolveSamplesMs),
+ connectMedianMs: median(connectSamplesMs),
+ totalMedianMs: median(totalSamplesMs),
+ cachedConnectMedianMs: median(cachedConnectSamplesMs),
+ totalP95Ms: percentile(totalSamplesMs, 95),
+ cachedConnectP95Ms: percentile(cachedConnectSamplesMs, 95),
+ generatedAt: new Date().toISOString(),
+ };
+
+ const outPath =
+ getArg('out') ??
+ path.join(
+ repoRoot,
+ 'benchmarks',
+ 'discovery-connect',
+ `discovery-connect-${bootstrapUrl.host.replace(/[^a-z0-9.-]+/gi, '_')}.json`,
+ );
+
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
+ await fs.writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
+ console.log(JSON.stringify(result, null, 2));
+ console.log(`wrote ${outPath}`);
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});