@blueshed/railroad 0.2.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 +86 -0
- package/.claude/skills/railroad/jsx.md +325 -0
- package/.claude/skills/railroad/routes.md +157 -0
- package/.claude/skills/railroad/shared.md +94 -0
- package/.claude/skills/railroad/signals.md +218 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/index.ts +18 -0
- package/jsx-dev-runtime.ts +4 -0
- package/jsx-runtime.ts +35 -0
- package/jsx.ts +381 -0
- package/logger.ts +91 -0
- package/package.json +40 -0
- package/routes.ts +110 -0
- package/shared.ts +32 -0
- package/signals.ts +186 -0
package/jsx.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSX Runtime — real DOM elements backed by signals
|
|
3
|
+
*
|
|
4
|
+
* createElement(tag, props, ...children)
|
|
5
|
+
* - tag: string → creates HTML element (or SVG element inside <svg>)
|
|
6
|
+
* - tag: function → calls component function(props)
|
|
7
|
+
* - props: attributes, event handlers (onclick etc), ref
|
|
8
|
+
* - children: string, number, Node, Signal<T>, arrays, null/undefined
|
|
9
|
+
*
|
|
10
|
+
* When a Signal is used as a child, an effect auto-updates the text node.
|
|
11
|
+
* When a Signal is used as a prop value, an effect auto-updates the attribute.
|
|
12
|
+
*
|
|
13
|
+
* SVG support:
|
|
14
|
+
* <svg> is created with the SVG namespace. Any HTML children appended to
|
|
15
|
+
* an SVG-namespaced parent are automatically adopted into the SVG namespace.
|
|
16
|
+
* This handles the JSX bottom-up evaluation order transparently — you can
|
|
17
|
+
* write <svg><g><circle /></g></svg> and it just works.
|
|
18
|
+
*
|
|
19
|
+
* Reactive helpers:
|
|
20
|
+
* when(signal, truthy, falsy?) — conditional rendering, swaps DOM nodes
|
|
21
|
+
* list(signal, keyFn, render) — keyed reactive list, render receives Signal<T>
|
|
22
|
+
* list(signal, render) — index-based reactive list, render receives raw T
|
|
23
|
+
* text(fn) — reactive text from computed expression
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Signal, signal, effect, computed } from "./signals";
|
|
27
|
+
import type { Dispose } from "./signals";
|
|
28
|
+
|
|
29
|
+
// === SVG namespace ===
|
|
30
|
+
|
|
31
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
32
|
+
const storedProps = new WeakMap<Element, Record<string, any>>();
|
|
33
|
+
|
|
34
|
+
// === Dispose scope management ===
|
|
35
|
+
|
|
36
|
+
const disposeStack: Dispose[][] = [];
|
|
37
|
+
|
|
38
|
+
export function pushDisposeScope(): void {
|
|
39
|
+
disposeStack.push([]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function popDisposeScope(): Dispose {
|
|
43
|
+
const disposers = disposeStack.pop() || [];
|
|
44
|
+
return () => disposers.forEach((d) => d());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function trackDispose(d: Dispose): void {
|
|
48
|
+
const scope = disposeStack[disposeStack.length - 1];
|
|
49
|
+
if (scope) scope.push(d);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// === Fragment ===
|
|
53
|
+
|
|
54
|
+
export function Fragment(props: any): DocumentFragment {
|
|
55
|
+
const frag = document.createDocumentFragment();
|
|
56
|
+
const children = props?.children
|
|
57
|
+
? (Array.isArray(props.children) ? props.children : [props.children])
|
|
58
|
+
: [];
|
|
59
|
+
appendChildren(frag, children);
|
|
60
|
+
return frag;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// === Props application ===
|
|
64
|
+
|
|
65
|
+
function applyProps(el: Element, props: Record<string, any>): void {
|
|
66
|
+
for (const [key, value] of Object.entries(props)) {
|
|
67
|
+
if (key === "ref") {
|
|
68
|
+
if (typeof value === "function") value(el);
|
|
69
|
+
} else if (key === "innerHTML") {
|
|
70
|
+
if (value instanceof Signal) {
|
|
71
|
+
trackDispose(effect(() => { el.innerHTML = value.get(); }));
|
|
72
|
+
} else {
|
|
73
|
+
el.innerHTML = value;
|
|
74
|
+
}
|
|
75
|
+
} else if (key === "className" || key === "class") {
|
|
76
|
+
if (value instanceof Signal) {
|
|
77
|
+
trackDispose(effect(() => { el.setAttribute("class", value.get()); }));
|
|
78
|
+
} else {
|
|
79
|
+
el.setAttribute("class", value);
|
|
80
|
+
}
|
|
81
|
+
} else if (key === "value" || key === "checked" || key === "disabled" || key === "selected" || key === "srcdoc" || key === "src") {
|
|
82
|
+
if (value instanceof Signal) {
|
|
83
|
+
trackDispose(effect(() => { (el as any)[key] = value.get(); }));
|
|
84
|
+
} else {
|
|
85
|
+
(el as any)[key] = value;
|
|
86
|
+
}
|
|
87
|
+
} else if (key === "style" && typeof value === "object" && !(value instanceof Signal)) {
|
|
88
|
+
Object.assign((el as any).style, value);
|
|
89
|
+
} else if (key.startsWith("on")) {
|
|
90
|
+
el.addEventListener(key.slice(2).toLowerCase(), value);
|
|
91
|
+
} else {
|
|
92
|
+
if (value instanceof Signal) {
|
|
93
|
+
trackDispose(effect(() => {
|
|
94
|
+
const v = value.get();
|
|
95
|
+
if (v === false || v == null) el.removeAttribute(key);
|
|
96
|
+
else el.setAttribute(key, String(v));
|
|
97
|
+
}));
|
|
98
|
+
} else if (value !== false && value != null) {
|
|
99
|
+
el.setAttribute(key, String(value));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// === createElement ===
|
|
106
|
+
|
|
107
|
+
export function createElement(
|
|
108
|
+
tag: string | Function,
|
|
109
|
+
props: Record<string, any> | null,
|
|
110
|
+
...children: any[]
|
|
111
|
+
): Node {
|
|
112
|
+
if (typeof tag === "function") {
|
|
113
|
+
const componentProps = { ...props, children };
|
|
114
|
+
return tag(componentProps);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// SVG root element is always created with the SVG namespace.
|
|
118
|
+
// Child SVG elements are adopted in appendChildren when appended
|
|
119
|
+
// to an SVG-namespaced parent.
|
|
120
|
+
const el = tag === "svg"
|
|
121
|
+
? document.createElementNS(SVG_NS, tag)
|
|
122
|
+
: document.createElement(tag);
|
|
123
|
+
|
|
124
|
+
if (props) {
|
|
125
|
+
storedProps.set(el, props);
|
|
126
|
+
applyProps(el, props);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
appendChildren(el, children);
|
|
130
|
+
return el;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// === SVG adoption ===
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Recursively adopt an HTML element into the SVG namespace.
|
|
137
|
+
* Creates a new SVG element, re-applies stored props (or copies
|
|
138
|
+
* attributes), and recursively adopts all children.
|
|
139
|
+
*/
|
|
140
|
+
function adoptSvg(node: Node): Node {
|
|
141
|
+
if (node instanceof Text || node instanceof Comment) return node;
|
|
142
|
+
if (!(node instanceof Element) || node.namespaceURI === SVG_NS) return node;
|
|
143
|
+
|
|
144
|
+
const svgEl = document.createElementNS(SVG_NS, node.localName);
|
|
145
|
+
const props = storedProps.get(node);
|
|
146
|
+
|
|
147
|
+
if (props) {
|
|
148
|
+
storedProps.set(svgEl, props);
|
|
149
|
+
applyProps(svgEl, props);
|
|
150
|
+
} else {
|
|
151
|
+
// No stored props — copy attributes directly
|
|
152
|
+
for (let i = 0; i < node.attributes.length; i++) {
|
|
153
|
+
const attr = node.attributes[i]!;
|
|
154
|
+
svgEl.setAttribute(attr.name, attr.value);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Adopt children recursively
|
|
159
|
+
while (node.firstChild) {
|
|
160
|
+
svgEl.appendChild(adoptSvg(node.removeChild(node.firstChild)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return svgEl;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// === Child rendering ===
|
|
167
|
+
|
|
168
|
+
function appendChildren(parent: Node, children: any[]): void {
|
|
169
|
+
const isSvgParent = parent instanceof Element &&
|
|
170
|
+
parent.namespaceURI === SVG_NS;
|
|
171
|
+
|
|
172
|
+
for (const child of children.flat(Infinity)) {
|
|
173
|
+
if (child == null || child === false || child === true) continue;
|
|
174
|
+
|
|
175
|
+
if (child instanceof Signal) {
|
|
176
|
+
const text = document.createTextNode(String(child.peek()));
|
|
177
|
+
trackDispose(effect(() => {
|
|
178
|
+
text.textContent = String(child.get());
|
|
179
|
+
}));
|
|
180
|
+
parent.appendChild(text);
|
|
181
|
+
} else if (child instanceof Node) {
|
|
182
|
+
// Adopt HTML elements into SVG namespace when parent is SVG
|
|
183
|
+
if (isSvgParent &&
|
|
184
|
+
child instanceof Element &&
|
|
185
|
+
child.namespaceURI !== SVG_NS) {
|
|
186
|
+
parent.appendChild(adoptSvg(child));
|
|
187
|
+
} else {
|
|
188
|
+
parent.appendChild(child);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
parent.appendChild(document.createTextNode(String(child)));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// === text() — reactive computed text node ===
|
|
197
|
+
// Use for expressions: text(() => count.get() > 5 ? "High" : "Low")
|
|
198
|
+
|
|
199
|
+
export function text(fn: () => string): Node {
|
|
200
|
+
const value = computed(fn);
|
|
201
|
+
const node = document.createTextNode(value.peek());
|
|
202
|
+
trackDispose(effect(() => {
|
|
203
|
+
node.textContent = value.get();
|
|
204
|
+
}));
|
|
205
|
+
return node;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// === when() — conditional rendering ===
|
|
209
|
+
// Swaps DOM nodes only when truthiness transitions (falsy↔truthy).
|
|
210
|
+
// Value changes within the same branch (e.g. "a" → "b") do NOT re-render.
|
|
211
|
+
// Components inside each branch should use signals to react to value changes.
|
|
212
|
+
// when(isLoggedIn, () => <Dashboard />, () => <Login />)
|
|
213
|
+
|
|
214
|
+
export function when(
|
|
215
|
+
condition: Signal<any> | (() => any),
|
|
216
|
+
truthy: () => Node,
|
|
217
|
+
falsy?: () => Node,
|
|
218
|
+
): Node {
|
|
219
|
+
const anchor = document.createComment("when");
|
|
220
|
+
let current: Node | null = null;
|
|
221
|
+
let currentDispose: Dispose | null = null;
|
|
222
|
+
let wasTruthy: boolean | undefined = undefined;
|
|
223
|
+
|
|
224
|
+
const sig = typeof condition === "function" ? computed(condition) : condition;
|
|
225
|
+
|
|
226
|
+
function swap() {
|
|
227
|
+
const val = sig.get();
|
|
228
|
+
const isTruthy = !!val;
|
|
229
|
+
|
|
230
|
+
// Only swap when truthiness actually changes
|
|
231
|
+
if (isTruthy === wasTruthy) return;
|
|
232
|
+
wasTruthy = isTruthy;
|
|
233
|
+
|
|
234
|
+
if (currentDispose) currentDispose();
|
|
235
|
+
if (current && anchor.parentNode) {
|
|
236
|
+
anchor.parentNode.removeChild(current);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
pushDisposeScope();
|
|
240
|
+
current = isTruthy ? truthy() : (falsy ? falsy() : null);
|
|
241
|
+
currentDispose = popDisposeScope();
|
|
242
|
+
|
|
243
|
+
if (current && anchor.parentNode) {
|
|
244
|
+
anchor.parentNode.insertBefore(current, anchor.nextSibling);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
trackDispose(effect(() => {
|
|
249
|
+
sig.get(); // track
|
|
250
|
+
if (!anchor.parentNode) {
|
|
251
|
+
queueMicrotask(swap);
|
|
252
|
+
} else {
|
|
253
|
+
swap();
|
|
254
|
+
}
|
|
255
|
+
}));
|
|
256
|
+
|
|
257
|
+
// Return a fragment: anchor + initial content
|
|
258
|
+
const frag = document.createDocumentFragment();
|
|
259
|
+
frag.appendChild(anchor);
|
|
260
|
+
if (current) frag.appendChild(current);
|
|
261
|
+
return frag;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// === list() — keyed reactive list rendering ===
|
|
265
|
+
// Diffs by key to preserve DOM nodes across updates.
|
|
266
|
+
//
|
|
267
|
+
// Keyed form — render receives Signal<T> and Signal<number> so item
|
|
268
|
+
// updates flow into existing DOM without re-creating nodes:
|
|
269
|
+
// list(items, (t) => t.id, (item, index) => <li>{text(() => item.get().name)}</li>)
|
|
270
|
+
//
|
|
271
|
+
// Non-keyed form (index-based, raw values):
|
|
272
|
+
// list(items, (item, index) => <li>{item}</li>)
|
|
273
|
+
|
|
274
|
+
export function list<T>(
|
|
275
|
+
items: Signal<T[]>,
|
|
276
|
+
keyFnOrRender: ((item: T) => string | number) | ((item: T, index: number) => Node),
|
|
277
|
+
maybeRender?: (item: Signal<T>, index: Signal<number>) => Node,
|
|
278
|
+
): Node {
|
|
279
|
+
const hasKeyFn = maybeRender !== undefined;
|
|
280
|
+
const keyFn = hasKeyFn ? keyFnOrRender as (item: T) => string | number : null;
|
|
281
|
+
|
|
282
|
+
type Entry = { node: Node; dispose: Dispose; item?: Signal<T>; index?: Signal<number> };
|
|
283
|
+
const anchor = document.createComment("list");
|
|
284
|
+
let entries: Map<string | number, Entry> = new Map();
|
|
285
|
+
let order: (string | number)[] = [];
|
|
286
|
+
|
|
287
|
+
function removeEntry(key: string | number) {
|
|
288
|
+
const entry = entries.get(key);
|
|
289
|
+
if (entry) {
|
|
290
|
+
entry.dispose();
|
|
291
|
+
entry.node.parentNode?.removeChild(entry.node);
|
|
292
|
+
entries.delete(key);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function clearAll() {
|
|
297
|
+
for (const [, entry] of entries) {
|
|
298
|
+
entry.dispose();
|
|
299
|
+
entry.node.parentNode?.removeChild(entry.node);
|
|
300
|
+
}
|
|
301
|
+
entries = new Map();
|
|
302
|
+
order = [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function sync() {
|
|
306
|
+
const arr = items.get();
|
|
307
|
+
const parent = anchor.parentNode;
|
|
308
|
+
if (!parent) return;
|
|
309
|
+
|
|
310
|
+
const newKeys = arr.map((item, i) => keyFn ? keyFn(item) : i);
|
|
311
|
+
const newKeySet = new Set(newKeys);
|
|
312
|
+
|
|
313
|
+
// Remove entries no longer in the list
|
|
314
|
+
for (const key of order) {
|
|
315
|
+
if (!newKeySet.has(key)) removeEntry(key);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Add or reorder entries
|
|
319
|
+
let insertBefore: Node = anchor;
|
|
320
|
+
for (let i = newKeys.length - 1; i >= 0; i--) {
|
|
321
|
+
const key = newKeys[i]!;
|
|
322
|
+
let entry = entries.get(key);
|
|
323
|
+
|
|
324
|
+
if (!entry) {
|
|
325
|
+
// New item — create
|
|
326
|
+
pushDisposeScope();
|
|
327
|
+
let node: Node;
|
|
328
|
+
if (hasKeyFn) {
|
|
329
|
+
const itemSig = signal(arr[i]!);
|
|
330
|
+
const indexSig = signal(i);
|
|
331
|
+
node = maybeRender!(itemSig, indexSig);
|
|
332
|
+
const dispose = popDisposeScope();
|
|
333
|
+
entry = { node, dispose, item: itemSig, index: indexSig };
|
|
334
|
+
} else {
|
|
335
|
+
node = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
|
|
336
|
+
const dispose = popDisposeScope();
|
|
337
|
+
entry = { node, dispose };
|
|
338
|
+
}
|
|
339
|
+
entries.set(key, entry);
|
|
340
|
+
} else if (hasKeyFn) {
|
|
341
|
+
// Existing keyed item — push new value into its signal
|
|
342
|
+
entry.item!.set(arr[i]!);
|
|
343
|
+
entry.index!.set(i);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Move or insert into correct position
|
|
347
|
+
if (entry.node.nextSibling !== insertBefore) {
|
|
348
|
+
parent.insertBefore(entry.node, insertBefore);
|
|
349
|
+
}
|
|
350
|
+
insertBefore = entry.node;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
order = newKeys;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
trackDispose(effect(() => {
|
|
357
|
+
items.get(); // track
|
|
358
|
+
if (!anchor.parentNode) {
|
|
359
|
+
queueMicrotask(sync);
|
|
360
|
+
} else {
|
|
361
|
+
sync();
|
|
362
|
+
}
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
trackDispose(() => clearAll());
|
|
366
|
+
|
|
367
|
+
const frag = document.createDocumentFragment();
|
|
368
|
+
frag.appendChild(anchor);
|
|
369
|
+
return frag;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// === JSX namespace for TypeScript ===
|
|
373
|
+
|
|
374
|
+
declare global {
|
|
375
|
+
namespace JSX {
|
|
376
|
+
type Element = globalThis.Node;
|
|
377
|
+
interface IntrinsicElements {
|
|
378
|
+
[tag: string]: any;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
package/logger.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger — colored, timestamped, level-gated console output.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { createLogger, setLogLevel, loggedRequest } from "@blueshed/railroad";
|
|
6
|
+
*
|
|
7
|
+
* const log = createLogger("[server]");
|
|
8
|
+
* log.info("listening on :3000"); // 12:34:56.789 INFO [server] listening on :3000
|
|
9
|
+
* log.debug("tick"); // only shown when level is "debug"
|
|
10
|
+
* log.warn("slow query"); // yellow
|
|
11
|
+
* log.error("connection failed"); // red, always shown
|
|
12
|
+
*
|
|
13
|
+
* setLogLevel("debug"); // show everything
|
|
14
|
+
*
|
|
15
|
+
* const handler = loggedRequest("[api]", myHandler); // wrap a route with access logging
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
19
|
+
|
|
20
|
+
const LEVELS: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
21
|
+
|
|
22
|
+
let current: LogLevel =
|
|
23
|
+
(typeof process !== "undefined" && process.env?.LOG_LEVEL as LogLevel) || "info";
|
|
24
|
+
|
|
25
|
+
export function setLogLevel(level: LogLevel) {
|
|
26
|
+
current = level;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getLogLevel(): LogLevel {
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shouldLog(level: LogLevel): boolean {
|
|
34
|
+
return LEVELS[current] >= LEVELS[level];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// === Colors ===
|
|
38
|
+
|
|
39
|
+
const gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
|
|
40
|
+
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
41
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
42
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
43
|
+
|
|
44
|
+
const color: Record<LogLevel, (s: string) => string> = {
|
|
45
|
+
error: red,
|
|
46
|
+
warn: yellow,
|
|
47
|
+
info: gray,
|
|
48
|
+
debug: dim,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function timestamp() {
|
|
52
|
+
return new Date().toISOString().slice(11, 23);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fmt(level: LogLevel, tag: string, msg: string) {
|
|
56
|
+
return `${gray(timestamp())} ${color[level](level.toUpperCase().padEnd(5))} ${tag} ${msg}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Create a tagged logger instance. */
|
|
60
|
+
export function createLogger(tag: string) {
|
|
61
|
+
return {
|
|
62
|
+
info: (msg: string) => { if (shouldLog("info")) console.log(fmt("info", tag, msg)); },
|
|
63
|
+
warn: (msg: string) => { if (shouldLog("warn")) console.warn(fmt("warn", tag, msg)); },
|
|
64
|
+
error: (msg: string) => { console.error(fmt("error", tag, msg)); },
|
|
65
|
+
debug: (msg: string) => { if (shouldLog("debug")) console.log(fmt("debug", tag, msg)); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Handler = (req: Request) => Response | Promise<Response>;
|
|
70
|
+
|
|
71
|
+
/** Wrap a route handler with access logging. */
|
|
72
|
+
export function loggedRequest(tag: string, handler: Handler): Handler {
|
|
73
|
+
const log = createLogger(tag);
|
|
74
|
+
return async (req: Request) => {
|
|
75
|
+
const start = performance.now();
|
|
76
|
+
try {
|
|
77
|
+
const res = await handler(req);
|
|
78
|
+
const ms = (performance.now() - start).toFixed(1);
|
|
79
|
+
log.info(
|
|
80
|
+
`${req.method} ${new URL(req.url).pathname} → ${res.status} (${ms}ms)`,
|
|
81
|
+
);
|
|
82
|
+
return res;
|
|
83
|
+
} catch (err: any) {
|
|
84
|
+
const ms = (performance.now() - start).toFixed(1);
|
|
85
|
+
log.error(
|
|
86
|
+
`${req.method} ${new URL(req.url).pathname} threw (${ms}ms): ${err.message}`,
|
|
87
|
+
);
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blueshed/railroad",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Signals, JSX, and routes — a micro UI framework for Bun",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"types": "index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.ts",
|
|
10
|
+
"./signals": "./signals.ts",
|
|
11
|
+
"./jsx": "./jsx.ts",
|
|
12
|
+
"./routes": "./routes.ts",
|
|
13
|
+
"./shared": "./shared.ts",
|
|
14
|
+
"./jsx-runtime": "./jsx-runtime.ts",
|
|
15
|
+
"./jsx-dev-runtime": "./jsx-dev-runtime.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"*.ts",
|
|
19
|
+
"!*.test.ts",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
".claude/skills"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"check": "bunx tsc --noEmit",
|
|
26
|
+
"test": "bun test"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/bun": "^1.3.11",
|
|
30
|
+
"typescript": "^5.9.3"
|
|
31
|
+
},
|
|
32
|
+
"author": "Peter Bunyan <peter@blueshed.co.uk> (https://blueshed.co.uk)",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"homepage": "https://github.com/blueshed/railroad",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/blueshed/railroad"
|
|
38
|
+
},
|
|
39
|
+
"keywords": ["signals", "jsx", "router", "bun", "ui", "framework"]
|
|
40
|
+
}
|
package/routes.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes — Hash-based client router built on signals
|
|
3
|
+
*
|
|
4
|
+
* API:
|
|
5
|
+
* routes(target, table) — declarative hash router, swaps target content
|
|
6
|
+
* route<T>(pattern) — reactive route: Signal<T | null>, null when unmatched
|
|
7
|
+
* navigate(path) — set location.hash programmatically
|
|
8
|
+
* matchRoute(pattern, path) — pure pattern matcher, returns params or null
|
|
9
|
+
*
|
|
10
|
+
* Handlers return a Node to render. The router manages cleanup automatically.
|
|
11
|
+
* routes(app, {
|
|
12
|
+
* "/": () => <Home />,
|
|
13
|
+
* "/site/:id": ({ id }) => <SiteDetail id={id} />,
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Signal, computed, effect } from "./signals";
|
|
18
|
+
import type { Dispose } from "./signals";
|
|
19
|
+
import { pushDisposeScope, popDisposeScope } from "./jsx";
|
|
20
|
+
|
|
21
|
+
let hashSignal: Signal<string> | null = null;
|
|
22
|
+
|
|
23
|
+
function getHash(): Signal<string> {
|
|
24
|
+
if (!hashSignal) {
|
|
25
|
+
hashSignal = new Signal(location.hash.slice(1) || "/");
|
|
26
|
+
window.addEventListener("hashchange", () => {
|
|
27
|
+
hashSignal!.set(location.hash.slice(1) || "/");
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return hashSignal;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function matchRoute(
|
|
34
|
+
pattern: string,
|
|
35
|
+
path: string,
|
|
36
|
+
): Record<string, string> | null {
|
|
37
|
+
const pp = pattern.split("/");
|
|
38
|
+
const hp = path.split("/");
|
|
39
|
+
if (pp.length !== hp.length) return null;
|
|
40
|
+
const params: Record<string, string> = {};
|
|
41
|
+
for (let i = 0; i < pp.length; i++) {
|
|
42
|
+
if (pp[i]!.startsWith(":")) {
|
|
43
|
+
try {
|
|
44
|
+
params[pp[i]!.slice(1)] = decodeURIComponent(hp[i]!);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
} else if (pp[i] !== hp[i]) return null;
|
|
49
|
+
}
|
|
50
|
+
return params;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function route<
|
|
54
|
+
T extends Record<string, string> = Record<string, string>,
|
|
55
|
+
>(pattern: string): Signal<T | null> {
|
|
56
|
+
const hash = getHash();
|
|
57
|
+
return computed(() => matchRoute(pattern, hash.get()) as T | null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function navigate(path: string): void {
|
|
61
|
+
location.hash = path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type RouteHandler = (params: Record<string, string>) => Node;
|
|
65
|
+
|
|
66
|
+
export function routes(
|
|
67
|
+
target: HTMLElement,
|
|
68
|
+
table: Record<string, RouteHandler>,
|
|
69
|
+
): Dispose {
|
|
70
|
+
const hash = getHash();
|
|
71
|
+
let activePattern: string | null = null;
|
|
72
|
+
let activeDispose: Dispose | null = null;
|
|
73
|
+
|
|
74
|
+
function teardown() {
|
|
75
|
+
if (activeDispose) activeDispose();
|
|
76
|
+
activeDispose = null;
|
|
77
|
+
activePattern = null;
|
|
78
|
+
target.replaceChildren();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function run(handler: RouteHandler, params: Record<string, string>) {
|
|
82
|
+
pushDisposeScope();
|
|
83
|
+
const node = handler(params);
|
|
84
|
+
activeDispose = popDisposeScope();
|
|
85
|
+
target.appendChild(node);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return effect(() => {
|
|
89
|
+
const path = hash.get();
|
|
90
|
+
for (const [pattern, handler] of Object.entries(table)) {
|
|
91
|
+
const params = matchRoute(pattern, path);
|
|
92
|
+
if (params) {
|
|
93
|
+
if (pattern === activePattern) return;
|
|
94
|
+
teardown();
|
|
95
|
+
activePattern = pattern;
|
|
96
|
+
run(handler, params);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (table["*"]) {
|
|
101
|
+
if (activePattern !== "*") {
|
|
102
|
+
teardown();
|
|
103
|
+
activePattern = "*";
|
|
104
|
+
run(table["*"], {});
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
teardown();
|
|
109
|
+
});
|
|
110
|
+
}
|
package/shared.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared — typed provide / inject for dependency sharing.
|
|
3
|
+
*
|
|
4
|
+
* Any module can provide a value under a typed key.
|
|
5
|
+
* Any other module can inject it without prop-threading.
|
|
6
|
+
*
|
|
7
|
+
* const STORE = key<Store>("store");
|
|
8
|
+
* provide(STORE, createStore()); // in home.ts
|
|
9
|
+
* const store = inject(STORE); // anywhere
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const registry = new Map<symbol, unknown>();
|
|
13
|
+
|
|
14
|
+
export type Key<T> = symbol & { __brand: T };
|
|
15
|
+
|
|
16
|
+
export function key<T>(name: string): Key<T> {
|
|
17
|
+
return Symbol(name) as Key<T>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function provide<T>(k: Key<T>, value: T): void {
|
|
21
|
+
registry.set(k, value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function inject<T>(k: Key<T>): T {
|
|
25
|
+
const v = registry.get(k);
|
|
26
|
+
if (v === undefined) throw new Error(`No provider for ${k.description}`);
|
|
27
|
+
return v as T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function tryInject<T>(k: Key<T>): T | undefined {
|
|
31
|
+
return registry.get(k) as T | undefined;
|
|
32
|
+
}
|