@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
package/delta-server.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
225
|
+
for (const n of currentNodes) n.parentNode?.removeChild(n);
|
|
226
|
+
currentNodes = [];
|
|
224
227
|
|
|
225
228
|
pushDisposeScope();
|
|
226
|
-
|
|
229
|
+
const result = isTruthy ? truthy() : (falsy ? falsy() : null);
|
|
227
230
|
currentDispose = popDisposeScope();
|
|
228
231
|
|
|
229
|
-
if (
|
|
230
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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.
|
|
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.
|
|
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
|
|
324
|
+
let result: Node;
|
|
314
325
|
if (hasKeyFn) {
|
|
315
326
|
const itemSig = signal(arr[i]!);
|
|
316
327
|
const indexSig = signal(i);
|
|
317
|
-
|
|
328
|
+
result = maybeRender!(itemSig, indexSig);
|
|
318
329
|
const dispose = popDisposeScope();
|
|
319
|
-
entry = {
|
|
330
|
+
entry = { nodes: collectNodes(result), dispose, item: itemSig, index: indexSig };
|
|
320
331
|
} else {
|
|
321
|
-
|
|
332
|
+
result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
|
|
322
333
|
const dispose = popDisposeScope();
|
|
323
|
-
entry = {
|
|
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
|
|
343
|
+
const oldNodes = entry.nodes;
|
|
333
344
|
entry.dispose();
|
|
334
345
|
pushDisposeScope();
|
|
335
|
-
const
|
|
346
|
+
const result = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
|
|
336
347
|
const dispose = popDisposeScope();
|
|
337
|
-
|
|
348
|
+
const nodes = collectNodes(result);
|
|
349
|
+
entry = { nodes, dispose };
|
|
338
350
|
entries.set(key, entry);
|
|
339
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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.
|
|
366
|
+
insertBefore = entry.nodes[0] ?? insertBefore;
|
|
347
367
|
}
|
|
348
368
|
|
|
349
369
|
order = newKeys;
|
|
350
370
|
}
|
|
351
371
|
|
|
352
|
-
|
|
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.
|
|
4
|
-
"description": "Signals, JSX, and
|
|
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
|
}
|