@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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Delta Server — WebSocket protocol layer + document/method registration for Bun.
3
+ *
4
+ * Provides the server half of the delta-doc system:
5
+ * - createWs() — shared WebSocket infrastructure (action routing, pub/sub, upgrade)
6
+ * - registerDoc() — persist a typed JSON document, sync via delta ops
7
+ * - registerMethod() — expose a stateless RPC handler
8
+ *
9
+ * Usage:
10
+ * import { createWs, registerDoc, registerMethod } from "@blueshed/railroad/delta-server";
11
+ *
12
+ * const ws = createWs();
13
+ * await registerDoc<Message>(ws, "message", { file: "./message.json", empty: { message: "" } });
14
+ * registerMethod(ws, "status", () => ({ bun: Bun.version }));
15
+ *
16
+ * const server = Bun.serve({ routes: { "/ws": ws.upgrade }, websocket: ws.websocket });
17
+ * ws.setServer(server);
18
+ */
19
+ import { createLogger } from "./logger";
20
+ import { applyOps, type DeltaOp } from "./delta";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export type ActionHandler = (
27
+ msg: any,
28
+ ws: any,
29
+ respond: (result: any) => void,
30
+ ) => any | Promise<any>;
31
+
32
+ export interface WsServer {
33
+ on(action: string, handler: ActionHandler): void;
34
+ publish(channel: string, data: any): void;
35
+ sendTo(clientId: string, data: any): void;
36
+ setServer(s: any): void;
37
+ upgrade: (req: Request, server: any) => Response | undefined;
38
+ websocket: {
39
+ idleTimeout: number;
40
+ sendPings: boolean;
41
+ publishToSelf: boolean;
42
+ open(ws: any): void;
43
+ message(ws: any, raw: any): void;
44
+ close(ws: any): void;
45
+ };
46
+ }
47
+
48
+ export interface DocHandle<T> {
49
+ getDoc(): T;
50
+ setDoc(d: T): void;
51
+ persist(): Promise<void>;
52
+ applyAndBroadcast(ops: DeltaOp[]): void;
53
+ }
54
+
55
+ export interface DocOptions<T> {
56
+ file: string;
57
+ empty: T;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // WebSocket server
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** Create a shared WebSocket server with action routing and Bun pub/sub. */
65
+ export function createWs(): WsServer {
66
+ const log = createLogger("[ws]");
67
+ const actions = new Map<string, ActionHandler[]>();
68
+ const clients = new Map<string, any>();
69
+ let serverRef: any;
70
+
71
+ return {
72
+ on(action: string, handler: ActionHandler) {
73
+ if (!actions.has(action)) actions.set(action, []);
74
+ actions.get(action)!.push(handler);
75
+ },
76
+
77
+ publish(channel: string, data: any) {
78
+ serverRef?.publish(channel, JSON.stringify(data));
79
+ },
80
+
81
+ sendTo(clientId: string, data: any) {
82
+ const ws = clients.get(clientId);
83
+ if (ws?.readyState === 1) ws.send(JSON.stringify(data));
84
+ },
85
+
86
+ setServer(s: any) {
87
+ serverRef = s;
88
+ },
89
+
90
+ upgrade: (req: Request, server: any) => {
91
+ const url = new URL(req.url, "http://localhost");
92
+ const clientId = url.searchParams.get("clientId") ?? crypto.randomUUID();
93
+ if (server.upgrade(req, { data: { clientId } })) return;
94
+ return new Response("WebSocket upgrade failed", { status: 400 });
95
+ },
96
+
97
+ websocket: {
98
+ idleTimeout: 60,
99
+ sendPings: true,
100
+ publishToSelf: true,
101
+ open(ws: any) {
102
+ const clientId = ws.data?.clientId;
103
+ if (clientId) clients.set(clientId, ws);
104
+ for (const ch of ws.data?.channels ?? []) ws.subscribe(ch);
105
+ log.debug(`open id=${clientId ?? "?"}`);
106
+ },
107
+ async message(ws: any, raw: any) {
108
+ const msg = JSON.parse(String(raw));
109
+ const { id, action } = msg;
110
+
111
+ if (!action) {
112
+ for (const handler of actions.get("_raw") ?? []) {
113
+ await handler(msg, ws, () => {});
114
+ }
115
+ return;
116
+ }
117
+
118
+ try {
119
+ const handlers = actions.get(action);
120
+ if (!handlers?.length) {
121
+ if (id)
122
+ ws.send(
123
+ JSON.stringify({
124
+ id,
125
+ error: { code: -1, message: `Unknown action: ${action}` },
126
+ }),
127
+ );
128
+ return;
129
+ }
130
+ let responded = false;
131
+ const respond = (response: any) => {
132
+ if (!responded && id) {
133
+ responded = true;
134
+ ws.send(JSON.stringify({ id, ...response }));
135
+ }
136
+ };
137
+ for (const handler of handlers) {
138
+ await handler(msg, ws, respond);
139
+ if (responded) break;
140
+ }
141
+ if (!responded && id) {
142
+ ws.send(
143
+ JSON.stringify({
144
+ id,
145
+ error: { code: -1, message: `No handler matched: ${action}` },
146
+ }),
147
+ );
148
+ }
149
+ } catch (err: any) {
150
+ log.error(`error: ${err.message}`);
151
+ if (id)
152
+ ws.send(
153
+ JSON.stringify({ id, error: { code: -1, message: err.message } }),
154
+ );
155
+ }
156
+ },
157
+ close(ws: any) {
158
+ const clientId = ws.data?.clientId;
159
+ if (clientId) clients.delete(clientId);
160
+ log.debug(`close id=${clientId ?? "?"}`);
161
+ },
162
+ },
163
+ };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Document registration
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /** Register a persisted JSON document with the WebSocket server. */
171
+ export async function registerDoc<T>(
172
+ ws: Pick<WsServer, "on" | "publish">,
173
+ name: string,
174
+ opts: DocOptions<T>,
175
+ ): Promise<DocHandle<T>> {
176
+ const log = createLogger(`[${name}]`);
177
+ const dataFile = Bun.file(opts.file);
178
+ let doc: T = (await dataFile.exists())
179
+ ? { ...structuredClone(opts.empty), ...((await dataFile.json()) as T) }
180
+ : structuredClone(opts.empty);
181
+
182
+ log.info(`loaded from ${opts.file}`);
183
+
184
+ async function persist() {
185
+ await Bun.write(dataFile, JSON.stringify(doc, null, 2));
186
+ }
187
+
188
+ function applyAndBroadcast(ops: DeltaOp[]) {
189
+ applyOps(doc, ops);
190
+ log.info(`delta [${ops.map((o) => `${o.op} ${o.path}`).join(", ")}]`);
191
+ ws.publish(name, { doc: name, ops });
192
+ persist();
193
+ }
194
+
195
+ ws.on("open", (msg, client, respond) => {
196
+ if (msg.doc !== name) return;
197
+ client.subscribe(name);
198
+ respond({ result: doc });
199
+ log.debug("opened");
200
+ });
201
+
202
+ ws.on("delta", (msg, _client, respond) => {
203
+ if (msg.doc !== name) return;
204
+ applyAndBroadcast(msg.ops);
205
+ respond({ result: { ack: true } });
206
+ });
207
+
208
+ ws.on("close", (msg, client, respond) => {
209
+ if (msg.doc !== name) return;
210
+ client.unsubscribe(name);
211
+ respond({ result: { ack: true } });
212
+ log.debug("closed");
213
+ });
214
+
215
+ return { getDoc: () => doc, setDoc: (d: T) => { doc = d; }, persist, applyAndBroadcast };
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Method registration
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /** Register a stateless RPC method with the WebSocket server. */
223
+ export function registerMethod(
224
+ ws: Pick<WsServer, "on">,
225
+ name: string,
226
+ handler: (params: any, client: any) => any | Promise<any>,
227
+ ) {
228
+ ws.on("call", async (msg, client, respond) => {
229
+ if (msg.method !== name) return;
230
+ const log = createLogger(`[${name}]`);
231
+ log.debug("called");
232
+ respond({ result: await handler(msg.params, client) });
233
+ });
234
+ }
package/delta.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Delta — shared types and operations for JSON document patching.
3
+ *
4
+ * Used by both delta-server (apply + persist) and delta-client (apply + render).
5
+ * No dependencies — safe to import anywhere.
6
+ *
7
+ * Delta ops use JSON Pointer paths (`/`-separated, numeric for array index, `-` for append):
8
+ * { op: "replace", path: "/field", value: "new" } — set a value at path
9
+ * { op: "add", path: "/items/-", value: item } — append to array
10
+ * { op: "remove", path: "/items/0" } — delete by index
11
+ *
12
+ * Multiple ops applied via applyOps() are atomic in memory.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type DeltaOp =
20
+ | { op: "replace"; path: string; value: unknown }
21
+ | { op: "add"; path: string; value: unknown }
22
+ | { op: "remove"; path: string };
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Apply
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function parsePath(path: string): (string | number)[] {
29
+ return path
30
+ .split("/")
31
+ .filter(Boolean)
32
+ .map((s) => (/^\d+$/.test(s) ? Number(s) : s));
33
+ }
34
+
35
+ function walk(
36
+ obj: any,
37
+ segments: (string | number)[],
38
+ ): { parent: any; key: string | number } {
39
+ let current = obj;
40
+ for (let i = 0; i < segments.length - 1; i++) {
41
+ current = current[segments[i]!];
42
+ if (current == null)
43
+ throw new Error(`Path not found at segment ${segments[i]}`);
44
+ }
45
+ return { parent: current, key: segments[segments.length - 1]! };
46
+ }
47
+
48
+ /** Apply delta ops to a document in place. */
49
+ export function applyOps(doc: any, ops: DeltaOp[]): void {
50
+ for (const op of ops) {
51
+ const segments = parsePath(op.path);
52
+ const { parent, key } = walk(doc, segments);
53
+ switch (op.op) {
54
+ case "replace":
55
+ case "add":
56
+ if (Array.isArray(parent) && key === "-") parent.push(op.value);
57
+ else parent[key] = op.value;
58
+ break;
59
+ case "remove":
60
+ if (Array.isArray(parent) && typeof key === "number")
61
+ parent.splice(key, 1);
62
+ else delete parent[key];
63
+ break;
64
+ }
65
+ }
66
+ }
package/index.ts CHANGED
@@ -1,12 +1,11 @@
1
- // Railroad — Signals, JSX, and Routes
1
+ // Railroad — Signals, JSX, Routes, and Delta-doc
2
2
 
3
3
  export { Signal, signal, computed, effect, batch } from "./signals";
4
4
  export type { Dispose } from "./signals";
5
5
 
6
6
  export {
7
7
  createElement, Fragment,
8
- text, when, list,
9
- pushDisposeScope, popDisposeScope,
8
+ when, list,
10
9
  } from "./jsx";
11
10
 
12
11
  export { routes, route, navigate, matchRoute } from "./routes";
@@ -16,3 +15,6 @@ export type { Key } from "./shared";
16
15
 
17
16
  export { createLogger, setLogLevel, getLogLevel, loggedRequest } from "./logger";
18
17
  export type { LogLevel } from "./logger";
18
+
19
+ export { applyOps } from "./delta";
20
+ export type { DeltaOp } from "./delta";
package/jsx.ts CHANGED
@@ -5,11 +5,16 @@
5
5
  * - tag: string → creates HTML element (or SVG element inside <svg>)
6
6
  * - tag: function → calls component function(props)
7
7
  * - props: attributes, event handlers (onclick etc), ref
8
- * - children: string, number, Node, Signal<T>, arrays, null/undefined
8
+ * - children: string, number, Node, Signal<T>, () => any, arrays, null/undefined
9
9
  *
10
10
  * When a Signal is used as a child, an effect auto-updates the text node.
11
+ * When a function is used as a child, it auto-tracks dependencies:
12
+ * <span>{() => count.get() > 5 ? "High" : "Low"}</span>
11
13
  * When a Signal is used as a prop value, an effect auto-updates the attribute.
12
14
  *
15
+ * Components are auto-scoped — effects/computeds inside are disposed when
16
+ * the parent scope (route, when, list) tears down. No manual dispose needed.
17
+ *
13
18
  * SVG support:
14
19
  * <svg> is created with the SVG namespace. Any HTML children appended to
15
20
  * an SVG-namespaced parent are automatically adopted into the SVG namespace.
@@ -20,13 +25,12 @@
20
25
  * when(signal, truthy, falsy?) — conditional rendering, swaps DOM nodes
21
26
  * list(signal, keyFn, render) — keyed reactive list, render receives Signal<T>
22
27
  * list(signal, render) — index-based reactive list, render receives raw T
23
- * text(fn) — reactive text from computed expression
24
28
  */
25
29
 
26
30
  import { Signal, signal, effect, computed, pushDisposeScope, popDisposeScope, trackDispose } from "./signals";
27
31
  import type { Dispose } from "./signals";
28
32
 
29
- export { pushDisposeScope, popDisposeScope };
33
+ // pushDisposeScope / popDisposeScope are internal — used by createElement, when, list, routes
30
34
 
31
35
  // === SVG namespace ===
32
36
 
@@ -52,35 +56,35 @@ function applyProps(el: Element, props: Record<string, any>): void {
52
56
  if (typeof value === "function") value(el);
53
57
  } else if (key === "innerHTML") {
54
58
  if (value instanceof Signal) {
55
- trackDispose(effect(() => { el.innerHTML = value.get(); }));
59
+ effect(() => { el.innerHTML = value.get(); });
56
60
  } else {
57
61
  el.innerHTML = value;
58
62
  }
59
63
  } else if (key === "className" || key === "class") {
60
64
  if (value instanceof Signal) {
61
- trackDispose(effect(() => { el.setAttribute("class", value.get()); }));
65
+ effect(() => { el.setAttribute("class", value.get()); });
62
66
  } else {
63
67
  el.setAttribute("class", value);
64
68
  }
65
69
  } else if (key === "value" || key === "checked" || key === "disabled" || key === "selected" || key === "srcdoc" || key === "src") {
66
70
  if (value instanceof Signal) {
67
- trackDispose(effect(() => { (el as any)[key] = value.get(); }));
71
+ effect(() => { (el as any)[key] = value.get(); });
68
72
  } else {
69
73
  (el as any)[key] = value;
70
74
  }
71
75
  } else if (key === "style" && value instanceof Signal) {
72
- trackDispose(effect(() => { Object.assign((el as HTMLElement).style, value.get()); }));
76
+ effect(() => { Object.assign((el as HTMLElement).style, value.get()); });
73
77
  } else if (key === "style" && typeof value === "object") {
74
78
  Object.assign((el as HTMLElement).style, value);
75
79
  } else if (key.startsWith("on")) {
76
80
  el.addEventListener(key.slice(2).toLowerCase(), value);
77
81
  } else {
78
82
  if (value instanceof Signal) {
79
- trackDispose(effect(() => {
83
+ effect(() => {
80
84
  const v = value.get();
81
85
  if (v === false || v == null) el.removeAttribute(key);
82
86
  else el.setAttribute(key, String(v));
83
- }));
87
+ });
84
88
  } else if (value !== false && value != null) {
85
89
  el.setAttribute(key, String(value));
86
90
  }
@@ -96,8 +100,12 @@ export function createElement(
96
100
  ...children: any[]
97
101
  ): Node {
98
102
  if (typeof tag === "function") {
103
+ pushDisposeScope();
99
104
  const componentProps = { ...props, children };
100
- return tag(componentProps);
105
+ const node = tag(componentProps);
106
+ const dispose = popDisposeScope();
107
+ trackDispose(dispose);
108
+ return node;
101
109
  }
102
110
 
103
111
  // SVG root element is always created with the SVG namespace.
@@ -160,10 +168,17 @@ function appendChildren(parent: Node, children: any[]): void {
160
168
 
161
169
  if (child instanceof Signal) {
162
170
  const text = document.createTextNode(String(child.peek()));
163
- trackDispose(effect(() => {
171
+ effect(() => {
164
172
  text.textContent = String(child.get());
165
- }));
173
+ });
166
174
  parent.appendChild(text);
175
+ } else if (typeof child === "function") {
176
+ const fn = child as () => any;
177
+ const textNode = document.createTextNode("");
178
+ effect(() => {
179
+ textNode.textContent = String(fn() ?? "");
180
+ });
181
+ parent.appendChild(textNode);
167
182
  } else if (child instanceof Node) {
168
183
  // Adopt HTML elements into SVG namespace when parent is SVG
169
184
  if (isSvgParent &&
@@ -179,17 +194,6 @@ function appendChildren(parent: Node, children: any[]): void {
179
194
  }
180
195
  }
181
196
 
182
- // === text() — reactive computed text node ===
183
- // Use for expressions: text(() => count.get() > 5 ? "High" : "Low")
184
-
185
- export function text(fn: () => string): Node {
186
- const value = computed(fn);
187
- const node = document.createTextNode(value.peek());
188
- trackDispose(effect(() => {
189
- node.textContent = value.get();
190
- }));
191
- return node;
192
- }
193
197
 
194
198
  // === when() — conditional rendering ===
195
199
  // Swaps DOM nodes only when truthiness transitions (falsy↔truthy).
@@ -203,7 +207,7 @@ export function when(
203
207
  falsy?: () => Node,
204
208
  ): Node {
205
209
  const anchor = document.createComment("when");
206
- let current: Node | null = null;
210
+ let currentNodes: Node[] = [];
207
211
  let currentDispose: Dispose | null = null;
208
212
  let wasTruthy: boolean | undefined = undefined;
209
213
 
@@ -218,32 +222,33 @@ export function when(
218
222
  wasTruthy = isTruthy;
219
223
 
220
224
  if (currentDispose) currentDispose();
221
- if (current && anchor.parentNode) {
222
- anchor.parentNode.removeChild(current);
223
- }
225
+ for (const n of currentNodes) n.parentNode?.removeChild(n);
226
+ currentNodes = [];
224
227
 
225
228
  pushDisposeScope();
226
- current = isTruthy ? truthy() : (falsy ? falsy() : null);
229
+ const result = isTruthy ? truthy() : (falsy ? falsy() : null);
227
230
  currentDispose = popDisposeScope();
228
231
 
229
- if (current && anchor.parentNode) {
230
- anchor.parentNode.insertBefore(current, anchor.nextSibling);
232
+ if (result && anchor.parentNode) {
233
+ // Capture actual child nodes before fragment is emptied by insertBefore
234
+ currentNodes = result instanceof DocumentFragment
235
+ ? [...result.childNodes]
236
+ : [result];
237
+ anchor.parentNode.insertBefore(result, anchor.nextSibling);
231
238
  }
232
239
  }
233
240
 
234
- trackDispose(effect(() => {
241
+ effect(() => {
235
242
  sig.get(); // track
236
243
  if (!anchor.parentNode) {
237
244
  queueMicrotask(swap);
238
245
  } else {
239
246
  swap();
240
247
  }
241
- }));
248
+ });
242
249
 
243
- // Return a fragment: anchor + initial content
244
250
  const frag = document.createDocumentFragment();
245
251
  frag.appendChild(anchor);
246
- if (current) frag.appendChild(current);
247
252
  return frag;
248
253
  }
249
254
 
@@ -252,11 +257,17 @@ export function when(
252
257
  //
253
258
  // Keyed form — render receives Signal<T> and Signal<number> so item
254
259
  // updates flow into existing DOM without re-creating nodes:
255
- // list(items, (t) => t.id, (item, index) => <li>{text(() => item.get().name)}</li>)
260
+ // list(items, (t) => t.id, (item$) => <li>{item$.map(t => t.name)}</li>)
256
261
  //
257
262
  // Non-keyed form (index-based, raw values):
258
263
  // list(items, (item, index) => <li>{item}</li>)
259
264
 
265
+ function collectNodes(result: Node): Node[] {
266
+ return result instanceof DocumentFragment
267
+ ? [...result.childNodes]
268
+ : [result];
269
+ }
270
+
260
271
  export function list<T>(
261
272
  items: Signal<T[]>,
262
273
  keyFnOrRender: ((item: T) => string | number) | ((item: T, index: number) => Node),
@@ -265,7 +276,7 @@ export function list<T>(
265
276
  const hasKeyFn = maybeRender !== undefined;
266
277
  const keyFn = hasKeyFn ? keyFnOrRender as (item: T) => string | number : null;
267
278
 
268
- type Entry = { node: Node; dispose: Dispose; item?: Signal<T>; index?: Signal<number> };
279
+ type Entry = { nodes: Node[]; dispose: Dispose; item?: Signal<T>; index?: Signal<number> };
269
280
  const anchor = document.createComment("list");
270
281
  let entries: Map<string | number, Entry> = new Map();
271
282
  let order: (string | number)[] = [];
@@ -274,7 +285,7 @@ export function list<T>(
274
285
  const entry = entries.get(key);
275
286
  if (entry) {
276
287
  entry.dispose();
277
- entry.node.parentNode?.removeChild(entry.node);
288
+ for (const n of entry.nodes) n.parentNode?.removeChild(n);
278
289
  entries.delete(key);
279
290
  }
280
291
  }
@@ -282,7 +293,7 @@ export function list<T>(
282
293
  function clearAll() {
283
294
  for (const [, entry] of entries) {
284
295
  entry.dispose();
285
- entry.node.parentNode?.removeChild(entry.node);
296
+ for (const n of entry.nodes) n.parentNode?.removeChild(n);
286
297
  }
287
298
  entries = new Map();
288
299
  order = [];
@@ -310,17 +321,17 @@ export function list<T>(
310
321
  if (!entry) {
311
322
  // New item — create
312
323
  pushDisposeScope();
313
- let node: Node;
324
+ let result: Node;
314
325
  if (hasKeyFn) {
315
326
  const itemSig = signal(arr[i]!);
316
327
  const indexSig = signal(i);
317
- node = maybeRender!(itemSig, indexSig);
328
+ result = maybeRender!(itemSig, indexSig);
318
329
  const dispose = popDisposeScope();
319
- entry = { node, dispose, item: itemSig, index: indexSig };
330
+ entry = { nodes: collectNodes(result), dispose, item: itemSig, index: indexSig };
320
331
  } else {
321
- node = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
332
+ result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
322
333
  const dispose = popDisposeScope();
323
- entry = { node, dispose };
334
+ entry = { nodes: collectNodes(result), dispose };
324
335
  }
325
336
  entries.set(key, entry);
326
337
  } else if (hasKeyFn) {
@@ -329,34 +340,43 @@ export function list<T>(
329
340
  entry.index!.set(i);
330
341
  } else {
331
342
  // Index-based — dispose old, recreate with new item
332
- const oldNode = entry.node;
343
+ const oldNodes = entry.nodes;
333
344
  entry.dispose();
334
345
  pushDisposeScope();
335
- const node = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
346
+ const result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
336
347
  const dispose = popDisposeScope();
337
- entry = { node, dispose };
348
+ const nodes = collectNodes(result);
349
+ entry = { nodes, dispose };
338
350
  entries.set(key, entry);
339
- if (oldNode.parentNode) oldNode.parentNode.replaceChild(node, oldNode);
351
+ const ref = oldNodes[oldNodes.length - 1]?.nextSibling ?? null;
352
+ const oldParent = oldNodes[0]?.parentNode;
353
+ for (const n of oldNodes) n.parentNode?.removeChild(n);
354
+ if (oldParent) {
355
+ for (const n of nodes) oldParent.insertBefore(n, ref);
356
+ }
340
357
  }
341
358
 
342
359
  // Move or insert into correct position
343
- if (entry.node.nextSibling !== insertBefore) {
344
- parent.insertBefore(entry.node, insertBefore);
360
+ const lastNode = entry.nodes[entry.nodes.length - 1];
361
+ if (lastNode?.nextSibling !== insertBefore) {
362
+ for (const n of entry.nodes) {
363
+ parent.insertBefore(n, insertBefore);
364
+ }
345
365
  }
346
- insertBefore = entry.node;
366
+ insertBefore = entry.nodes[0] ?? insertBefore;
347
367
  }
348
368
 
349
369
  order = newKeys;
350
370
  }
351
371
 
352
- trackDispose(effect(() => {
372
+ effect(() => {
353
373
  items.get(); // track
354
374
  if (!anchor.parentNode) {
355
375
  queueMicrotask(sync);
356
376
  } else {
357
377
  sync();
358
378
  }
359
- }));
379
+ });
360
380
 
361
381
  trackDispose(() => clearAll());
362
382
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blueshed/railroad",
3
- "version": "0.3.4",
4
- "description": "Signals, JSX, and routes — a micro UI framework for Bun",
3
+ "version": "0.5.0",
4
+ "description": "Signals, JSX, routes, and delta-doc — a micro UI framework for Bun",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "types": "index.ts",
@@ -11,6 +11,9 @@
11
11
  "./jsx": "./jsx.ts",
12
12
  "./routes": "./routes.ts",
13
13
  "./shared": "./shared.ts",
14
+ "./delta": "./delta.ts",
15
+ "./delta-server": "./delta-server.ts",
16
+ "./delta-client": "./delta-client.ts",
14
17
  "./jsx-runtime": "./jsx-runtime.ts",
15
18
  "./jsx-dev-runtime": "./jsx-dev-runtime.ts"
16
19
  },
@@ -36,5 +39,5 @@
36
39
  "type": "git",
37
40
  "url": "https://github.com/blueshed/railroad"
38
41
  },
39
- "keywords": ["signals", "jsx", "router", "bun", "ui", "framework"]
42
+ "keywords": ["signals", "jsx", "router", "delta-doc", "bun", "ui", "framework"]
40
43
  }