@getforma/core 1.0.1 → 1.0.2
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/README.md +469 -287
- package/dist/formajs-runtime-hardened.global.js.map +1 -1
- package/dist/formajs-runtime.global.js.map +1 -1
- package/dist/formajs.global.js +0 -4
- package/dist/formajs.global.js.map +1 -1
- package/dist/index.cjs +0 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -4
- package/dist/index.d.ts +1 -4
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime-hardened.cjs.map +1 -1
- package/dist/runtime-hardened.js.map +1 -1
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/getforma-dev/formajs/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@getforma/core)
|
|
5
|
+
[](https://socket.dev/npm/package/@getforma/core)
|
|
5
6
|
[](https://opensource.org/licenses/MIT)
|
|
6
7
|
|
|
7
|
-
Reactive DOM library with fine-grained signals. No virtual DOM —
|
|
8
|
+
Reactive DOM library with fine-grained signals. No virtual DOM — signals update only the DOM nodes that changed. Components run once. ~15 KB gzipped.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { createSignal, h, mount } from "@getforma/core";
|
|
12
|
+
|
|
13
|
+
const [count, setCount] = createSignal(0);
|
|
14
|
+
|
|
15
|
+
function Counter() {
|
|
16
|
+
return (
|
|
17
|
+
<button onClick={() => setCount((c) => c + 1)}>
|
|
18
|
+
{() => `Clicked ${count()} times`}
|
|
19
|
+
</button>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
mount(() => <Counter />, "#app");
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No re-renders. No dependency arrays. No `useMemo`. The button text updates because it reads `count()` inside a reactive function — nothing else in the tree is touched.
|
|
27
|
+
|
|
28
|
+
---
|
|
8
29
|
|
|
9
30
|
## Install
|
|
10
31
|
|
|
@@ -12,15 +33,23 @@ Reactive DOM library with fine-grained signals. No virtual DOM — `h()` creates
|
|
|
12
33
|
npm install @getforma/core
|
|
13
34
|
```
|
|
14
35
|
|
|
15
|
-
Or use
|
|
36
|
+
Or use a CDN — no build step, no bundler:
|
|
16
37
|
|
|
17
38
|
```html
|
|
39
|
+
<!-- jsDelivr (recommended) -->
|
|
40
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@latest/dist/formajs-runtime.global.js"></script>
|
|
41
|
+
|
|
42
|
+
<!-- unpkg -->
|
|
18
43
|
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime.global.js"></script>
|
|
19
44
|
```
|
|
20
45
|
|
|
21
|
-
|
|
46
|
+
> **Production:** Pin the version (e.g., `@getforma/core@1.0.1`) instead of `@latest`.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Getting Started with a Bundler
|
|
22
51
|
|
|
23
|
-
After `npm install`, you need a bundler to resolve
|
|
52
|
+
After `npm install`, you need a bundler to resolve ES module imports. Here's a minimal Vite setup:
|
|
24
53
|
|
|
25
54
|
```bash
|
|
26
55
|
npm install @getforma/core
|
|
@@ -33,17 +62,20 @@ npm install -D vite
|
|
|
33
62
|
<script type="module" src="./main.ts"></script>
|
|
34
63
|
```
|
|
35
64
|
|
|
36
|
-
```
|
|
65
|
+
```ts
|
|
37
66
|
// main.ts
|
|
38
|
-
import { createSignal, h, mount } from
|
|
67
|
+
import { createSignal, h, mount } from "@getforma/core";
|
|
39
68
|
|
|
40
69
|
const [count, setCount] = createSignal(0);
|
|
41
70
|
|
|
42
|
-
mount(
|
|
43
|
-
|
|
44
|
-
(
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
mount(
|
|
72
|
+
() =>
|
|
73
|
+
h(
|
|
74
|
+
"button",
|
|
75
|
+
{ onClick: () => setCount((c) => c + 1) },
|
|
76
|
+
() => `Clicked ${count()} times`,
|
|
77
|
+
),
|
|
78
|
+
"#app",
|
|
47
79
|
);
|
|
48
80
|
```
|
|
49
81
|
|
|
@@ -51,167 +83,288 @@ mount(() =>
|
|
|
51
83
|
npx vite
|
|
52
84
|
```
|
|
53
85
|
|
|
54
|
-
|
|
86
|
+
**Any bundler works.** Vite, esbuild, tsup, webpack, Rollup — FormaJS ships standard ESM and CJS via `package.json` exports. No plugins, no special config.
|
|
55
87
|
|
|
56
|
-
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Coming from React?
|
|
91
|
+
|
|
92
|
+
If you know React, you already know ~80% of FormaJS. Components are functions. Props flow down. You import, export, and compose the same way. The difference is *how reactivity works* — and it's simpler.
|
|
57
93
|
|
|
58
|
-
|
|
94
|
+
React re-runs your entire component function on every state change, diffs a virtual DOM, and patches the real one. FormaJS runs each component **once**. Signals update only the specific DOM nodes that read them. No reconciliation, no stale closures, no `useCallback`.
|
|
59
95
|
|
|
60
|
-
|
|
96
|
+
| React | FormaJS | What changes |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `useState` | `createSignal` | Same `[value, setter]` tuple |
|
|
99
|
+
| `useMemo` | `createComputed` | No dependency array — auto-tracks |
|
|
100
|
+
| `useEffect` | `createEffect` | No dependency array — auto-tracks |
|
|
101
|
+
| `useReducer` | `createReducer` | Same dispatch pattern |
|
|
102
|
+
| `useContext` | `createContext` / `inject` | Same provider pattern |
|
|
103
|
+
| `React.memo` | *Not needed* | Components already run once |
|
|
104
|
+
| Component functions | Same | `function Counter(props) { ... }` |
|
|
105
|
+
| Props | Same | `<Counter count={count} />` |
|
|
106
|
+
| Children | Same | Rest params or `props.children` |
|
|
107
|
+
| Import / export | Same | Standard ES modules |
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
- **Fine-grained reactivity.** Powered by [alien-signals](https://github.com/nicolo-ribaudo/alien-signals). When a signal changes, only the specific DOM text node or attribute that depends on it updates — not the whole component tree.
|
|
64
|
-
- **Three entry points, one engine.** HTML Runtime (like Alpine — zero build step), `h()` hyperscript (like Preact), or JSX. All share the same signal graph. Pick the right tool for the job, upgrade without rewriting.
|
|
65
|
-
- **CSP-safe capable.** The HTML Runtime includes a hand-written expression parser. The standard build enables `new Function()` as a fallback for complex expressions; the hardened build (`forma.hardened.js`) locks it off entirely for strict CSP environments.
|
|
66
|
-
- **Islands over SPAs.** `activateIslands()` hydrates independent regions of server-rendered HTML. Each island is self-contained. Ship less JavaScript, keep server-rendered content instant.
|
|
109
|
+
**The mental model:** "I write components the same way, pass props the same way, compose the same way — but I never think about re-renders, dependency arrays, or memoization. Signals just work."
|
|
67
110
|
|
|
68
|
-
|
|
111
|
+
---
|
|
69
112
|
|
|
70
113
|
## Three Ways to Use FormaJS
|
|
71
114
|
|
|
72
|
-
|
|
115
|
+
All three share the same signal graph and reactive engine. Pick the one that fits your project — or mix them.
|
|
116
|
+
|
|
117
|
+
### 1. JSX
|
|
118
|
+
|
|
119
|
+
The most familiar path for React and Solid developers. JSX compiles to `h()` calls — it's syntactic sugar, not a different system.
|
|
120
|
+
|
|
121
|
+
Configure your bundler (TypeScript or Babel):
|
|
122
|
+
|
|
123
|
+
```jsonc
|
|
124
|
+
// tsconfig.json
|
|
125
|
+
{
|
|
126
|
+
"compilerOptions": {
|
|
127
|
+
"jsx": "react",
|
|
128
|
+
"jsxFactory": "h",
|
|
129
|
+
"jsxFragmentFactory": "Fragment"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { createSignal, h, Fragment, mount } from "@getforma/core";
|
|
136
|
+
|
|
137
|
+
const [count, setCount] = createSignal(0);
|
|
138
|
+
|
|
139
|
+
function Counter() {
|
|
140
|
+
return (
|
|
141
|
+
<>
|
|
142
|
+
<p>{() => `Count: ${count()}`}</p>
|
|
143
|
+
<button onClick={() => setCount((c) => c + 1)}>+1</button>
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
mount(() => <Counter />, "#app");
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Under the hood, the JSX above compiles to the exact `h()` calls shown in the next section. There's no JSX-specific runtime — it's the same function.
|
|
152
|
+
|
|
153
|
+
### 2. Hyperscript — `h()`
|
|
154
|
+
|
|
155
|
+
No JSX transform needed. Same reactive behavior, explicit function calls.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { createSignal, h, mount } from "@getforma/core";
|
|
73
159
|
|
|
74
|
-
|
|
160
|
+
const [count, setCount] = createSignal(0);
|
|
161
|
+
|
|
162
|
+
mount(
|
|
163
|
+
() =>
|
|
164
|
+
h(
|
|
165
|
+
"button",
|
|
166
|
+
{ onClick: () => setCount((c) => c + 1) },
|
|
167
|
+
() => `Clicked ${count()} times`,
|
|
168
|
+
),
|
|
169
|
+
"#app",
|
|
170
|
+
);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See [The `h()` function](#the-h-function) below for the full signature and all call patterns.
|
|
174
|
+
|
|
175
|
+
### 3. HTML Runtime (no build step)
|
|
176
|
+
|
|
177
|
+
Drop a script tag, write `data-*` attributes. Zero config, zero tooling — works from a CDN.
|
|
75
178
|
|
|
76
179
|
```html
|
|
77
|
-
<script src="https://
|
|
180
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@latest/dist/formajs-runtime.global.js"></script>
|
|
78
181
|
|
|
79
|
-
<div data-forma-state='{"count": 0}'>
|
|
182
|
+
<div data-forma-state='{ "count": 0 }'>
|
|
80
183
|
<p data-text="{count}"></p>
|
|
81
184
|
<button data-on:click="{count++}">+1</button>
|
|
82
185
|
<button data-on:click="{count = 0}">Reset</button>
|
|
83
186
|
</div>
|
|
84
187
|
```
|
|
85
188
|
|
|
86
|
-
|
|
189
|
+
The expression parser is hand-written — no `eval()`, no `new Function()` by default. For strict CSP environments, use the hardened build:
|
|
190
|
+
|
|
191
|
+
```html
|
|
192
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@latest/dist/formajs-runtime-hardened.global.js"></script>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
<details>
|
|
196
|
+
<summary><strong>Full directive reference</strong></summary>
|
|
87
197
|
|
|
88
198
|
| Directive | Description | Example |
|
|
89
|
-
|
|
199
|
+
|---|---|---|
|
|
90
200
|
| `data-forma-state` | Declare reactive state (JSON) | `data-forma-state='{"count": 0}'` |
|
|
91
201
|
| `data-text` | Bind text content | `data-text="{count}"` |
|
|
92
202
|
| `data-show` | Toggle visibility (display) | `data-show="{isOpen}"` |
|
|
93
|
-
| `data-if` | Conditional render (add/remove
|
|
203
|
+
| `data-if` | Conditional render (add/remove DOM) | `data-if="{loggedIn}"` |
|
|
94
204
|
| `data-model` | Two-way binding (inputs) | `data-model="{email}"` |
|
|
95
205
|
| `data-on:event` | Event handler | `data-on:click="{count++}"` |
|
|
96
206
|
| `data-class:name` | Conditional CSS class | `data-class:active="{isActive}"` |
|
|
97
207
|
| `data-bind:attr` | Dynamic attribute | `data-bind:href="{url}"` |
|
|
98
|
-
| `data-list` | List rendering
|
|
208
|
+
| `data-list` | List rendering (keyed reconciliation) | `data-list="{items}"` |
|
|
99
209
|
| `data-computed` | Computed value | `data-computed="doubled = count * 2"` |
|
|
100
210
|
| `data-persist` | Persist state to localStorage | `data-persist="{count}"` |
|
|
101
211
|
| `data-fetch` | Fetch data from URL | `data-fetch="GET /api/items → items"` |
|
|
102
212
|
| `data-transition:*` | Enter/leave CSS transitions | `data-transition:enter="fade-in"` |
|
|
103
213
|
| `data-ref` | Register element for `$refs` access | `data-ref="myInput"` |
|
|
104
|
-
| `$el` |
|
|
105
|
-
| `$dispatch` | Fire
|
|
214
|
+
| `$el` | Current DOM element | `data-on:click="{$el.classList.toggle('active')}"` |
|
|
215
|
+
| `$dispatch` | Fire CustomEvent (bubbles, crosses Shadow DOM) | `data-on:click="{$dispatch('selected', {id})}"` |
|
|
106
216
|
| `$refs` | Named element references | `data-on:click="{$refs.myInput.focus()}"` |
|
|
107
217
|
|
|
108
|
-
|
|
218
|
+
</details>
|
|
109
219
|
|
|
110
|
-
|
|
111
|
-
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime-hardened.global.js"></script>
|
|
112
|
-
```
|
|
220
|
+
---
|
|
113
221
|
|
|
114
|
-
|
|
222
|
+
## The `h()` Function
|
|
115
223
|
|
|
116
|
-
|
|
117
|
-
|
|
224
|
+
`h()` is the core of FormaJS rendering. Every component — whether written in JSX, hyperscript, or compiled from the HTML Runtime — resolves to `h()` calls that create real DOM elements.
|
|
225
|
+
|
|
226
|
+
### Signature
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
h(tag, props?, ...children)
|
|
118
230
|
```
|
|
119
231
|
|
|
120
|
-
|
|
121
|
-
|
|
232
|
+
| Parameter | Type | Description |
|
|
233
|
+
|---|---|---|
|
|
234
|
+
| `tag` | `string \| Function` | An HTML tag name (`'div'`, `'button'`) or a component function (`Counter`) |
|
|
235
|
+
| `props` | `object \| null` | Attributes, event handlers, and component props. Pass `null` or `{}` to skip. |
|
|
236
|
+
| `children` | `string \| number \| () => string \| Node \| Array` | Zero or more children — static text, reactive functions, elements, or arrays of any of these. |
|
|
122
237
|
|
|
123
|
-
|
|
238
|
+
### The key rule
|
|
124
239
|
|
|
125
|
-
|
|
126
|
-
h('button', { onClick: () => setCount(count() + 1) },
|
|
127
|
-
() => `Clicked ${count()} times`
|
|
128
|
-
),
|
|
129
|
-
'#app'
|
|
130
|
-
);
|
|
131
|
-
```
|
|
240
|
+
**If a child is a function, it's reactive.** FormaJS wraps it in an effect so the DOM text node or subtree updates automatically when signals inside it change. If a child is a plain string or number, it's static — rendered once, never touched again.
|
|
132
241
|
|
|
133
|
-
###
|
|
242
|
+
### Patterns
|
|
134
243
|
|
|
135
|
-
|
|
244
|
+
```ts
|
|
245
|
+
// Static text child
|
|
246
|
+
h("footer", { class: "text-sm" }, "Built with Forma")
|
|
136
247
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{
|
|
140
|
-
"compilerOptions": {
|
|
141
|
-
"jsx": "react",
|
|
142
|
-
"jsxFactory": "h",
|
|
143
|
-
"jsxFragmentFactory": "Fragment"
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
```
|
|
248
|
+
// Reactive text child — updates when count() changes
|
|
249
|
+
h("button", { onClick: fn }, () => `Count: ${count()}`)
|
|
147
250
|
|
|
148
|
-
|
|
149
|
-
|
|
251
|
+
// Multiple children
|
|
252
|
+
h("div", { class: "card" },
|
|
253
|
+
h("h2", null, "Title"),
|
|
254
|
+
h("p", null, "Body text"),
|
|
255
|
+
h("button", { onClick: fn }, "Click"),
|
|
256
|
+
)
|
|
150
257
|
|
|
151
|
-
|
|
258
|
+
// Children as an array (useful for dynamic lists)
|
|
259
|
+
h("ul", null, items.map(item => h("li", null, item.name)))
|
|
152
260
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
</>
|
|
159
|
-
);
|
|
160
|
-
}
|
|
261
|
+
// No props, just children
|
|
262
|
+
h("p", null, "Hello world")
|
|
263
|
+
|
|
264
|
+
// Component function with props
|
|
265
|
+
h(Counter, { initial: 5 })
|
|
161
266
|
|
|
162
|
-
|
|
267
|
+
// Nested reactive children
|
|
268
|
+
h("div", null,
|
|
269
|
+
() => showHeader() ? h("h1", null, "Welcome") : null,
|
|
270
|
+
h("p", null, () => `You have ${count()} items`),
|
|
271
|
+
)
|
|
163
272
|
```
|
|
164
273
|
|
|
165
|
-
|
|
274
|
+
### JSX equivalence
|
|
166
275
|
|
|
167
|
-
|
|
276
|
+
JSX is syntactic sugar that compiles to `h()` calls. These are identical:
|
|
168
277
|
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
<
|
|
278
|
+
```tsx
|
|
279
|
+
// JSX
|
|
280
|
+
<button class="btn" onClick={() => setCount((c) => c + 1)}>
|
|
281
|
+
{() => `Count: ${count()}`}
|
|
282
|
+
</button>
|
|
172
283
|
|
|
173
|
-
|
|
174
|
-
|
|
284
|
+
// h()
|
|
285
|
+
h("button", { class: "btn", onClick: () => setCount((c) => c + 1) },
|
|
286
|
+
() => `Count: ${count()}`
|
|
287
|
+
)
|
|
175
288
|
```
|
|
176
289
|
|
|
177
|
-
|
|
290
|
+
---
|
|
178
291
|
|
|
179
|
-
|
|
180
|
-
<script type="module">
|
|
181
|
-
import { createSignal, h, mount } from 'https://cdn.jsdelivr.net/npm/@getforma/core@1.0.0/dist/index.js';
|
|
292
|
+
## Why FormaJS?
|
|
182
293
|
|
|
183
|
-
|
|
184
|
-
mount(() => h('button', { onClick: () => setCount(count() + 1) }, () => `${count()}`), '#app');
|
|
185
|
-
</script>
|
|
186
|
-
```
|
|
294
|
+
Most UI libraries force a choice: simple but limited (Alpine, htmx), or powerful but heavy (React, Vue, Svelte). FormaJS gives you a single reactive core that scales from a CDN script tag to a compiled Rust SSR pipeline.
|
|
187
295
|
|
|
188
|
-
|
|
296
|
+
**Components run once.** No virtual DOM, no diffing, no reconciliation overhead. `h('div')` returns an actual `HTMLDivElement`. When a signal changes, only the specific text node or attribute that reads it updates — not the component, not the tree.
|
|
189
297
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
298
|
+
**Fine-grained reactivity.** Powered by [alien-signals](https://github.com/nicolo-ribaudo/alien-signals) 3.x. The signal graph tracks dependencies automatically. No dependency arrays, no stale closures, no `useCallback` / `useMemo` ceremony.
|
|
299
|
+
|
|
300
|
+
**Three entry points, one engine.** HTML Runtime (like Alpine — zero build step), `h()` hyperscript (like Preact), or JSX (like React/Solid). All share the same signal graph. Start with a CDN script tag, graduate to a full build pipeline without rewriting.
|
|
301
|
+
|
|
302
|
+
**Islands over SPAs.** `activateIslands()` hydrates independent regions of server-rendered HTML. Each island is self-contained with error isolation, deferred hydration triggers (`visible`, `idle`, `interaction`), and disposal for module swaps.
|
|
303
|
+
|
|
304
|
+
**CSP-safe.** The HTML Runtime includes a hand-written expression parser — no `eval()`, no `new Function()`. The hardened build locks it off entirely, with zero `new Function` in the dist (verified by dead code elimination).
|
|
305
|
+
|
|
306
|
+
**What FormaJS is not:** It's not a framework with opinions about routing, data fetching, or state management. It's a reactive DOM library. You bring the architecture.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## The Rust Compiler (Optional)
|
|
311
|
+
|
|
312
|
+
Everything above works without the Rust compiler. You can build a complete application with just `npm install @getforma/core` and a bundler. The compiler is an **optimization layer** — you add it when performance and deployment constraints demand it.
|
|
313
|
+
|
|
314
|
+
### What the compiler does
|
|
315
|
+
|
|
316
|
+
| Without compiler | With compiler |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `h()` calls create DOM elements at runtime | `h()` calls are pre-compiled to `template()` + `cloneNode()` for faster initial render |
|
|
319
|
+
| SSR requires Node.js (`renderToString`) | SSR runs natively in Rust via the FMIR binary walker — no JS runtime on the server |
|
|
320
|
+
| Standard JS bundle shipped to the client | Components compile to FMIR (Forma Module IR), a compact binary format sent over the wire |
|
|
321
|
+
| Islands hydrate from HTML + JS | Islands hydrate from FMIR binary — smaller payload, faster parse |
|
|
322
|
+
|
|
323
|
+
### When to add it
|
|
324
|
+
|
|
325
|
+
You don't need the compiler to get started, prototype, or even ship to production. Add it when:
|
|
196
326
|
|
|
197
|
-
|
|
327
|
+
- **SSR without Node.js** — your backend is Rust/Axum and you don't want a Node.js sidecar just for rendering.
|
|
328
|
+
- **Faster initial render** — pre-compiled templates skip the `h()` → `createElement` path and go straight to `cloneNode()`.
|
|
329
|
+
- **Smaller payloads** — FMIR binary is more compact than the equivalent JavaScript for complex component trees.
|
|
330
|
+
- **The full Forma stack** — `@getforma/compiler` → FMIR → `forma-ir` (Rust parser) → `forma-server` (Axum SSR) gives you a complete pipeline at ~$18/month deployment cost.
|
|
198
331
|
|
|
199
|
-
|
|
332
|
+
### Architecture
|
|
200
333
|
|
|
201
|
-
|
|
202
|
-
|
|
334
|
+
```
|
|
335
|
+
TypeScript/JSX components
|
|
336
|
+
↓
|
|
337
|
+
@getforma/compiler (TS → FMIR binary)
|
|
338
|
+
↓
|
|
339
|
+
forma-ir (Rust: parse + walk FMIR)
|
|
340
|
+
↓
|
|
341
|
+
forma-server (Rust/Axum: SSR + asset serving + CSP)
|
|
342
|
+
↓
|
|
343
|
+
HTML response (server-rendered, islands hydrate on client)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
All entry points — JSX, `h()`, and the HTML Runtime — work both with and without the compiler:
|
|
347
|
+
|
|
348
|
+
| | Without Compiler | With Compiler |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| HTML Runtime | `data-*` directives | + SSR from IR walker |
|
|
351
|
+
| `h()` hyperscript | `createSignal` + `h()` | + compiled templates |
|
|
352
|
+
| JSX | `createSignal` + JSX | + compiled templates + SSR |
|
|
353
|
+
| Islands | `activateIslands()` | + FMIR hydration |
|
|
354
|
+
|
|
355
|
+
---
|
|
203
356
|
|
|
204
357
|
## Core API
|
|
205
358
|
|
|
206
359
|
### Signals
|
|
207
360
|
|
|
208
|
-
```
|
|
209
|
-
import { createSignal, createEffect, createComputed, batch } from
|
|
361
|
+
```ts
|
|
362
|
+
import { createSignal, createEffect, createComputed, batch } from "@getforma/core";
|
|
210
363
|
|
|
211
364
|
const [count, setCount] = createSignal(0);
|
|
212
365
|
const doubled = createComputed(() => count() * 2);
|
|
213
366
|
|
|
214
|
-
createEffect(() => console.log(
|
|
367
|
+
createEffect(() => console.log("count:", count()));
|
|
215
368
|
|
|
216
369
|
batch(() => {
|
|
217
370
|
setCount(1);
|
|
@@ -219,25 +372,21 @@ batch(() => {
|
|
|
219
372
|
});
|
|
220
373
|
```
|
|
221
374
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
Skip updates when the new value is equal to the current value:
|
|
375
|
+
**Custom equality** — skip updates when the value hasn't meaningfully changed:
|
|
225
376
|
|
|
226
|
-
```
|
|
377
|
+
```ts
|
|
227
378
|
const [pos, setPos] = createSignal(
|
|
228
379
|
{ x: 0, y: 0 },
|
|
229
380
|
{ equals: (a, b) => a.x === b.x && a.y === b.y },
|
|
230
381
|
);
|
|
231
382
|
|
|
232
|
-
setPos({ x: 0, y: 0 }); // skipped —
|
|
233
|
-
setPos({ x: 1, y: 0 }); // applied —
|
|
383
|
+
setPos({ x: 0, y: 0 }); // skipped — equal
|
|
384
|
+
setPos({ x: 1, y: 0 }); // applied — different
|
|
234
385
|
```
|
|
235
386
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
The computed getter receives the previous value for efficient diffing:
|
|
387
|
+
**Computed with previous value** — the getter receives the previous result:
|
|
239
388
|
|
|
240
|
-
```
|
|
389
|
+
```ts
|
|
241
390
|
const changes = createComputed((prev) => {
|
|
242
391
|
const current = items();
|
|
243
392
|
if (prev) console.log(`${prev.length} → ${current.length} items`);
|
|
@@ -245,218 +394,217 @@ const changes = createComputed((prev) => {
|
|
|
245
394
|
});
|
|
246
395
|
```
|
|
247
396
|
|
|
248
|
-
|
|
397
|
+
**Reactive introspection** — type guards and utilities from alien-signals 3.x:
|
|
249
398
|
|
|
250
|
-
|
|
399
|
+
```ts
|
|
400
|
+
import { isSignal, isComputed, getBatchDepth, trigger } from "@getforma/core";
|
|
251
401
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
isComputed(doubled); // true — is this a computed value?
|
|
257
|
-
getBatchDepth(); // 0 outside batch, 1+ inside
|
|
258
|
-
trigger(doubled); // force recomputation even if deps unchanged
|
|
402
|
+
isSignal(count); // true
|
|
403
|
+
isComputed(doubled); // true
|
|
404
|
+
getBatchDepth(); // 0 outside batch, 1+ inside
|
|
405
|
+
trigger(doubled); // force recomputation
|
|
259
406
|
```
|
|
260
407
|
|
|
261
408
|
### Conditional Rendering
|
|
262
409
|
|
|
263
|
-
```
|
|
264
|
-
import { createSignal, createShow, createSwitch, h } from
|
|
410
|
+
```ts
|
|
411
|
+
import { createSignal, createShow, createSwitch, h } from "@getforma/core";
|
|
265
412
|
|
|
266
413
|
const [loggedIn, setLoggedIn] = createSignal(false);
|
|
267
414
|
|
|
268
|
-
//
|
|
269
|
-
createShow(
|
|
270
|
-
|
|
271
|
-
() => h(
|
|
415
|
+
// Two branches
|
|
416
|
+
createShow(
|
|
417
|
+
loggedIn,
|
|
418
|
+
() => h("p", null, "Welcome back"),
|
|
419
|
+
() => h("p", null, "Please sign in"),
|
|
272
420
|
);
|
|
273
421
|
|
|
274
|
-
//
|
|
275
|
-
const [view, setView] = createSignal(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
422
|
+
// Multi-branch with caching
|
|
423
|
+
const [view, setView] = createSignal("home");
|
|
424
|
+
|
|
425
|
+
createSwitch(
|
|
426
|
+
view,
|
|
427
|
+
[
|
|
428
|
+
{ match: "home", render: () => h("div", null, "Home") },
|
|
429
|
+
{ match: "settings", render: () => h("div", null, "Settings") },
|
|
430
|
+
],
|
|
431
|
+
() => h("div", null, "404 Not Found"),
|
|
432
|
+
);
|
|
280
433
|
```
|
|
281
434
|
|
|
282
435
|
### List Rendering
|
|
283
436
|
|
|
284
|
-
```
|
|
285
|
-
import { createSignal, createList, h } from
|
|
437
|
+
```ts
|
|
438
|
+
import { createSignal, createList, h } from "@getforma/core";
|
|
286
439
|
|
|
287
440
|
const [items, setItems] = createSignal([
|
|
288
|
-
{ id: 1, name:
|
|
289
|
-
{ id: 2, name:
|
|
441
|
+
{ id: 1, name: "Alice" },
|
|
442
|
+
{ id: 2, name: "Bob" },
|
|
290
443
|
]);
|
|
291
444
|
|
|
292
445
|
createList(
|
|
293
446
|
items,
|
|
294
|
-
(item) => item.id,
|
|
295
|
-
(item) => h(
|
|
447
|
+
(item) => item.id,
|
|
448
|
+
(item) => h("li", null, item.name),
|
|
296
449
|
);
|
|
297
450
|
```
|
|
298
451
|
|
|
299
|
-
### Store (
|
|
452
|
+
### Store (Deep Reactivity)
|
|
300
453
|
|
|
301
|
-
```
|
|
302
|
-
import { createStore } from
|
|
454
|
+
```ts
|
|
455
|
+
import { createStore } from "@getforma/core";
|
|
303
456
|
|
|
304
457
|
const [state, setState] = createStore({
|
|
305
|
-
user: { name:
|
|
458
|
+
user: { name: "Alice", prefs: { theme: "dark" } },
|
|
306
459
|
items: [1, 2, 3],
|
|
307
460
|
});
|
|
308
461
|
|
|
309
462
|
// Read reactively — tracked at the exact property path
|
|
310
|
-
state.user.name;
|
|
311
|
-
state.items[0];
|
|
463
|
+
state.user.name; // "Alice"
|
|
464
|
+
state.items[0]; // 1
|
|
312
465
|
|
|
313
|
-
// Setter API — partial
|
|
314
|
-
setState({ user: { ...state.user, name:
|
|
315
|
-
setState(prev => ({ items: [...prev.items, 4] }));
|
|
466
|
+
// Setter API — partial merge
|
|
467
|
+
setState({ user: { ...state.user, name: "Bob" } });
|
|
468
|
+
setState((prev) => ({ items: [...prev.items, 4] }));
|
|
316
469
|
|
|
317
|
-
// Or mutate directly
|
|
318
|
-
state.user.name =
|
|
319
|
-
state.items.push(4);
|
|
470
|
+
// Or mutate directly — only affected subscribers update
|
|
471
|
+
state.user.name = "Bob";
|
|
472
|
+
state.items.push(4);
|
|
320
473
|
```
|
|
321
474
|
|
|
322
|
-
|
|
475
|
+
> **Note:** `Object.keys(state)`, `for...in`, and spread (`{...state}`) are NOT reactive. Use signals or explicit arrays for collections that need to react to membership changes.
|
|
323
476
|
|
|
324
|
-
|
|
325
|
-
|
|
477
|
+
### Components & Lifecycle
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
import { createSignal, defineComponent, onMount, onUnmount, h } from "@getforma/core";
|
|
326
481
|
|
|
327
482
|
const Timer = defineComponent(() => {
|
|
328
483
|
const [seconds, setSeconds] = createSignal(0);
|
|
329
484
|
|
|
330
485
|
onMount(() => {
|
|
331
|
-
const id = setInterval(() => setSeconds(s => s + 1), 1000);
|
|
486
|
+
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
332
487
|
return () => clearInterval(id); // cleanup on unmount
|
|
333
488
|
});
|
|
334
489
|
|
|
335
|
-
return h(
|
|
490
|
+
return h("span", null, () => `${seconds()}s`);
|
|
336
491
|
});
|
|
337
492
|
|
|
338
493
|
document.body.appendChild(Timer());
|
|
339
494
|
```
|
|
340
495
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
- **`onMount(fn)`** — runs after the component's DOM is created. If `fn` returns a function, that function is automatically registered as an unmount callback.
|
|
344
|
-
- **`onUnmount(fn)`** — explicitly registers a cleanup function that runs when the component is disposed.
|
|
345
|
-
|
|
346
|
-
Both mechanisms feed into the same cleanup queue — the `onMount` return shorthand is convenience for the common pattern of setting up and tearing down in one place:
|
|
496
|
+
`onMount(fn)` runs after DOM creation. If `fn` returns a function, it registers as an unmount callback. `onUnmount(fn)` explicitly registers cleanup. Both feed the same cleanup queue:
|
|
347
497
|
|
|
348
|
-
```
|
|
498
|
+
```ts
|
|
349
499
|
// These are equivalent:
|
|
350
500
|
onMount(() => {
|
|
351
501
|
const id = setInterval(tick, 1000);
|
|
352
502
|
return () => clearInterval(id);
|
|
353
503
|
});
|
|
354
504
|
|
|
355
|
-
// vs.
|
|
356
505
|
onMount(() => {
|
|
357
506
|
const id = setInterval(tick, 1000);
|
|
358
507
|
onUnmount(() => clearInterval(id));
|
|
359
508
|
});
|
|
360
509
|
```
|
|
361
510
|
|
|
362
|
-
###
|
|
363
|
-
|
|
364
|
-
**`mount()` fails fast.** If the container selector doesn't match any element, it throws:
|
|
365
|
-
|
|
366
|
-
```typescript
|
|
367
|
-
mount(() => h('p', null, 'hello'), '#nonexistent');
|
|
368
|
-
// Error: mount: container not found — "#nonexistent"
|
|
369
|
-
```
|
|
511
|
+
### Context (Dependency Injection)
|
|
370
512
|
|
|
371
|
-
|
|
513
|
+
```ts
|
|
514
|
+
import { createContext, provide, inject } from "@getforma/core";
|
|
372
515
|
|
|
373
|
-
|
|
374
|
-
import { onError } from '@getforma/core';
|
|
516
|
+
const ThemeCtx = createContext("light");
|
|
375
517
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
});
|
|
518
|
+
provide(ThemeCtx, "dark");
|
|
519
|
+
const theme = inject(ThemeCtx); // "dark"
|
|
379
520
|
```
|
|
380
521
|
|
|
381
|
-
|
|
522
|
+
### Reducer
|
|
382
523
|
|
|
383
|
-
```
|
|
384
|
-
import {
|
|
524
|
+
```ts
|
|
525
|
+
import { createReducer } from "@getforma/core";
|
|
385
526
|
|
|
386
|
-
|
|
387
|
-
() =>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
527
|
+
const [state, dispatch] = createReducer(
|
|
528
|
+
(state, action) => {
|
|
529
|
+
switch (action.type) {
|
|
530
|
+
case "INCREMENT": return { count: state.count + 1 };
|
|
531
|
+
case "DECREMENT": return { count: state.count - 1 };
|
|
532
|
+
default: return state;
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
{ count: 0 },
|
|
392
536
|
);
|
|
537
|
+
|
|
538
|
+
dispatch({ type: "INCREMENT" }); // state() === { count: 1 }
|
|
393
539
|
```
|
|
394
540
|
|
|
395
|
-
###
|
|
541
|
+
### History (Undo / Redo)
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
import { createHistory } from "@getforma/core";
|
|
396
545
|
|
|
397
|
-
|
|
398
|
-
import { createContext, provide, inject } from '@getforma/core';
|
|
546
|
+
const [state, setState, { undo, redo, canUndo, canRedo }] = createHistory({ text: "" });
|
|
399
547
|
|
|
400
|
-
|
|
548
|
+
setState({ text: "hello" });
|
|
549
|
+
setState({ text: "hello world" });
|
|
401
550
|
|
|
402
|
-
|
|
403
|
-
|
|
551
|
+
undo(); // state.text === "hello"
|
|
552
|
+
canUndo(); // true
|
|
553
|
+
redo(); // state.text === "hello world"
|
|
404
554
|
```
|
|
405
555
|
|
|
406
|
-
###
|
|
556
|
+
### Error Handling
|
|
557
|
+
|
|
558
|
+
`mount()` fails fast — if the selector doesn't match, it throws:
|
|
407
559
|
|
|
408
|
-
```
|
|
409
|
-
|
|
560
|
+
```ts
|
|
561
|
+
mount(() => h("p", null, "hello"), "#nonexistent");
|
|
562
|
+
// Error: mount: container not found — "#nonexistent"
|
|
563
|
+
```
|
|
410
564
|
|
|
411
|
-
|
|
565
|
+
**Global error handler** for effects and lifecycle callbacks:
|
|
412
566
|
|
|
413
|
-
|
|
414
|
-
|
|
567
|
+
```ts
|
|
568
|
+
import { onError } from "@getforma/core";
|
|
415
569
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
570
|
+
onError((error, info) => {
|
|
571
|
+
console.error(`[${info?.source}]`, error);
|
|
572
|
+
});
|
|
419
573
|
```
|
|
420
574
|
|
|
421
|
-
|
|
575
|
+
**Error boundaries** — catch rendering errors with fallback UI:
|
|
422
576
|
|
|
423
|
-
```
|
|
424
|
-
import {
|
|
577
|
+
```ts
|
|
578
|
+
import { createErrorBoundary, h } from "@getforma/core";
|
|
425
579
|
|
|
426
|
-
|
|
427
|
-
(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
},
|
|
434
|
-
{ count: 0 },
|
|
580
|
+
createErrorBoundary(
|
|
581
|
+
() => h(UnstableComponent),
|
|
582
|
+
(error, retry) =>
|
|
583
|
+
h("div", null,
|
|
584
|
+
h("p", null, `Something went wrong: ${error.message}`),
|
|
585
|
+
h("button", { onClick: retry }, "Retry"),
|
|
586
|
+
),
|
|
435
587
|
);
|
|
436
|
-
|
|
437
|
-
dispatch({ type: 'INCREMENT' }); // state() === { count: 1 }
|
|
438
588
|
```
|
|
439
589
|
|
|
590
|
+
---
|
|
591
|
+
|
|
440
592
|
## Islands Architecture
|
|
441
593
|
|
|
442
|
-
|
|
594
|
+
Hydrate independent interactive regions of server-rendered HTML. Each island callback receives the root element and parsed props, then returns a component tree. The hydration system walks the tree against existing SSR DOM, attaching handlers and reactive bindings without recreating elements.
|
|
443
595
|
|
|
444
|
-
```
|
|
445
|
-
import { activateIslands, createSignal, h } from
|
|
596
|
+
```ts
|
|
597
|
+
import { activateIslands, createSignal, h } from "@getforma/core";
|
|
446
598
|
|
|
447
599
|
activateIslands({
|
|
448
600
|
Counter: (el, props) => {
|
|
449
601
|
const [count, setCount] = createSignal(props?.initial ?? 0);
|
|
450
602
|
|
|
451
|
-
|
|
452
|
-
// focus management, CSS classes, or reading extra data-* attributes.
|
|
453
|
-
el.classList.add('is-hydrated');
|
|
603
|
+
el.classList.add("is-hydrated");
|
|
454
604
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
h('span', null, () => String(count())),
|
|
459
|
-
h('button', { onClick: () => setCount(c => c + 1) }, '+1'),
|
|
605
|
+
return h("div", null,
|
|
606
|
+
h("span", null, () => String(count())),
|
|
607
|
+
h("button", { onClick: () => setCount((c) => c + 1) }, "+1"),
|
|
460
608
|
);
|
|
461
609
|
},
|
|
462
610
|
});
|
|
@@ -470,135 +618,169 @@ activateIslands({
|
|
|
470
618
|
</div>
|
|
471
619
|
```
|
|
472
620
|
|
|
473
|
-
Each island
|
|
621
|
+
Each island runs in its own `createRoot` scope with error isolation — a broken island never takes down its siblings.
|
|
474
622
|
|
|
475
623
|
### Hydration Triggers
|
|
476
624
|
|
|
477
625
|
Control when an island hydrates via `data-forma-hydrate`:
|
|
478
626
|
|
|
479
627
|
| Trigger | When it hydrates | Use case |
|
|
480
|
-
|
|
481
|
-
| `load` (default) | Immediately on page load | Above-the-fold
|
|
628
|
+
|---|---|---|
|
|
629
|
+
| `load` (default) | Immediately on page load | Above-the-fold content |
|
|
482
630
|
| `visible` | When island enters viewport | Below-the-fold components |
|
|
483
|
-
| `idle` | During browser idle time
|
|
484
|
-
| `interaction` | On first `pointerdown` or `focusin` | Skeleton+skin pattern |
|
|
631
|
+
| `idle` | During browser idle time | Non-critical functionality |
|
|
632
|
+
| `interaction` | On first `pointerdown` or `focusin` | Skeleton + skin pattern |
|
|
485
633
|
|
|
486
634
|
```html
|
|
487
635
|
<div data-forma-island="1" data-forma-component="Comments" data-forma-hydrate="visible">
|
|
488
|
-
<!--
|
|
636
|
+
<!-- JS loads only when scrolled into view -->
|
|
489
637
|
</div>
|
|
490
638
|
```
|
|
491
639
|
|
|
492
640
|
### Island Disposal
|
|
493
641
|
|
|
494
|
-
When swapping
|
|
642
|
+
When swapping content (e.g., inside `<forma-stage>` Shadow DOM), dispose islands to prevent leaked effects:
|
|
495
643
|
|
|
496
|
-
```
|
|
497
|
-
import { deactivateIsland, deactivateAllIslands } from
|
|
644
|
+
```ts
|
|
645
|
+
import { deactivateIsland, deactivateAllIslands } from "@getforma/core";
|
|
498
646
|
|
|
499
|
-
// Dispose all active islands under a root
|
|
500
647
|
deactivateAllIslands(shadowRoot);
|
|
501
|
-
|
|
502
|
-
// Or dispose a single island
|
|
503
648
|
deactivateIsland(islandElement);
|
|
504
649
|
```
|
|
505
650
|
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
## CDN Builds
|
|
654
|
+
|
|
655
|
+
### Script tag (IIFE — auto-initializes)
|
|
656
|
+
|
|
657
|
+
```html
|
|
658
|
+
<!-- jsDelivr (recommended) -->
|
|
659
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@1.0.1/dist/formajs-runtime.global.js"></script>
|
|
660
|
+
|
|
661
|
+
<!-- unpkg -->
|
|
662
|
+
<script src="https://unpkg.com/@getforma/core@1.0.1/dist/formajs-runtime.global.js"></script>
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
### ESM import (modern browsers, no bundler)
|
|
666
|
+
|
|
667
|
+
```html
|
|
668
|
+
<script type="module">
|
|
669
|
+
import { createSignal, h, mount } from "https://cdn.jsdelivr.net/npm/@getforma/core@1.0.1/dist/index.js";
|
|
670
|
+
|
|
671
|
+
const [count, setCount] = createSignal(0);
|
|
672
|
+
mount(() => h("button", { onClick: () => setCount((c) => c + 1) }, () => `${count()}`), "#app");
|
|
673
|
+
</script>
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### All builds
|
|
677
|
+
|
|
678
|
+
| Build | Filename |
|
|
679
|
+
|---|---|
|
|
680
|
+
| Standard (recommended) | `formajs-runtime.global.js` |
|
|
681
|
+
| CSP-safe (no `new Function`) | `formajs-runtime-hardened.global.js` |
|
|
682
|
+
| Standard (short alias) | `forma-runtime.js` |
|
|
683
|
+
| CSP-safe (short alias) | `forma-runtime-csp.js` |
|
|
684
|
+
|
|
685
|
+
Available from `unpkg.com/@getforma/core@VERSION/dist/` and `cdn.jsdelivr.net/npm/@getforma/core@VERSION/dist/`.
|
|
686
|
+
|
|
687
|
+
---
|
|
688
|
+
|
|
506
689
|
## Subpath Exports
|
|
507
690
|
|
|
508
|
-
The main entry point (`@getforma/core`) has **zero network code** — no
|
|
691
|
+
The main entry point (`@getforma/core`) has **zero network code** — no fetch, no WebSocket, no `process.env`. Network-capable modules are separate imports:
|
|
509
692
|
|
|
510
693
|
| Import | Description |
|
|
511
|
-
|
|
512
|
-
| `@getforma/core` | Signals, `h()`,
|
|
694
|
+
|---|---|
|
|
695
|
+
| `@getforma/core` | Signals, `h()`, mount, lists, stores, components, islands, events, DOM utils |
|
|
513
696
|
| `@getforma/core/http` | `createFetch`, `fetchJSON`, `createSSE`, `createWebSocket` |
|
|
514
697
|
| `@getforma/core/storage` | `createLocalStorage`, `createSessionStorage`, `createIndexedDB` |
|
|
515
698
|
| `@getforma/core/server` | `createAction`, `$$serverFunction`, `handleRPC`, `createRPCMiddleware` |
|
|
516
699
|
| `@getforma/core/runtime` | HTML Runtime — `initRuntime()`, `mount()`, `unmount()` |
|
|
517
|
-
| `@getforma/core/runtime/global` | HTML Runtime global build (IIFE, for `<script>` tags) |
|
|
518
700
|
| `@getforma/core/runtime-hardened` | Runtime with `new Function()` locked off (strict CSP) |
|
|
519
|
-
| `@getforma/core/runtime-csp` | Alias for `runtime-hardened` (CSP-safe build) |
|
|
520
|
-
| `@getforma/core/runtime-csp/global` | CSP-safe global build (IIFE, for `<script>` tags) |
|
|
521
701
|
| `@getforma/core/ssr` | Server-side rendering — `renderToString()`, `renderToStream()` |
|
|
522
702
|
| `@getforma/core/tc39` | TC39-compatible `Signal.State` and `Signal.Computed` classes |
|
|
523
703
|
|
|
524
|
-
```
|
|
525
|
-
// Core
|
|
526
|
-
import { createSignal, h, mount, createStore } from
|
|
704
|
+
```ts
|
|
705
|
+
// Core — zero network code
|
|
706
|
+
import { createSignal, h, mount, createStore } from "@getforma/core";
|
|
527
707
|
|
|
528
|
-
// HTTP
|
|
529
|
-
import { createFetch, createSSE } from
|
|
708
|
+
// HTTP — only when needed
|
|
709
|
+
import { createFetch, createSSE } from "@getforma/core/http";
|
|
530
710
|
|
|
531
|
-
// Storage
|
|
532
|
-
import { createLocalStorage } from
|
|
711
|
+
// Storage — only when needed
|
|
712
|
+
import { createLocalStorage } from "@getforma/core/storage";
|
|
533
713
|
|
|
534
|
-
// Server
|
|
535
|
-
import { createAction, $$serverFunction } from
|
|
714
|
+
// Server — only when needed
|
|
715
|
+
import { createAction, $$serverFunction } from "@getforma/core/server";
|
|
536
716
|
```
|
|
537
717
|
|
|
538
|
-
|
|
718
|
+
---
|
|
539
719
|
|
|
540
|
-
|
|
720
|
+
## How Is This Different from Solid?
|
|
541
721
|
|
|
542
|
-
|
|
543
|
-
- **counter-jsx** — same counter with JSX syntax
|
|
544
|
-
- **csp** — CSP-safe runtime with strict Content-Security-Policy meta tag
|
|
545
|
-
- **todo** — todo list with `createList` and keyed reconciliation
|
|
546
|
-
- **data-table** — sortable table with `createList`
|
|
722
|
+
FormaJS shares Solid's core insight — fine-grained signals updating the real DOM without a virtual DOM. If you know Solid, you'll feel at home.
|
|
547
723
|
|
|
548
|
-
|
|
724
|
+
The differences are in scope and delivery: FormaJS adds built-in islands hydration without a meta-framework, CSP compliance without a build step, three entry points (CDN, hyperscript, JSX) sharing one signal graph, and a Rust SSR path that eliminates Node.js from the server. Solid gives you a mature JavaScript ecosystem with routing, a meta-framework (SolidStart), devtools, and community component libraries.
|
|
549
725
|
|
|
550
|
-
FormaJS
|
|
726
|
+
**Choose FormaJS** when you want islands baked in, CSP safety out of the box, a Rust backend without a Node.js sidecar, or a CDN-first starting point that scales to a full compiled pipeline.
|
|
551
727
|
|
|
552
|
-
|
|
553
|
-
|-|-------|---------|
|
|
554
|
-
| **Build requirement** | Always needs a compiler (JSX transform) | CDN runtime works with zero build step; bundler is optional |
|
|
555
|
-
| **Entry points** | JSX-first | HTML Runtime (`data-*` attributes), `h()` hyperscript, or JSX |
|
|
556
|
-
| **CSP** | Relies on compiler output | Hand-written expression parser; hardened build has no `new Function()` |
|
|
557
|
-
| **Islands** | Via [solid-start](https://start.solidjs.com/) meta-framework | Built-in `activateIslands()` — no meta-framework needed |
|
|
558
|
-
| **Ecosystem** | Mature (router, meta-framework, devtools) | Minimal — reactive core only, you bring the architecture |
|
|
559
|
-
| **SSR runtime** | Node.js required | Node.js via `renderToString`, or Rust walker (no JS runtime on the server) |
|
|
560
|
-
| **Size** | ~7KB | ~15KB (includes runtime parser, stores, SSR) |
|
|
728
|
+
**Choose Solid** when you want a mature JS ecosystem, SolidStart for full-stack JS, community devtools, and your backend is already Node.js.
|
|
561
729
|
|
|
562
|
-
|
|
730
|
+
> FormaJS is the reactive layer of the [Forma stack](https://getforma.dev). The full pipeline compiles components to FMIR binary, renders them in Rust via `forma-ir`, and serves pages through `forma-server` — SSR without Node.js, binary IR over the wire, deployed for ~$18/month.
|
|
563
731
|
|
|
564
|
-
|
|
732
|
+
---
|
|
565
733
|
|
|
566
|
-
|
|
734
|
+
## Examples
|
|
567
735
|
|
|
568
|
-
|
|
736
|
+
See the [`examples/`](./examples) directory:
|
|
737
|
+
|
|
738
|
+
| Example | Description |
|
|
739
|
+
|---|---|
|
|
740
|
+
| **counter** | Minimal `h()` counter |
|
|
741
|
+
| **counter-jsx** | Same counter with JSX syntax |
|
|
742
|
+
| **csp** | CSP-safe runtime with strict `Content-Security-Policy` |
|
|
743
|
+
| **todo** | Todo list with `createList` and keyed reconciliation |
|
|
744
|
+
| **data-table** | Sortable table with `createList` |
|
|
745
|
+
|
|
746
|
+
---
|
|
569
747
|
|
|
570
|
-
|
|
748
|
+
## Stability
|
|
571
749
|
|
|
572
750
|
| Feature | Status | Notes |
|
|
573
|
-
|
|
574
|
-
| Signals (`createSignal`, `createEffect`, `createComputed`, `batch`) | **Stable** | Core primitive
|
|
751
|
+
|---|---|---|
|
|
752
|
+
| Signals (`createSignal`, `createEffect`, `createComputed`, `batch`) | **Stable** | Core primitive. Custom `equals` supported. |
|
|
575
753
|
| Reactive introspection (`isSignal`, `isComputed`, `trigger`, `getBatchDepth`) | **Stable** | alien-signals 3.x type guards |
|
|
576
|
-
| `h()` / JSX rendering | **Stable** | |
|
|
754
|
+
| `h()` / JSX rendering | **Stable** | Function components supported |
|
|
577
755
|
| `mount()`, `createShow`, `createSwitch`, `createList` | **Stable** | |
|
|
578
|
-
| HTML Runtime (`data-*` directives) | **Stable** |
|
|
579
|
-
| CSP-hardened runtime | **Stable** |
|
|
756
|
+
| HTML Runtime (`data-*` directives) | **Stable** | CSP-safe expression parser |
|
|
757
|
+
| CSP-hardened runtime | **Stable** | Zero `new Function()` in dist |
|
|
580
758
|
| `createStore` (deep reactivity) | **Stable** | |
|
|
581
759
|
| Components (`defineComponent`, lifecycle) | **Stable** | |
|
|
582
760
|
| Context (`createContext`, `provide`, `inject`) | **Stable** | |
|
|
583
761
|
| Islands (`activateIslands`, disposal, triggers) | **Stable** | 10 activation + 88 hydration + 10 trigger tests |
|
|
584
762
|
| `createHistory` (undo/redo) | **Stable** | |
|
|
585
|
-
| `createReducer` | **Stable** |
|
|
586
|
-
| `data-fetch`, `data-transition
|
|
763
|
+
| `createReducer` | **Stable** | |
|
|
764
|
+
| `data-fetch`, `data-transition:*`, `data-ref` | **Stable** | |
|
|
587
765
|
| SSR (`renderToString`, `renderToStream`) | **Beta** | Functional, API may evolve |
|
|
588
|
-
| TC39 Signals compat (`Signal.State`, `Signal.Computed`) | **Beta** |
|
|
766
|
+
| TC39 Signals compat (`Signal.State`, `Signal.Computed`) | **Beta** | Tracks an evolving TC39 proposal |
|
|
767
|
+
|
|
768
|
+
---
|
|
589
769
|
|
|
590
770
|
## Ecosystem
|
|
591
771
|
|
|
592
|
-
FormaJS is the reactive frontend layer of a full-stack Rust + TypeScript framework.
|
|
772
|
+
FormaJS is the reactive frontend layer of a full-stack Rust + TypeScript framework.
|
|
593
773
|
|
|
594
774
|
| Package | Language | Description |
|
|
595
|
-
|
|
775
|
+
|---|---|---|
|
|
596
776
|
| [@getforma/core](https://www.npmjs.com/package/@getforma/core) | TypeScript | This library — reactive DOM, signals, islands, SSR hydration |
|
|
597
777
|
| [@getforma/compiler](https://github.com/getforma-dev/forma-tools) | TypeScript | TypeScript-to-FMIR compiler, Vite plugin, esbuild SSR plugin |
|
|
598
778
|
| [@getforma/build](https://github.com/getforma-dev/forma-tools) | TypeScript | esbuild pipeline with content hashing, compression, manifest |
|
|
599
779
|
| [@getforma/create-app](https://github.com/getforma-dev/create-forma-app) | TypeScript | `npx @getforma/create-app` — scaffold a new Forma project |
|
|
600
780
|
| [forma-ir](https://crates.io/crates/forma-ir) | Rust | FMIR binary format: parser, walker, WASM exports |
|
|
601
|
-
| [forma-server](https://crates.io/crates/forma-server) | Rust | Axum middleware for SSR
|
|
781
|
+
| [forma-server](https://crates.io/crates/forma-server) | Rust | Axum middleware for SSR, asset serving, CSP |
|
|
782
|
+
|
|
783
|
+
---
|
|
602
784
|
|
|
603
785
|
## License
|
|
604
786
|
|