@blueshed/railroad 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,94 +1,99 @@
1
- # Railroad — Skill for Claude
1
+ ---
2
+ name: railroad
3
+ description: "Railroad — micro reactive UI framework for Bun. Use when writing JSX components with signals, routes, when(), list(), delta-doc, or importing @blueshed/railroad."
4
+ ---
2
5
 
3
6
  Micro reactive UI framework for Bun. ~900 lines, zero dependencies, real DOM.
4
7
 
5
- **Source files are the canonical reference.** Each has a JSDoc header with full API docs. Read the source when you need detail:
6
- - `signals.ts` signal, computed, effect, batch, dispose scopes
7
- - `jsx.ts` — createElement, Fragment, text, when, list, SVG adoption
8
- - `routes.ts` — routes, route, navigate, matchRoute
9
- - `shared.ts` — key, provide, inject, tryInject
10
- - `logger.ts` — createLogger, setLogLevel, loggedRequest
8
+ **Read the source files for full API detail** each has a JSDoc header:
9
+ `signals.ts` · `jsx.ts` · `routes.ts` · `shared.ts` · `logger.ts` · `delta.ts` · `delta-server.ts` · `delta-client.ts`
11
10
 
12
11
  ## Setup
13
12
 
14
13
  ```json
15
- // tsconfig.json — automatic runtime (recommended)
14
+ // tsconfig.json
16
15
  { "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
17
16
  ```
18
17
 
19
- ```ts
20
- // server.ts
21
- import home from "./index.html";
22
- Bun.serve({ routes: { "/": home } });
23
- ```
18
+ ## Mental Model
24
19
 
25
- ## API Quick Reference
20
+ Components run **once**. They return real DOM nodes. Reactivity comes from signals — not re-rendering. Effects and computeds auto-dispose when their parent scope (component, route, `when`, `list`) tears down.
26
21
 
27
- ### Signals (`signals.ts`)
22
+ ```tsx
23
+ // Bare signal — auto-reactive
24
+ <span>{count}</span>
28
25
 
29
- ```ts
30
- signal<T>(value): Signal<T> // mutable reactive value
31
- computed<T>(fn): Signal<T> // derived, auto-tracked, auto-disposed in scope
32
- effect(fn): Dispose // side-effect, returns dispose function
33
- batch(fn): void // group writes, flush once
26
+ // Function child — auto-reactive expression
27
+ <span>{() => count.get() > 5 ? "High" : "Low"}</span>
28
+
29
+ // Signal.map() derive a signal for attributes and list items
30
+ <input disabled={count.map(n => n > 10)} />
31
+ {list(todos, t => t.id, (todo$) => <li>{todo$.map(t => t.name)}</li>)}
34
32
  ```
35
33
 
36
- Signal methods: `.get()` `.set(v)` `.update(fn)` `.mutate(fn)` `.patch(partial)` `.peek()`
34
+ ## Key Patterns
35
+
36
+ ```tsx
37
+ // Reactive attributes — .map() or computed()
38
+ <div class={visible.map(v => v ? "show" : "hide")}>...</div>
39
+
40
+ // Keyed list — render gets Signal<T>, use .map() for content
41
+ {list(todos, t => t.id, (todo$, idx$) => (
42
+ <li class={idx$.map(i => i % 2 ? "odd" : "even")}>
43
+ {todo$.map(t => t.name)}
44
+ </li>
45
+ ))}
46
+
47
+ // Nested routes — wildcard keeps layout mounted, route() for sub-navigation
48
+ routes(app, { "/sites/*": () => <SitesLayout /> });
49
+ function SitesLayout() {
50
+ const detail = route<{ id: string }>("/sites/:id");
51
+ return when(() => detail.get(), () => <SiteDetail />, () => <SitesList />);
52
+ }
53
+ ```
37
54
 
38
- Dispose scopes: `pushDisposeScope()` `popDisposeScope(): Dispose` `trackDispose(fn)`
55
+ ## Delta-doc
39
56
 
40
- ### JSX (`jsx.ts`)
57
+ Real-time JSON document sync over WebSocket. Server persists to files, client gets reactive signals.
41
58
 
42
- Components are functions called **once**. Reactivity comes from signals, not re-rendering.
59
+ **Read source for full API:** `delta.ts` · `delta-server.ts` · `delta-client.ts`
43
60
 
44
61
  ```ts
45
- createElement(tag, props, ...children): Node
46
- Fragment(props): DocumentFragment
47
- text(fn): Node // reactive computed text node
48
- when(condition, truthy, falsy?): Node // conditional swap on truthiness transition
49
- list(items, keyFn, render): Node // keyed list, render gets Signal<T>, Signal<number>
50
- list(items, render): Node // index-based list, render gets raw T
51
- ```
52
-
53
- Props: `class`/`className`, `style` (object or Signal), `value`/`checked`/`disabled`/`selected`/`src`/`srcdoc` (as DOM properties), `innerHTML`, `ref(el)`, `on*` events. All support Signal values for reactivity.
62
+ // Server register docs and methods
63
+ import { createWs, registerDoc, registerMethod } from "@blueshed/railroad/delta-server";
54
64
 
55
- SVG: `<svg>` children are auto-adopted into SVG namespace.
65
+ const ws = createWs();
66
+ await registerDoc<Message>(ws, "message", { file: "./data/message.json", empty: { message: "" } });
67
+ registerMethod(ws, "status", () => ({ bun: Bun.version }));
56
68
 
57
- ### Routes (`routes.ts`)
58
-
59
- ```ts
60
- routes(target, table): Dispose // hash router, auto-disposes on swap
61
- route<T>(pattern): Signal<T | null> // reactive route match
62
- navigate(path): void // set location.hash
63
- matchRoute(pattern, path): params | null
69
+ const server = Bun.serve({ routes: { "/ws": ws.upgrade }, websocket: ws.websocket });
70
+ ws.setServer(server);
64
71
  ```
65
72
 
66
- Handlers receive `(params, params$)` — plain object + Signal. Destructure the first, watch the second for same-pattern param changes (e.g. `/users/1` → `/users/2`).
73
+ ```tsx
74
+ // Client — open docs as reactive signals
75
+ import { provide } from "@blueshed/railroad";
76
+ import { connectWs, WS, openDoc, call } from "@blueshed/railroad/delta-client";
67
77
 
68
- ### Shared (`shared.ts`)
78
+ provide(WS, connectWs("/ws"));
69
79
 
70
- ```ts
71
- key<T>(name): Key<T> // typed symbol key
72
- provide<T>(key, value): void // register value
73
- inject<T>(key): T // retrieve value (throws if missing)
74
- tryInject<T>(key): T | undefined // retrieve value (returns undefined if missing)
80
+ const message = openDoc<Message>("message");
81
+ effect(() => console.log(message.data.get()));
82
+ message.send([{ op: "replace", path: "/message", value: "hello" }]);
83
+
84
+ const status = await call<Status>("status");
75
85
  ```
76
86
 
77
- ### Logger (`logger.ts`)
87
+ **Delta ops** use JSON Pointer paths:
88
+ - `{ op: "replace", path: "/field", value: "new" }` — set
89
+ - `{ op: "add", path: "/items/-", value: item }` — append to array
90
+ - `{ op: "remove", path: "/items/0" }` — delete by index
78
91
 
79
- ```ts
80
- createLogger(tag): { info, warn, error, debug }
81
- setLogLevel(level): void // "error" | "warn" | "info" | "debug"
82
- loggedRequest(tag, handler): Handler // wrap route handler with access logging
83
- ```
92
+ Multiple ops in one `send()` are atomic.
84
93
 
85
94
  ## Anti-Patterns
86
95
 
87
- 1. **No React.** No useState, useEffect, hooks, lifecycle methods, memo, or react imports.
88
- 2. **No `.get()` in JSX children.** `<p>{count}</p>` is reactive. `<p>{count.get()}</p>` is static.
89
- 3. **No `text()` for attributes.** `text()` creates a DOM node. Use `computed()` for reactive attributes.
90
- 4. **No JSX in effects without dispose scopes.** Any effect that rebuilds DOM must use `pushDisposeScope`/`popDisposeScope` and return a cleanup function. See `jsx.ts` source for the pattern.
91
- 5. **No `transition-all` in CSS** near layout boundaries. Use specific properties.
92
- 6. **No bare nested `when()`.** `when()` returns a fragment — nesting fragments inside another `when()` breaks dispose scope tracking. Always wrap an inner `when()` in a real element: `<div>{when(...)}</div>`.
93
- 7. **No shared DOM nodes across `when()` branches.** Nodes must be created fresh inside each branch function. A node created outside and reused across branches will be torn out of the DOM when the other branch activates.
94
- 8. **Guard against null inside `when()` branches.** Signal cascade order is not guaranteed — an inner `when()` can fire before the outer `when()` swaps it away. Always null-check even inside a branch that "shouldn't" be reached (e.g. `text(() => item.get()?.name ?? "")`).
96
+ 1. **No React.** No useState, useEffect, hooks, lifecycle methods, or react imports.
97
+ 2. **No `.get()` in JSX children.** `{count}` or `{() => count.get() + 1}` never `{count.get()}`.
98
+ 3. **No shared DOM nodes across `when()` branches.** Create nodes fresh inside each branch.
99
+ 4. **No `transition-all` in CSS** near layout boundaries. Use specific properties.
package/README.md CHANGED
@@ -37,7 +37,7 @@ function Home() {
37
37
  <div>
38
38
  <h1>Hello World</h1>
39
39
  <button onclick={() => count.update(n => n + 1)}>
40
- Count: {count}
40
+ {() => `Count: ${count.get()}`}
41
41
  </button>
42
42
  </div>
43
43
  );
@@ -74,7 +74,7 @@ function Home() {
74
74
  <div>
75
75
  <h1>Hello World</h1>
76
76
  <button onclick={() => count.update(n => n + 1)}>
77
- Count: {count}
77
+ {() => `Count: ${count.get()}`}
78
78
  </button>
79
79
  </div>
80
80
  );
@@ -107,6 +107,7 @@ import { signal, computed, effect, batch } from "@blueshed/railroad";
107
107
 
108
108
  const count = signal(0);
109
109
  const doubled = computed(() => count.get() * 2);
110
+ const label = count.map(n => `Count: ${n}`); // derive a signal
110
111
 
111
112
  const dispose = effect(() => {
112
113
  console.log(`count is ${count.get()}`);
@@ -134,10 +135,10 @@ dispose(); // stop listening
134
135
 
135
136
  ### JSX
136
137
 
137
- Components are functions that return DOM nodes. Signals in children and props auto-update.
138
+ Components are functions that run **once** and return DOM nodes. Reactivity comes from signals, not re-rendering.
138
139
 
139
140
  ```tsx
140
- import { createElement, text, when, list, signal } from "@blueshed/railroad";
141
+ import { signal, when, list } from "@blueshed/railroad";
141
142
 
142
143
  const name = signal("World");
143
144
 
@@ -146,10 +147,18 @@ function Greeting() {
146
147
  }
147
148
  ```
148
149
 
149
- #### `text(fn)`reactive computed text
150
+ #### Reactive expressions function children
150
151
 
151
152
  ```tsx
152
- <span>{text(() => count.get() > 5 ? "High" : "Low")}</span>
153
+ <span>{() => count.get() > 5 ? "High" : "Low"}</span>
154
+ <p>{() => `${first.get()} ${last.get()}`}</p>
155
+ ```
156
+
157
+ #### Reactive attributes — `computed()` or `.map()`
158
+
159
+ ```tsx
160
+ <div class={visible.map(v => v ? "show" : "hide")}>...</div>
161
+ <input disabled={count.map(n => n > 10)} />
153
162
  ```
154
163
 
155
164
  #### `when(condition, truthy, falsy?)` — conditional rendering
@@ -162,15 +171,17 @@ function Greeting() {
162
171
  )}
163
172
  ```
164
173
 
174
+ Nestable — `when()` inside `when()` works without wrapper elements.
175
+
165
176
  #### `list(items, keyFn?, render)` — keyed list rendering
166
177
 
167
178
  ```tsx
168
179
  // Keyed — render receives Signal<T> and Signal<number>:
169
- {list(
170
- todos,
171
- (t) => t.id,
172
- (todo, idx) => <li>{text(() => todo.get().name)}</li>,
173
- )}
180
+ {list(todos, t => t.id, (todo$, idx$) => (
181
+ <li class={idx$.map(i => i % 2 ? "odd" : "even")}>
182
+ {todo$.map(t => t.name)}
183
+ </li>
184
+ ))}
174
185
 
175
186
  // Non-keyed (index-based, raw values):
176
187
  {list(items, (item, i) => <li>{item}</li>)}
@@ -178,19 +189,17 @@ function Greeting() {
178
189
 
179
190
  ### Routes
180
191
 
181
- Hash-based client router with automatic dispose scoping. Handlers receive `(params, params$)` — destructure the first for convenience, watch the second for reactive param changes.
192
+ Hash-based client router. Handlers receive `(params, params$)` — destructure the first for convenience, watch the second for reactive param changes.
182
193
 
183
194
  ```tsx
184
- import { routes, navigate, effect, text } from "@blueshed/railroad";
195
+ import { routes, navigate, route, when, effect } from "@blueshed/railroad";
185
196
 
186
- const dispose = routes(app, {
197
+ routes(app, {
187
198
  "/": () => <Home />,
188
- // Simple — destructure params as before:
189
199
  "/about": () => <About />,
190
- // Reactive — watch params$ for same-pattern navigation (/users/1 → /users/2):
191
200
  "/users/:id": ({ id }, params$) => {
192
201
  effect(() => fetchUser(params$.get().id));
193
- return <h1>{text(() => `User ${params$.get().id}`)}</h1>;
202
+ return <h1>{params$.map(p => `User ${p.id}`)}</h1>;
194
203
  },
195
204
  "*": () => <NotFound />,
196
205
  });
@@ -198,6 +207,33 @@ const dispose = routes(app, {
198
207
  navigate("/users/42");
199
208
  ```
200
209
 
210
+ #### Nested routes
211
+
212
+ Use wildcard patterns to keep a layout mounted while sub-views swap:
213
+
214
+ ```tsx
215
+ routes(app, {
216
+ "/": () => <Home />,
217
+ "/sites/*": () => <SitesLayout />,
218
+ });
219
+
220
+ function SitesLayout() {
221
+ const detail = route<{ id: string }>("/sites/:id");
222
+
223
+ return (
224
+ <div>
225
+ <SitesNav />
226
+ {when(() => detail.get(),
227
+ () => <SiteDetail params$={detail} />,
228
+ () => <SitesList />,
229
+ )}
230
+ </div>
231
+ );
232
+ }
233
+ ```
234
+
235
+ Navigate `/sites` → `/sites/42` → `/sites/99`: `SitesLayout` stays mounted, only the inner content swaps. Navigate away from `/sites/*`: layout tears down cleanly.
236
+
201
237
  ### Shared
202
238
 
203
239
  Typed dependency injection without prop threading.
@@ -235,9 +271,9 @@ const handler = loggedRequest("[api]", myHandler);
235
271
  ## Design
236
272
 
237
273
  - **Signals hold state** — reactive primitives with automatic dependency tracking
238
- - **Effects update the DOM** — run when dependencies change, return cleanup
274
+ - **Effects update the DOM** — run when dependencies change, auto-cleanup in scope
239
275
  - **JSX creates the DOM** — real elements, not virtual. Signal-aware props and children
240
- - **Routes swap the DOM** — hash-based, dispose-scoped, Bun.serve-style tables
276
+ - **Routes swap the DOM** — hash-based, auto-scoped, nestable via wildcards
241
277
 
242
278
  No lifecycle methods. No hooks rules. No context providers. No `useCallback`. Just signals and the DOM.
243
279
 
@@ -275,7 +311,7 @@ Every import path (`/signals`, `/shared`, `/logger`, `/jsx`, `/routes`) works st
275
311
 
276
312
  ## Claude Code
277
313
 
278
- This package ships with a [Claude Code](https://claude.com/claude-code) skill in `.claude/skills/railroad/`. Copy it into your project so Claude generates correct railroad code — including API usage, patterns, and anti-patterns:
314
+ This package ships with a [Claude Code](https://claude.com/claude-code) skill in `.claude/skills/railroad/`. Copy it into your project so Claude generates correct railroad code:
279
315
 
280
316
  ```sh
281
317
  cp -r node_modules/@blueshed/railroad/.claude/skills/railroad .claude/skills/
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Delta Client — WebSocket client + reactive document sync for the browser.
3
+ *
4
+ * Provides the client half of the delta-doc system:
5
+ * - connectWs() — reconnecting WebSocket with request/response and notifications
6
+ * - openDoc() — open a persisted document as a reactive signal
7
+ * - call() — invoke a stateless RPC method
8
+ *
9
+ * Usage:
10
+ * import { connectWs, openDoc, call, WS } from "@blueshed/railroad/delta-client";
11
+ * import { provide } from "@blueshed/railroad/shared";
12
+ *
13
+ * provide(WS, connectWs("/ws"));
14
+ *
15
+ * const message = openDoc<Message>("message");
16
+ * effect(() => console.log(message.data.get()));
17
+ * message.send([{ op: "replace", path: "/message", value: "hello" }]);
18
+ *
19
+ * const status = await call<Status>("status");
20
+ */
21
+ import { signal, createLogger } from "./index";
22
+ import { key, inject } from "./shared";
23
+ import { applyOps, type DeltaOp } from "./delta";
24
+
25
+ export type { DeltaOp } from "./delta";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Reconnecting WebSocket
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function reconnectingWebSocket(url: string): WebSocket {
32
+ let ws!: WebSocket;
33
+ const proxy = new EventTarget();
34
+ let backoff = 500;
35
+
36
+ function connect() {
37
+ ws = new WebSocket(url);
38
+ ws.addEventListener("open", () => {
39
+ backoff = 500;
40
+ proxy.dispatchEvent(new Event("open"));
41
+ });
42
+ ws.addEventListener("message", (e: MessageEvent) => {
43
+ proxy.dispatchEvent(new MessageEvent("message", { data: e.data }));
44
+ });
45
+ ws.addEventListener("close", () => {
46
+ proxy.dispatchEvent(new Event("close"));
47
+ setTimeout(connect, (backoff = Math.min(backoff * 2, 30_000)));
48
+ });
49
+ ws.addEventListener("error", () => {
50
+ proxy.dispatchEvent(new Event("error"));
51
+ });
52
+ }
53
+
54
+ (proxy as any).send = (data: string) => {
55
+ if (ws.readyState === WebSocket.OPEN) ws.send(data);
56
+ };
57
+
58
+ Object.defineProperty(proxy, "readyState", {
59
+ get: () => ws.readyState,
60
+ });
61
+
62
+ connect();
63
+ return proxy as unknown as WebSocket;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // WebSocket client
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export type NotifyHandler = (msg: any) => void;
71
+
72
+ export interface WsClient {
73
+ connected: ReturnType<typeof signal<boolean>>;
74
+ send(msg: any): Promise<any>;
75
+ on(event: string, handler: NotifyHandler): () => void;
76
+ }
77
+
78
+ export const WS = key<WsClient>("ws");
79
+
80
+ /** Connect to a delta-server WebSocket endpoint. */
81
+ export function connectWs(
82
+ wsPath: string = "/ws",
83
+ opts?: { clientId?: string },
84
+ ): WsClient {
85
+ const log = createLogger("[ws]");
86
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
87
+ const query = opts?.clientId ? `?clientId=${opts.clientId}` : "";
88
+ const connected = signal(false);
89
+ const ws = reconnectingWebSocket(
90
+ `${proto}//${location.host}${wsPath}${query}`,
91
+ );
92
+ const pending = new Map<
93
+ number,
94
+ { resolve: (v: any) => void; reject: (e: any) => void }
95
+ >();
96
+ const listeners = new Map<string, Set<NotifyHandler>>();
97
+ let nextId = 1;
98
+ let readyResolve: () => void;
99
+ let ready = new Promise<void>((r) => {
100
+ readyResolve = r;
101
+ });
102
+
103
+ ws.addEventListener("open", () => {
104
+ log.info("connected");
105
+ connected.set(true);
106
+ readyResolve();
107
+ listeners.get("open")?.forEach((fn) => fn({}));
108
+ });
109
+
110
+ ws.addEventListener("close", () => {
111
+ log.info("disconnected");
112
+ connected.set(false);
113
+ ready = new Promise<void>((r) => {
114
+ readyResolve = r;
115
+ });
116
+ listeners.get("close")?.forEach((fn) => fn({}));
117
+ });
118
+
119
+ ws.addEventListener(
120
+ "message",
121
+ ((ev: MessageEvent) => {
122
+ const msg = JSON.parse(ev.data);
123
+ if (msg.id != null && pending.has(msg.id)) {
124
+ const { resolve, reject } = pending.get(msg.id)!;
125
+ pending.delete(msg.id);
126
+ if (msg.error) {
127
+ log.error(`#${msg.id} error: ${msg.error.message}`);
128
+ reject(msg.error);
129
+ } else {
130
+ log.debug(`#${msg.id} ack`);
131
+ resolve(msg.result);
132
+ }
133
+ } else {
134
+ log.debug(`notify ${JSON.stringify(msg).slice(0, 80)}`);
135
+ listeners.get("message")?.forEach((fn) => fn(msg));
136
+ }
137
+ }) as EventListener,
138
+ );
139
+
140
+ return {
141
+ connected,
142
+ async send(msg: any): Promise<any> {
143
+ await ready;
144
+ return new Promise((resolve, reject) => {
145
+ const id = nextId++;
146
+ pending.set(id, { resolve, reject });
147
+ log.debug(`#${id} ${msg.action} ${msg.doc ?? msg.method ?? ""}`);
148
+ ws.send(JSON.stringify({ ...msg, id }));
149
+ });
150
+ },
151
+ on(event: string, handler: NotifyHandler): () => void {
152
+ if (!listeners.has(event)) listeners.set(event, new Set());
153
+ listeners.get(event)!.add(handler);
154
+ return () => listeners.get(event)!.delete(handler);
155
+ },
156
+ };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Document — reactive signal backed by a server-side delta-doc
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export interface Doc<T> {
164
+ data: ReturnType<typeof signal<T | null>>;
165
+ dataVersion: ReturnType<typeof signal<number>>;
166
+ ready: Promise<void>;
167
+ send(ops: DeltaOp[]): Promise<any>;
168
+ }
169
+
170
+ const log = createLogger("[doc]");
171
+ const openDocs = new Map<
172
+ string,
173
+ { data: ReturnType<typeof signal<any>>; dataVersion: ReturnType<typeof signal<number>> }
174
+ >();
175
+
176
+ /** Lazily resolve the WS client — deferred so openDoc can be called at module level. */
177
+ let _ws: WsClient | null = null;
178
+ function ws(): WsClient {
179
+ if (!_ws) {
180
+ _ws = inject(WS);
181
+
182
+ _ws.on("open", () => {
183
+ for (const [name, entry] of openDocs) {
184
+ _ws!.send({ action: "open", doc: name }).then((state) => {
185
+ entry.data.set(state);
186
+ entry.dataVersion.set(entry.dataVersion.peek() + 1);
187
+ });
188
+ }
189
+ });
190
+
191
+ _ws.on("message", (msg) => {
192
+ if (msg.doc && msg.ops) {
193
+ const entry = openDocs.get(msg.doc);
194
+ if (entry) {
195
+ const current = entry.data.peek();
196
+ if (current) {
197
+ const updated = structuredClone(current);
198
+ applyOps(updated, msg.ops);
199
+ entry.data.set(updated);
200
+ entry.dataVersion.set(entry.dataVersion.peek() + 1);
201
+ }
202
+ }
203
+ }
204
+ });
205
+ }
206
+ return _ws;
207
+ }
208
+
209
+ /** Open a persisted doc as a reactive signal. Safe to call at module level. */
210
+ export function openDoc<T>(name: string): Doc<T> {
211
+ const data = signal<T | null>(null);
212
+ const dataVersion = signal(0);
213
+ openDocs.set(name, { data, dataVersion });
214
+
215
+ let readyResolve: () => void;
216
+ const ready = new Promise<void>((r) => {
217
+ readyResolve = r;
218
+ });
219
+
220
+ queueMicrotask(() => {
221
+ try {
222
+ ws()
223
+ .send({ action: "open", doc: name })
224
+ .then((state) => {
225
+ data.set(state as T);
226
+ readyResolve();
227
+ });
228
+ } catch (err: any) {
229
+ log.error(`openDoc("${name}"): ${err.message}`);
230
+ }
231
+ });
232
+
233
+ return {
234
+ data,
235
+ dataVersion,
236
+ ready,
237
+ send(ops: DeltaOp[]) {
238
+ return ws().send({ action: "delta", doc: name, ops });
239
+ },
240
+ };
241
+ }
242
+
243
+ /** Call a stateless RPC method. */
244
+ export function call<T>(method: string, params?: any): Promise<T> {
245
+ return ws().send({ action: "call", method, params });
246
+ }