@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.
@@ -0,0 +1,218 @@
1
+ # Signals — Reactive State
2
+
3
+ Signals are the foundation of railroad. Everything reactive flows from them.
4
+
5
+ ## Creating Signals
6
+
7
+ ```ts
8
+ import { signal } from "@blueshed/railroad";
9
+
10
+ const count = signal(0); // Signal<number>
11
+ const name = signal("World"); // Signal<string>
12
+ const items = signal<string[]>([]); // Signal<string[]>
13
+ ```
14
+
15
+ ## Reading and Writing
16
+
17
+ ```ts
18
+ count.get() // read (tracks dependency inside effect/computed)
19
+ count.set(5) // write (notifies listeners if value changed)
20
+ count.update(n => n + 1) // transform and write (caller must return a new value)
21
+ count.mutate(v => ...) // clone, mutate in place, notify (see below)
22
+ count.patch({ key: v }) // shallow merge for object signals (see below)
23
+ count.peek() // read WITHOUT tracking — use for one-off reads outside effects
24
+ ```
25
+
26
+ ### Equality Check
27
+
28
+ `set()` uses `Object.is()` to compare old and new values. If they are the same, no listeners are notified. This means:
29
+
30
+ - Primitives: setting the same number/string/boolean is a no-op.
31
+ - Objects/arrays: you must create a new reference to trigger updates.
32
+
33
+ ```ts
34
+ const todos = signal([{ id: 1, text: "Buy milk" }]);
35
+
36
+ // WRONG — same array reference, no update:
37
+ todos.peek().push({ id: 2, text: "Walk dog" });
38
+ todos.set(todos.peek()); // Object.is says same reference, listeners NOT notified
39
+
40
+ // RIGHT — new array:
41
+ todos.update(arr => [...arr, { id: 2, text: "Walk dog" }]);
42
+ ```
43
+
44
+ ### `mutate(fn)` — In-Place Mutation
45
+
46
+ `mutate()` clones the current value with `structuredClone`, passes the clone to your function for in-place mutation, then notifies listeners. Use it when you want to modify objects or arrays naturally without manually creating a new reference.
47
+
48
+ ```ts
49
+ const todos = signal([{ id: 1, text: "Buy milk" }]);
50
+
51
+ // Append:
52
+ todos.mutate(arr => arr.push({ id: 2, text: "Walk dog" }));
53
+
54
+ // Modify nested property:
55
+ const doc = signal({ title: "Draft", meta: { tags: ["a"] } });
56
+ doc.mutate(d => d.meta.tags.push("b"));
57
+
58
+ // Toggle in a Set:
59
+ const selected = signal(new Set([1, 2, 3]));
60
+ selected.mutate(s => s.has(4) ? s.delete(4) : s.add(4));
61
+ ```
62
+
63
+ `mutate()` always notifies listeners (the clone guarantees a new reference). Use `update()` when you can return a new value cheaply; use `mutate()` when in-place mutation is more natural.
64
+
65
+ ### `patch(partial)` — Shallow Merge
66
+
67
+ `patch()` does a shallow merge for object signals — equivalent to `set({ ...current, ...partial })`:
68
+
69
+ ```ts
70
+ const filter = signal({ color: "red", size: 10, active: true });
71
+
72
+ filter.patch({ color: "blue" }); // { color: "blue", size: 10, active: true }
73
+ filter.patch({ size: 20, active: false }); // { color: "blue", size: 20, active: false }
74
+ ```
75
+
76
+ Like `set()`, `patch()` uses `Object.is` — since the spread always creates a new reference, listeners are always notified.
77
+
78
+ ## Computed Signals
79
+
80
+ Derived read-only signals that auto-update when dependencies change.
81
+
82
+ ```ts
83
+ import { computed } from "@blueshed/railroad";
84
+
85
+ const firstName = signal("John");
86
+ const lastName = signal("Doe");
87
+ const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
88
+
89
+ fullName.get(); // "John Doe"
90
+ firstName.set("Jane");
91
+ fullName.get(); // "Jane Doe"
92
+ ```
93
+
94
+ Computed signals chain:
95
+
96
+ ```ts
97
+ const a = signal(1);
98
+ const b = computed(() => a.get() + 1); // 2
99
+ const c = computed(() => b.get() * 10); // 20
100
+ a.set(3);
101
+ c.get(); // 40
102
+ ```
103
+
104
+ ## Effects
105
+
106
+ Run a function whenever its signal dependencies change. The function runs immediately on creation.
107
+
108
+ ```ts
109
+ import { effect } from "@blueshed/railroad";
110
+
111
+ const count = signal(0);
112
+
113
+ const dispose = effect(() => {
114
+ console.log(`count is ${count.get()}`);
115
+ });
116
+ // logs: "count is 0"
117
+
118
+ count.set(1);
119
+ // logs: "count is 1"
120
+
121
+ dispose(); // stop listening
122
+ count.set(2); // nothing logged
123
+ ```
124
+
125
+ ### Cleanup Functions
126
+
127
+ An effect can return a cleanup function. It runs before each re-execution and on dispose.
128
+
129
+ ```ts
130
+ effect(() => {
131
+ const id = setInterval(() => console.log(count.get()), 1000);
132
+ return () => clearInterval(id); // cleanup before re-run or on dispose
133
+ });
134
+ ```
135
+
136
+ ### Automatic Dependency Tracking
137
+
138
+ Effects only track signals read during execution. If a branch is not taken, those signals are not tracked:
139
+
140
+ ```ts
141
+ const showDetail = signal(true);
142
+ const summary = signal("short");
143
+ const detail = signal("long");
144
+
145
+ effect(() => {
146
+ if (showDetail.get()) {
147
+ console.log(detail.get()); // tracked
148
+ } else {
149
+ console.log(summary.get()); // tracked only when showDetail is false
150
+ }
151
+ });
152
+
153
+ // When showDetail is true, changing summary does NOT re-run the effect.
154
+ ```
155
+
156
+ ### Infinite Loop Protection
157
+
158
+ Effects are guarded against infinite loops (max depth 100). If an effect sets a signal that triggers itself in a cycle, it throws:
159
+
160
+ ```
161
+ Error: Maximum effect depth exceeded — possible infinite loop
162
+ ```
163
+
164
+ ## Batch
165
+
166
+ Group multiple signal writes so effects run only once at the end.
167
+
168
+ ```ts
169
+ import { batch } from "@blueshed/railroad";
170
+
171
+ const a = signal(1);
172
+ const b = signal(2);
173
+
174
+ effect(() => {
175
+ console.log(a.get() + b.get());
176
+ });
177
+ // logs: 3
178
+
179
+ batch(() => {
180
+ a.set(10);
181
+ b.set(20);
182
+ });
183
+ // logs: 30 (once, not twice)
184
+ ```
185
+
186
+ Batches nest safely — effects flush only when the outermost batch completes.
187
+
188
+ ## Dispose Pattern
189
+
190
+ `effect()` returns a dispose function. Call it to stop the effect and run its cleanup.
191
+
192
+ The JSX layer manages dispose scopes automatically — effects created during component rendering are collected and disposed when the component is removed (e.g., by the router or `when()`). You rarely need to manage dispose manually unless you create effects outside of JSX rendering.
193
+
194
+ ```ts
195
+ // Manual dispose (outside JSX):
196
+ const dispose = effect(() => { ... });
197
+ // later:
198
+ dispose();
199
+
200
+ // Inside JSX components, effects are auto-collected.
201
+ // The router or when() calls dispose when swapping content.
202
+ ```
203
+
204
+ ## Where to Declare Signals
205
+
206
+ - **Module level** — shared state, lives for the app lifetime. Good for stores, global UI state.
207
+ - **Inside a component** — local state, created fresh each time the component mounts. Disposed when the component is removed.
208
+
209
+ ```ts
210
+ // Module level — shared, persistent
211
+ const currentUser = signal<User | null>(null);
212
+
213
+ // Component level — local, ephemeral
214
+ function SearchBox() {
215
+ const query = signal("");
216
+ return <input value={query} oninput={(e) => query.set(e.target.value)} />;
217
+ }
218
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 blueshed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # Railroad
2
+
3
+ Signals, JSX, and routes — a micro UI framework for Bun.
4
+
5
+ ~400 lines. Zero dependencies. Real DOM. No virtual DOM, no compiler, no build step.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @blueshed/railroad
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### Automatic runtime (recommended)
16
+
17
+ No JSX imports needed — the compiler inserts them for you.
18
+
19
+ ```json
20
+ // tsconfig.json
21
+ {
22
+ "compilerOptions": {
23
+ "jsx": "react-jsx",
24
+ "jsxImportSource": "@blueshed/railroad"
25
+ }
26
+ }
27
+ ```
28
+
29
+ ```tsx
30
+ // app.tsx
31
+ import { signal, routes } from "@blueshed/railroad";
32
+
33
+ const count = signal(0);
34
+
35
+ function Home() {
36
+ return (
37
+ <div>
38
+ <h1>Hello World</h1>
39
+ <button onclick={() => count.update(n => n + 1)}>
40
+ Count: {count}
41
+ </button>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ routes(document.getElementById("app")!, {
47
+ "/": () => <Home />,
48
+ });
49
+ ```
50
+
51
+ ### Classic runtime
52
+
53
+ If you prefer explicit imports:
54
+
55
+ ```json
56
+ // tsconfig.json
57
+ {
58
+ "compilerOptions": {
59
+ "jsx": "react",
60
+ "jsxFactory": "createElement",
61
+ "jsxFragmentFactory": "Fragment"
62
+ }
63
+ }
64
+ ```
65
+
66
+ ```tsx
67
+ // app.tsx
68
+ import { createElement, signal, routes } from "@blueshed/railroad";
69
+
70
+ const count = signal(0);
71
+
72
+ function Home() {
73
+ return (
74
+ <div>
75
+ <h1>Hello World</h1>
76
+ <button onclick={() => count.update(n => n + 1)}>
77
+ Count: {count}
78
+ </button>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ routes(document.getElementById("app")!, {
84
+ "/": () => <Home />,
85
+ });
86
+ ```
87
+
88
+ ### Server
89
+
90
+ ```ts
91
+ // server.ts
92
+ import home from "./index.html";
93
+
94
+ Bun.serve({
95
+ routes: { "/": home },
96
+ });
97
+ ```
98
+
99
+ Resources and routes. Server and client. Same pattern.
100
+
101
+ ## API
102
+
103
+ ### Signals
104
+
105
+ ```ts
106
+ import { signal, computed, effect, batch } from "@blueshed/railroad";
107
+
108
+ const count = signal(0);
109
+ const doubled = computed(() => count.get() * 2);
110
+
111
+ const dispose = effect(() => {
112
+ console.log(`count is ${count.get()}`);
113
+ });
114
+
115
+ count.set(1); // logs "count is 1"
116
+ count.update(n => n + 1); // logs "count is 2"
117
+ count.peek(); // read without tracking
118
+
119
+ // In-place mutation (auto-clones, always notifies):
120
+ const todos = signal([{ id: 1, text: "Buy milk" }]);
121
+ todos.mutate(arr => arr.push({ id: 2, text: "Walk dog" }));
122
+
123
+ // Shallow merge for object signals:
124
+ const filter = signal({ color: "red", size: 10 });
125
+ filter.patch({ color: "blue" }); // { color: "blue", size: 10 }
126
+
127
+ batch(() => {
128
+ count.set(10);
129
+ count.set(20); // effect runs once, not twice
130
+ });
131
+
132
+ dispose(); // stop listening
133
+ ```
134
+
135
+ ### JSX
136
+
137
+ Components are functions that return DOM nodes. Signals in children and props auto-update.
138
+
139
+ ```tsx
140
+ import { createElement, text, when, list, signal } from "@blueshed/railroad";
141
+
142
+ const name = signal("World");
143
+
144
+ function Greeting() {
145
+ return <h1>Hello {name}</h1>; // updates when name changes
146
+ }
147
+ ```
148
+
149
+ #### `text(fn)` — reactive computed text
150
+
151
+ ```tsx
152
+ <span>{text(() => count.get() > 5 ? "High" : "Low")}</span>
153
+ ```
154
+
155
+ #### `when(condition, truthy, falsy?)` — conditional rendering
156
+
157
+ ```tsx
158
+ {when(
159
+ () => loggedIn.get(),
160
+ () => <Dashboard />,
161
+ () => <Login />,
162
+ )}
163
+ ```
164
+
165
+ #### `list(items, keyFn?, render)` — keyed list rendering
166
+
167
+ ```tsx
168
+ // Keyed — render receives Signal<T> and Signal<number>:
169
+ {list(
170
+ todos,
171
+ (t) => t.id,
172
+ (todo, idx) => <li>{text(() => todo.get().name)}</li>,
173
+ )}
174
+
175
+ // Non-keyed (index-based, raw values):
176
+ {list(items, (item, i) => <li>{item}</li>)}
177
+ ```
178
+
179
+ ### Routes
180
+
181
+ Hash-based client router with automatic dispose scoping.
182
+
183
+ ```tsx
184
+ import { routes, navigate } from "@blueshed/railroad";
185
+
186
+ const dispose = routes(app, {
187
+ "/": () => <Home />,
188
+ "/users/:id": ({ id }) => <User id={id} />,
189
+ "*": () => <NotFound />,
190
+ });
191
+
192
+ navigate("/users/42");
193
+ ```
194
+
195
+ ### Shared
196
+
197
+ Typed dependency injection without prop threading.
198
+
199
+ ```ts
200
+ import { key, provide, inject } from "@blueshed/railroad";
201
+
202
+ const STORE = key<AppStore>("store");
203
+ provide(STORE, createStore());
204
+
205
+ // anywhere:
206
+ const store = inject(STORE);
207
+ ```
208
+
209
+ ## Design
210
+
211
+ - **Signals hold state** — reactive primitives with automatic dependency tracking
212
+ - **Effects update the DOM** — run when dependencies change, return cleanup
213
+ - **JSX creates the DOM** — real elements, not virtual. Signal-aware props and children
214
+ - **Routes swap the DOM** — hash-based, dispose-scoped, Bun.serve-style tables
215
+
216
+ No lifecycle methods. No hooks rules. No context providers. No `useCallback`. Just signals and the DOM.
217
+
218
+ ## Claude Code
219
+
220
+ This package ships with a [Claude Code](https://claude.com/claude-code) skill in `.claude/skills/railroad/`. Copy it into your project so Claude generates correct railroad code — including API usage, patterns, and anti-patterns:
221
+
222
+ ```sh
223
+ cp -r node_modules/@blueshed/railroad/.claude/skills/railroad .claude/skills/
224
+ ```
225
+
226
+ Or install it user-wide (available in all projects):
227
+
228
+ ```sh
229
+ cp -r node_modules/@blueshed/railroad/.claude/skills/railroad ~/.claude/skills/
230
+ ```
231
+
232
+ ## License
233
+
234
+ MIT
package/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Railroad — Signals, JSX, and Routes
2
+
3
+ export { Signal, signal, computed, effect, batch } from "./signals";
4
+ export type { Dispose } from "./signals";
5
+
6
+ export {
7
+ createElement, Fragment,
8
+ text, when, list,
9
+ pushDisposeScope, popDisposeScope,
10
+ } from "./jsx";
11
+
12
+ export { routes, route, navigate, matchRoute } from "./routes";
13
+
14
+ export { key, provide, inject, tryInject } from "./shared";
15
+ export type { Key } from "./shared";
16
+
17
+ export { createLogger, setLogLevel, getLogLevel, loggedRequest } from "./logger";
18
+ export type { LogLevel } from "./logger";
@@ -0,0 +1,4 @@
1
+ // Development JSX runtime — re-exports the production runtime.
2
+ // Required for TypeScript's "jsx": "react-jsxdev" mode.
3
+
4
+ export { jsx, jsx as jsxDEV, jsxs, Fragment, type JSX } from "./jsx-runtime";
package/jsx-runtime.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Automatic JSX Runtime for Railroad
3
+ *
4
+ * Enables "jsx": "react-jsx" / jsxImportSource so consumers
5
+ * can write JSX without importing createElement.
6
+ *
7
+ * tsconfig.json:
8
+ * { "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
9
+ */
10
+
11
+ import { createElement, Fragment } from "./jsx";
12
+
13
+ export { Fragment };
14
+
15
+ export function jsx(
16
+ type: string | Function,
17
+ props: Record<string, any> | null,
18
+ _key?: string,
19
+ ): Node {
20
+ if (!props) return createElement(type, null);
21
+ const { children, ...rest } = props;
22
+ if (children === undefined) return createElement(type, rest);
23
+ if (Array.isArray(children)) return createElement(type, rest, ...children);
24
+ return createElement(type, rest, children);
25
+ }
26
+
27
+ export { jsx as jsxs };
28
+
29
+ // Re-export JSX namespace so TypeScript react-jsx mode finds the types
30
+ export namespace JSX {
31
+ export type Element = globalThis.Node;
32
+ export interface IntrinsicElements {
33
+ [tag: string]: any;
34
+ }
35
+ }