@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.
- package/.claude/skills/railroad/SKILL.md +67 -62
- package/README.md +56 -20
- package/delta-client.ts +246 -0
- package/delta-server.ts +234 -0
- package/delta.ts +66 -0
- package/index.ts +5 -3
- package/jsx.ts +72 -52
- package/package.json +6 -3
- package/routes.ts +32 -14
- package/signals.ts +14 -6
|
@@ -1,94 +1,99 @@
|
|
|
1
|
-
|
|
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
|
-
**
|
|
6
|
-
|
|
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
|
|
14
|
+
// tsconfig.json
|
|
16
15
|
{ "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
// server.ts
|
|
21
|
-
import home from "./index.html";
|
|
22
|
-
Bun.serve({ routes: { "/": home } });
|
|
23
|
-
```
|
|
18
|
+
## Mental Model
|
|
24
19
|
|
|
25
|
-
|
|
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
|
-
|
|
22
|
+
```tsx
|
|
23
|
+
// Bare signal — auto-reactive
|
|
24
|
+
<span>{count}</span>
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
## Delta-doc
|
|
39
56
|
|
|
40
|
-
|
|
57
|
+
Real-time JSON document sync over WebSocket. Server persists to files, client gets reactive signals.
|
|
41
58
|
|
|
42
|
-
|
|
59
|
+
**Read source for full API:** `delta.ts` · `delta-server.ts` · `delta-client.ts`
|
|
43
60
|
|
|
44
61
|
```ts
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
provide(WS, connectWs("/ws"));
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
88
|
-
2. **No `.get()` in JSX children.**
|
|
89
|
-
3. **No
|
|
90
|
-
4. **No
|
|
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.
|
|
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 {
|
|
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
|
-
####
|
|
150
|
+
#### Reactive expressions — function children
|
|
150
151
|
|
|
151
152
|
```tsx
|
|
152
|
-
<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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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,
|
|
195
|
+
import { routes, navigate, route, when, effect } from "@blueshed/railroad";
|
|
185
196
|
|
|
186
|
-
|
|
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>{
|
|
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,
|
|
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,
|
|
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
|
|
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/
|
package/delta-client.ts
ADDED
|
@@ -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
|
+
}
|