Skip to content

Commit 95df399

Browse files
feat(discovery): add standalone bootstrap server
1 parent 8bb9968 commit 95df399

18 files changed

Lines changed: 744 additions & 362 deletions

File tree

docs/BENCHMARKS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ This is the benchmark to use when you want a direct before/after comparison betw
306306
Use `benchmark:sync:bootstrap` when you want to isolate the one-time discovery
307307
layer from the steady-state sync path.
308308

309+
The benchmark target can be a standalone bootstrap server such as
310+
`@treecrdt/discovery-server-node`, not just a colocated sync-server route.
311+
309312
It measures:
310313

311314
- `resolveSamplesMs`: `GET /resolve-doc?docId=...`

examples/playground/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
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.
44

55
## Features
6+
67
- Insert children under any node, reorder siblings (up/down), move nodes back to the root, or delete them (root is protected).
78
- Collapsible tree with per-node controls and a composer form to target any parent.
89
- 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
1112
- Optional auth/ACL demo (COSE_Sign1 + CWT subtree capabilities) with invite links, per-op signatures, and a pending-op inspector.
1213

1314
## Running locally
15+
1416
```bash
1517
pnpm install --filter @treecrdt/playground
1618
pnpm -C examples/playground dev
@@ -27,14 +29,16 @@ pnpm build
2729
pnpm sync-server:postgres:db:start
2830
# Start the TreeCRDT sync server on ws://localhost:8787 using that Postgres DB.
2931
pnpm sync-server:postgres:local
32+
# Start the standalone bootstrap server on http://localhost:8788.
33+
pnpm discovery-server:local
3034
# Start the playground UI.
3135
pnpm -C examples/playground dev
3236
```
3337

3438
Then in the playground:
3539

3640
- Open the `Connections` panel
37-
- Paste `ws://localhost:8787` into `Remote sync server`
41+
- Paste `http://localhost:8788` into `Remote sync / bootstrap`
3842
- Leave mode as `Hybrid`, or switch to `Remote server` if you want to disable local tab sync
3943

4044
## Public sync server
@@ -49,6 +53,9 @@ The playground will call `/resolve-doc` once, cache the returned websocket
4953
attachment, and then connect directly to the resolved `wss://.../sync`
5054
endpoint.
5155

56+
If you want to skip bootstrap entirely, you can still paste a direct websocket
57+
endpoint such as `ws://localhost:8787`.
58+
5259
`pnpm sync-server:postgres:db:start` starts a disposable local Postgres at:
5360

5461
```bash
@@ -95,6 +102,7 @@ pnpm --filter @treecrdt/wa-sqlite-vendor rebuild
95102
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.
96103

97104
## Building / deploying to GitHub Pages
105+
98106
```bash
99107
pnpm -C examples/playground build # outputs to dist/
100108
pnpm -C examples/playground deploy # pushes dist/ via gh-pages

examples/playground/src/playground/components/PeersPanel.tsx

Lines changed: 71 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,44 @@
1-
import React from "react";
2-
import { MdCheckCircle, MdCloudOff, MdCloudQueue, MdErrorOutline, MdSync } from "react-icons/md";
1+
import React from 'react';
2+
import { MdCheckCircle, MdCloudOff, MdCloudQueue, MdErrorOutline, MdSync } from 'react-icons/md';
33

4-
import { PLAYGROUND_PUBLIC_DISCOVERY_URL } from "../constants";
5-
import type { PeerInfo, RemoteSyncStatus, SyncTransportMode } from "../types";
4+
import { PLAYGROUND_PUBLIC_DISCOVERY_URL } from '../constants';
5+
import type { PeerInfo, RemoteSyncStatus, SyncTransportMode } from '../types';
66

77
function formatPeerId(id: string): string {
8-
if (id.startsWith("remote:")) return `remote(${id.slice("remote:".length)})`;
8+
if (id.startsWith('remote:')) return `remote(${id.slice('remote:'.length)})`;
99
return id.length > 18 ? `${id.slice(0, 8)}${id.slice(-6)}` : id;
1010
}
1111

1212
function transportModeButtonClass(active: boolean): string {
1313
return active
14-
? "border-accent bg-accent/15 text-white"
15-
: "border-slate-700 bg-slate-900/70 text-slate-300 hover:border-slate-500 hover:text-white";
14+
? 'border-accent bg-accent/15 text-white'
15+
: 'border-slate-700 bg-slate-900/70 text-slate-300 hover:border-slate-500 hover:text-white';
1616
}
1717

1818
function remoteStatusTone(status: RemoteSyncStatus): string {
1919
switch (status.state) {
20-
case "connected":
21-
return "border-emerald-500/40 bg-emerald-500/10 text-emerald-100";
22-
case "connecting":
23-
return "border-sky-500/40 bg-sky-500/10 text-sky-100";
24-
case "disabled":
25-
return "border-slate-700 bg-slate-900/70 text-slate-400";
26-
case "missing_url":
27-
return "border-amber-500/30 bg-amber-500/10 text-amber-100";
28-
case "invalid":
29-
case "error":
30-
return "border-rose-500/40 bg-rose-500/10 text-rose-100";
20+
case 'connected':
21+
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100';
22+
case 'connecting':
23+
return 'border-sky-500/40 bg-sky-500/10 text-sky-100';
24+
case 'disabled':
25+
return 'border-slate-700 bg-slate-900/70 text-slate-400';
26+
case 'missing_url':
27+
return 'border-amber-500/30 bg-amber-500/10 text-amber-100';
28+
case 'invalid':
29+
case 'error':
30+
return 'border-rose-500/40 bg-rose-500/10 text-rose-100';
3131
}
3232
}
3333

3434
function RemoteStatusIcon({ status }: { status: RemoteSyncStatus }) {
35-
if (status.state === "connected") {
35+
if (status.state === 'connected') {
3636
return <MdCheckCircle className="text-[14px]" />;
3737
}
38-
if (status.state === "connecting") {
38+
if (status.state === 'connecting') {
3939
return <MdSync className="text-[14px]" />;
4040
}
41-
if (status.state === "disabled") {
41+
if (status.state === 'disabled') {
4242
return <MdCloudOff className="text-[14px]" />;
4343
}
4444
return <MdErrorOutline className="text-[14px]" />;
@@ -63,7 +63,7 @@ export function PeersPanel({
6363
remoteSyncStatus: RemoteSyncStatus;
6464
peers: PeerInfo[];
6565
}) {
66-
const requiresRemoteUrl = syncTransportMode !== "local";
66+
const requiresRemoteUrl = syncTransportMode !== 'local';
6767
const hasRemoteUrl = syncServerUrl.trim().length > 0;
6868

6969
return (
@@ -73,74 +73,86 @@ export function PeersPanel({
7373
>
7474
<div className="flex flex-wrap items-start justify-between gap-3">
7575
<div>
76-
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">Connections</div>
76+
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
77+
Connections
78+
</div>
7779
<div className="mt-1 max-w-xl text-[11px] text-slate-400">
78-
Choose how this tab syncs. Local tabs use `BroadcastChannel`. Remote transport can use either a direct
79-
websocket sync endpoint or an HTTP bootstrap endpoint.
80+
Choose how this tab syncs. Local tabs use `BroadcastChannel`. Remote transport can use
81+
either a direct websocket sync endpoint or a separate HTTP bootstrap endpoint.
8082
</div>
8183
</div>
8284
<button
8385
className={`flex h-8 items-center gap-2 rounded-lg border px-3 text-[11px] font-semibold transition ${
8486
online
85-
? "border-slate-700 bg-slate-800/70 text-slate-200 hover:border-accent hover:text-white"
86-
: "border-amber-500/60 bg-amber-500/10 text-amber-100 hover:border-amber-400"
87+
? 'border-slate-700 bg-slate-800/70 text-slate-200 hover:border-accent hover:text-white'
88+
: 'border-amber-500/60 bg-amber-500/10 text-amber-100 hover:border-amber-400'
8789
}`}
8890
onClick={() => setOnline((v) => !v)}
8991
type="button"
90-
title={online ? "Pause sync activity" : "Resume sync activity"}
92+
title={online ? 'Pause sync activity' : 'Resume sync activity'}
9193
>
92-
{online ? <MdCloudQueue className="text-[16px]" /> : <MdCloudOff className="text-[16px]" />}
93-
<span>{online ? "Sync enabled" : "Sync paused"}</span>
94+
{online ? (
95+
<MdCloudQueue className="text-[16px]" />
96+
) : (
97+
<MdCloudOff className="text-[16px]" />
98+
)}
99+
<span>{online ? 'Sync enabled' : 'Sync paused'}</span>
94100
</button>
95101
</div>
96102

97103
<div className="mt-3 rounded-lg border border-slate-800/70 bg-slate-950/30 p-2">
98-
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">Transport</div>
104+
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
105+
Transport
106+
</div>
99107
<div className="mt-2 flex flex-wrap gap-2">
100108
<button
101109
type="button"
102-
className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === "local")}`}
103-
onClick={() => setSyncTransportMode("local")}
110+
className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === 'local')}`}
111+
onClick={() => setSyncTransportMode('local')}
104112
>
105113
Local tabs
106114
</button>
107115
<button
108116
type="button"
109-
className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === "remote")}`}
110-
onClick={() => setSyncTransportMode("remote")}
117+
className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === 'remote')}`}
118+
onClick={() => setSyncTransportMode('remote')}
111119
>
112120
Remote server
113121
</button>
114122
<button
115123
type="button"
116-
className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === "hybrid")}`}
117-
onClick={() => setSyncTransportMode("hybrid")}
124+
className={`rounded-md border px-3 py-1.5 text-[11px] font-semibold transition ${transportModeButtonClass(syncTransportMode === 'hybrid')}`}
125+
onClick={() => setSyncTransportMode('hybrid')}
118126
>
119127
Hybrid
120128
</button>
121129
</div>
122130
<div className="mt-2 text-[11px] text-slate-500">
123-
{syncTransportMode === "local" && "Only same-origin tabs in this browser will sync."}
124-
{syncTransportMode === "remote" && "Only the configured websocket sync server will be used."}
125-
{syncTransportMode === "hybrid" && "Use both same-origin tabs and the configured websocket sync server."}
131+
{syncTransportMode === 'local' && 'Only same-origin tabs in this browser will sync.'}
132+
{syncTransportMode === 'remote' &&
133+
'Only the configured remote websocket or bootstrap endpoint will be used.'}
134+
{syncTransportMode === 'hybrid' &&
135+
'Use both same-origin tabs and the configured remote websocket or bootstrap endpoint.'}
126136
</div>
127137
</div>
128138

129139
<div className="mt-3 rounded-lg border border-slate-800/70 bg-slate-950/30 p-2">
130140
<div className="flex items-center justify-between gap-2">
131-
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">Remote sync server</div>
141+
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
142+
Remote sync / bootstrap
143+
</div>
132144
<div
133145
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[10px] font-semibold ${remoteStatusTone(remoteSyncStatus)}`}
134146
title={remoteSyncStatus.detail}
135147
>
136148
<RemoteStatusIcon status={remoteSyncStatus} />
137149
<span>
138-
{remoteSyncStatus.state === "connected" && "Connected"}
139-
{remoteSyncStatus.state === "connecting" && "Connecting"}
140-
{remoteSyncStatus.state === "disabled" && "Inactive"}
141-
{remoteSyncStatus.state === "missing_url" && "Missing URL"}
142-
{remoteSyncStatus.state === "invalid" && "Invalid URL"}
143-
{remoteSyncStatus.state === "error" && "Unreachable"}
150+
{remoteSyncStatus.state === 'connected' && 'Connected'}
151+
{remoteSyncStatus.state === 'connecting' && 'Connecting'}
152+
{remoteSyncStatus.state === 'disabled' && 'Inactive'}
153+
{remoteSyncStatus.state === 'missing_url' && 'Missing URL'}
154+
{remoteSyncStatus.state === 'invalid' && 'Invalid URL'}
155+
{remoteSyncStatus.state === 'error' && 'Unreachable'}
144156
</span>
145157
</div>
146158
</div>
@@ -151,20 +163,20 @@ export function PeersPanel({
151163
onChange={(event) => {
152164
const next = event.target.value;
153165
setSyncServerUrl(next);
154-
if (syncTransportMode === "local" && next.trim().length > 0) {
155-
setSyncTransportMode("hybrid");
166+
if (syncTransportMode === 'local' && next.trim().length > 0) {
167+
setSyncTransportMode('hybrid');
156168
}
157169
}}
158-
placeholder={`${PLAYGROUND_PUBLIC_DISCOVERY_URL} or ws://localhost:8787/sync`}
170+
placeholder={`${PLAYGROUND_PUBLIC_DISCOVERY_URL} or ws://localhost:8787`}
159171
spellCheck={false}
160172
/>
161173
<button
162174
type="button"
163175
className="rounded-md border border-slate-700 px-2 py-1.5 text-[11px] font-semibold text-slate-300 transition hover:border-slate-500 hover:text-white"
164176
onClick={() => {
165177
setSyncServerUrl(PLAYGROUND_PUBLIC_DISCOVERY_URL);
166-
if (syncTransportMode === "local") {
167-
setSyncTransportMode("hybrid");
178+
if (syncTransportMode === 'local') {
179+
setSyncTransportMode('hybrid');
168180
}
169181
}}
170182
title="Use the public emhub.net bootstrap endpoint"
@@ -174,7 +186,7 @@ export function PeersPanel({
174186
<button
175187
type="button"
176188
className="rounded-md border border-slate-700 px-2 py-1.5 text-[11px] font-semibold text-slate-300 transition hover:border-slate-500 hover:text-white disabled:opacity-50"
177-
onClick={() => setSyncServerUrl("")}
189+
onClick={() => setSyncServerUrl('')}
178190
disabled={syncServerUrl.trim().length === 0}
179191
title="Clear remote sync server URL"
180192
>
@@ -189,14 +201,18 @@ export function PeersPanel({
189201

190202
<div className="mt-3 rounded-lg border border-slate-800/70 bg-slate-950/30 p-2">
191203
<div className="flex items-center justify-between gap-2">
192-
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">Connected peers</div>
204+
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
205+
Connected peers
206+
</div>
193207
<div className="text-[10px] text-slate-500">{peers.length}</div>
194208
</div>
195209
<div className="mt-2 max-h-32 overflow-auto pr-1">
196210
{peers.map((p) => (
197211
<div key={p.id} className="flex items-center justify-between gap-2 py-1">
198212
<span className="font-mono text-slate-200">{formatPeerId(p.id)}</span>
199-
<span className="text-[10px] text-slate-500">{Math.max(0, Date.now() - p.lastSeen)}ms</span>
213+
<span className="text-[10px] text-slate-500">
214+
{Math.max(0, Date.now() - p.lastSeen)}ms
215+
</span>
200216
</div>
201217
))}
202218
{peers.length === 0 && (

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"benchmark:web": "pnpm -C packages/treecrdt-wa-sqlite/e2e run bench",
5151
"test": "pnpm run test:browser && pnpm run test:native-node",
5252
"playground": "pnpm --filter @treecrdt/playground dev",
53+
"discovery-server": "pnpm --filter @treecrdt/discovery-server-node dev",
54+
"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",
5355
"sync-server:postgres:setup": "pnpm --filter @treecrdt/postgres-napi... --filter @treecrdt/sync-server-postgres-node... run build",
5456
"sync-server:postgres": "pnpm --filter @treecrdt/sync-server-postgres-node dev",
5557
"sync-server:postgres:local": "node scripts/run-sync-server-postgres-local.mjs",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# TreeCRDT Discovery Server (Node)
2+
3+
Small standalone HTTP bootstrap service for `resolveDoc`.
4+
5+
It is intentionally separate from the websocket sync server:
6+
7+
- discovery/bootstrap happens once at connect time
8+
- clients cache the returned attachment plan
9+
- steady-state sync then talks directly to the resolved `ws://` or `wss://` endpoint
10+
11+
## Run locally
12+
13+
From the repo root:
14+
15+
```sh
16+
pnpm discovery-server:local
17+
```
18+
19+
This starts a bootstrap server on `http://localhost:8788` that advertises:
20+
21+
- bootstrap base: `http://localhost:8788`
22+
- sync websocket base: `ws://localhost:8787`
23+
24+
## Environment
25+
26+
- `HOST` (default: `0.0.0.0`)
27+
- `PORT` (default: `8788`)
28+
- `TREECRDT_DISCOVERY_RESOLVE_PATH` (default: `/resolve-doc`)
29+
- `TREECRDT_DISCOVERY_PUBLIC_HTTP_BASE_URL` (optional absolute HTTP base URL advertised to clients)
30+
- `TREECRDT_DISCOVERY_PUBLIC_WS_BASE_URL` (optional absolute websocket base URL advertised to clients)
31+
- `TREECRDT_DISCOVERY_CACHE_TTL_MS` (default: `3600000`)
32+
33+
## Endpoints
34+
35+
- `GET /health`
36+
- `GET /status`
37+
- `GET /resolve-doc?docId=YOUR_DOC_ID`
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@treecrdt/discovery-server-node",
3+
"version": "0.0.1",
4+
"private": true,
5+
"type": "module",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"import": "./dist/index.js",
11+
"types": "./dist/index.d.ts"
12+
}
13+
},
14+
"files": [
15+
"dist",
16+
"src",
17+
"README.md"
18+
],
19+
"scripts": {
20+
"dev": "pnpm --filter @treecrdt/discovery-server-node^... run build && tsx src/cli.ts",
21+
"build": "tsc -p tsconfig.json",
22+
"test": "pnpm -C ../discovery run build && pnpm run build && vitest run"
23+
},
24+
"dependencies": {
25+
"@treecrdt/discovery": "workspace:*"
26+
},
27+
"devDependencies": {
28+
"@types/node": "^20.19.25",
29+
"tsx": "^4.19.2",
30+
"typescript": "^5.9.3",
31+
"vitest": "^1.6.0"
32+
}
33+
}

0 commit comments

Comments
 (0)