@nativelayer.dev/restate 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.
Potentially problematic release.
This version of @nativelayer.dev/restate might be problematic. Click here for more details.
- package/.github/workflows/release.yml +20 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +132 -0
- package/README.md +1349 -0
- package/dist/plugin/async.cjs.min.js +8 -0
- package/dist/plugin/async.esm.min.js +8 -0
- package/dist/plugin/history.cjs.min.js +8 -0
- package/dist/plugin/history.esm.min.js +8 -0
- package/dist/plugin/immutable.cjs.min.js +8 -0
- package/dist/plugin/immutable.esm.min.js +8 -0
- package/dist/plugin/persistence.cjs.min.js +8 -0
- package/dist/plugin/persistence.esm.min.js +8 -0
- package/dist/plugin/validate.cjs.min.js +8 -0
- package/dist/plugin/validate.esm.min.js +8 -0
- package/dist/restate.cjs.min.js +2 -0
- package/dist/restate.esm.min.js +2 -0
- package/package.json +49 -0
- package/restate-logo.jpg +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@nativelayer.dev/restate)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
# restate
|
|
8
|
+
|
|
9
|
+
version `0.2.0`
|
|
10
|
+
|
|
11
|
+
A minimal, framework-agnostic reactive state management library built on ES6 proxies. `restate` provides:
|
|
12
|
+
|
|
13
|
+
- A tiny footprint (~2.8 KB gzipped), zero dependencies.
|
|
14
|
+
- A simple core API: `$onChange`, `$watch`, `$set`, `$destroy`, plus built-in `$computed` and `$methods`.
|
|
15
|
+
- Chainable plugin system to add persistence, history, immutability, async handling, and validation.
|
|
16
|
+
- Both CommonJS and ES module builds for universal compatibility.
|
|
17
|
+
- No boilerplate: read and write state directly on a proxy.
|
|
18
|
+
|
|
19
|
+
**Bundle size (minified + gzipped): ~2.8 KB** — includes built-in `$computed()` and `$methods()`.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Import directly in a browser or module bundler:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
// ESM (recommended)
|
|
28
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
29
|
+
|
|
30
|
+
// CommonJS (Node.js)
|
|
31
|
+
const { restate } = require('./dist/cjs/restate.min.js');
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Which format should I use?
|
|
35
|
+
|
|
36
|
+
| Your setup | Use this |
|
|
37
|
+
|------------|----------|
|
|
38
|
+
| Vite, esbuild, modern bundler | `dist/esm/` (recommended) |
|
|
39
|
+
| Node.js with `require()` | `dist/cjs/` |
|
|
40
|
+
| Node.js with `"type": "module"` | `dist/esm/` |
|
|
41
|
+
|
|
42
|
+
## Basic Usage
|
|
43
|
+
|
|
44
|
+
`restate` was designed to have a fast learning curve and to provide pleasant developer experience.
|
|
45
|
+
Basic operations can be done with a straight-forward syntax:
|
|
46
|
+
|
|
47
|
+
### Create a Reactive State
|
|
48
|
+
|
|
49
|
+
Initialize with a plain object:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const state = restate({
|
|
53
|
+
count: 0,
|
|
54
|
+
items: []
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Access and Mutation
|
|
59
|
+
|
|
60
|
+
Read and write properties directly on the reactive proxy:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
console.log(state.count); // 0
|
|
64
|
+
// Straight forward syntax for mutations
|
|
65
|
+
state.count = 5; // mutates 'count'
|
|
66
|
+
// and triggers change notifications
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Core Reactivity API
|
|
70
|
+
|
|
71
|
+
### $onChange(fn)
|
|
72
|
+
|
|
73
|
+
Subscribe to *all* key/value changes. The callback receives `(key, newValue, oldValue)`:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
state.$onChange((key, newValue, oldValue) => {
|
|
77
|
+
console.log(`${key} changed from`, oldValue, 'to', newValue);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// You can also omit oldValue if you’re only interested in the new value:
|
|
81
|
+
state.$onChange((key, newValue) => {
|
|
82
|
+
console.log(`${key} changed to`, newValue);
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### $watch(path, handler)
|
|
87
|
+
|
|
88
|
+
Listen to specific key or wildcard patterns (`\*` or `\**`). The handler receives `(key, newValue, oldValue)`:
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
// Exact key
|
|
92
|
+
state.$watch('count', (key, newValue, oldValue) => console.log(`count changed from ${oldValue} to ${newValue}`));
|
|
93
|
+
// You can omit oldValue if desired:
|
|
94
|
+
state.$watch('count', (key, newValue) => console.log(`count is now ${newValue}`));
|
|
95
|
+
|
|
96
|
+
// Single-level wildcard: watches direct children of 'user'
|
|
97
|
+
state.$watch('user.*', (key, newValue, oldValue) => console.log(key, newValue, oldValue));
|
|
98
|
+
|
|
99
|
+
// Deep wildcard: watches all nested descendants of 'config'
|
|
100
|
+
state.$watch('config.**', (key, newValue, oldValue) => console.log(key, newValue, oldValue));
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Returns an *unwatch* function to cancel:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
const unwatch = state.$watch('count', handler);
|
|
107
|
+
unwatch();
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### $set(updatesOrFn)
|
|
111
|
+
|
|
112
|
+
Batch multiple updates into one operation :
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
// Object form
|
|
116
|
+
state.$set({ count: 10, foo: 'bar' });
|
|
117
|
+
|
|
118
|
+
// Function form
|
|
119
|
+
state.$set(s => {
|
|
120
|
+
s.count++;
|
|
121
|
+
s.items.push('x');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Nested object update
|
|
125
|
+
state.$set({
|
|
126
|
+
user: { name: 'Alice', roles: ['admin', 'editor'] },
|
|
127
|
+
loggedIn: true
|
|
128
|
+
});
|
|
129
|
+
console.log(state.user); // { name: 'Alice', roles: ['admin','editor'] }
|
|
130
|
+
console.log(state.loggedIn); // true
|
|
131
|
+
|
|
132
|
+
// Complex updates with function form
|
|
133
|
+
state.$set(s => {
|
|
134
|
+
// toggle a boolean flag
|
|
135
|
+
s.loggedIn = !s.loggedIn;
|
|
136
|
+
// increment count by 5 only if logged in
|
|
137
|
+
if (s.loggedIn) {
|
|
138
|
+
s.count += 5;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
console.log(state.count, state.loggedIn);
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### $methods(obj)
|
|
145
|
+
|
|
146
|
+
Define custom methods on the state proxy. Methods are **non-enumerable** and have `this` bound to the proxy:
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
state.$methods({
|
|
150
|
+
increment() { this.count++; },
|
|
151
|
+
reset() { this.count = 0; }
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
state.increment(); // count = 1
|
|
155
|
+
state.reset(); // count = 0
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
> Why use `$methods()` instead of direct assignment?
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
// ❌ Direct assignment - problematic
|
|
162
|
+
state.getUserById = function(id) { return this.users.find(u => u.id === id); };
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| Issue | Direct Assignment | `$methods()` |
|
|
166
|
+
|-------|-------------------|--------------|
|
|
167
|
+
| **Triggers watchers** | ✅ Yes (unwanted) | ❌ No |
|
|
168
|
+
| **Enumerable** | ✅ Yes (pollutes `Object.keys()`) | ❌ No |
|
|
169
|
+
| **`this` binding** | ⚠️ Can be lost | ✅ Always bound |
|
|
170
|
+
| **Shows in `JSON.stringify`** | ⚠️ Attempted | ❌ No |
|
|
171
|
+
|
|
172
|
+
**Use `$methods()` to cleanly separate behavior from data.**
|
|
173
|
+
|
|
174
|
+
Usage:
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
// $methods() is available without any plugin
|
|
178
|
+
state.$methods({
|
|
179
|
+
increment(n) { this.count += n; },
|
|
180
|
+
reset() { this.count = 0; }
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Custom methods are added to the reactive proxy (non-enumerable) and can be called directly:
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
state.increment(5);
|
|
188
|
+
state.reset();
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### $destroy()
|
|
192
|
+
|
|
193
|
+
Disable further updates and clear watchers:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
state.$destroy();
|
|
197
|
+
state.count = 7; // Throws or no-op
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### $computed(obj)
|
|
201
|
+
|
|
202
|
+
Define reactive computed properties with automatic dependency tracking:
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
206
|
+
|
|
207
|
+
// Initialize state - $computed is built-in, no plugin needed
|
|
208
|
+
const state = restate({ a: 1, b: 2 });
|
|
209
|
+
|
|
210
|
+
// Define computed properties
|
|
211
|
+
state.$computed({
|
|
212
|
+
sum: s => {
|
|
213
|
+
console.log('computing sum');
|
|
214
|
+
return s.a + s.b;
|
|
215
|
+
},
|
|
216
|
+
double: s => s.sum * 2
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// First access triggers computation
|
|
220
|
+
console.log(state.sum); // logs 'computing sum', outputs 3
|
|
221
|
+
console.log(state.double); // outputs 6 (sum is cached)
|
|
222
|
+
|
|
223
|
+
// Second access uses cache
|
|
224
|
+
console.log(state.sum); // no log, outputs 3
|
|
225
|
+
|
|
226
|
+
// Changing a base value invalidates cache
|
|
227
|
+
state.a = 5; // invalidates sum
|
|
228
|
+
console.log(state.sum); // logs 'computing sum', outputs 7
|
|
229
|
+
console.log(state.double); // logs no 'computing sum' but recomputes double: outputs 14
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Chaining computed values:
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
236
|
+
|
|
237
|
+
// No plugin needed - $computed is built-in
|
|
238
|
+
const state = restate({ a: 2, b: 3 });
|
|
239
|
+
|
|
240
|
+
state.$computed({
|
|
241
|
+
sum: s => s.a + s.b,
|
|
242
|
+
product: s => s.a * s.b,
|
|
243
|
+
doubledSum: s => s.sum * 2,
|
|
244
|
+
sumPlusProd: s => s.sum + s.product
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
console.log(state.sum); // → 5
|
|
248
|
+
console.log(state.product); // → 6
|
|
249
|
+
console.log(state.doubledSum); // → 10
|
|
250
|
+
console.log(state.sumPlusProd); // → 11
|
|
251
|
+
|
|
252
|
+
state.a = 4; // invalidates `sum` & `product`
|
|
253
|
+
console.log(state.doubledSum); // → 14
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Computed over nested objects:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
const userState = restate({ user: { first: 'Jane', last: 'Doe' } });
|
|
260
|
+
|
|
261
|
+
userState.$computed({
|
|
262
|
+
fullName: s => `${s.user.first} ${s.user.last}`,
|
|
263
|
+
initials: s => `${s.user.first[0]}.${s.user.last[0]}.`
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
console.log(userState.fullName); // → "Jane Doe"
|
|
267
|
+
console.log(userState.initials); // → "J.D."
|
|
268
|
+
|
|
269
|
+
userState.user.last = 'Smith';
|
|
270
|
+
console.log(userState.fullName); // → "Jane Smith"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Reactive side-effects with `$onChange` + computed
|
|
274
|
+
|
|
275
|
+
```js
|
|
276
|
+
const counter = restate({ count: 0 });
|
|
277
|
+
|
|
278
|
+
counter.$computed({
|
|
279
|
+
parity: s => s.count % 2 === 0 ? 'even' : 'odd'
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
counter.$onChange((key, newVal) => {
|
|
283
|
+
if (key === 'count') {
|
|
284
|
+
console.log(`Count is ${newVal}, parity: ${counter.parity}`);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
counter.count = 1; // logs "Count is 1, parity: odd"
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Dynamically adding more computed props:
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
state.$computed({
|
|
295
|
+
triple: s => s.a * 3
|
|
296
|
+
});
|
|
297
|
+
console.log(state.triple); // → 12 (if a === 4)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### $dependencies()
|
|
301
|
+
|
|
302
|
+
Inspect dependency graph to know which properties each computed depends on (useful for debugging):
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
console.log(state.$dependencies());
|
|
306
|
+
// → {
|
|
307
|
+
// sum: ['a','b'],
|
|
308
|
+
// product: ['a','b'],
|
|
309
|
+
// doubledSum: ['sum'],
|
|
310
|
+
// sumPlusProd: ['sum','product']
|
|
311
|
+
// }
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Dependencies are automatically tracked; when a base property changes, computed caches are invalidated and recomputed on access.
|
|
315
|
+
|
|
316
|
+
### All methods table
|
|
317
|
+
|
|
318
|
+
Below are the core methods available on every `restate` instance:
|
|
319
|
+
|
|
320
|
+
| Method | Parameters | Returns | Description |
|
|
321
|
+
|---------------------------------|-------------------------------------------------|----------------|-------------------------------------------------------------|
|
|
322
|
+
| `use(pluginFactory, options)` | `pluginFactory` (function or plugin), `options?` (object) | reactive proxy | Register a plugin after initialization |
|
|
323
|
+
| `$onChange(fn)` | `fn(key, newValue, oldValue)` (function) | reactive proxy | Subscribe to all value changes with previous value |
|
|
324
|
+
| `$watch(path, handler)` | `path` (string), `handler` (function) | `() => void` | Watch a specific path or wildcard (`\*`/`\**`) |
|
|
325
|
+
| `$watch(path, handler)` | `path` (string), `handler(key, newValue, oldValue)` (function) | `() => void` | Watch changes on a path or wildcard with previous value |
|
|
326
|
+
| `$unwatch(path)` | `path` (string) | reactive proxy | Remove the watcher for the given path |
|
|
327
|
+
| `$set(updatesOrFn)` | `updatesOrFn` (object or function) | reactive proxy | Apply atomic updates; accepts an object or updater function |
|
|
328
|
+
| `$computed(obj)` | `obj` (object of getter functions) | reactive proxy | Define computed properties with auto dependency tracking |
|
|
329
|
+
| `$dependencies()` | none | object | Get dependency graph for all computed properties |
|
|
330
|
+
| `$methods(obj)` | `obj` (object of functions) | reactive proxy | Define custom methods (non-enumerable, bound to proxy) |
|
|
331
|
+
| `$destroy()` | none | reactive proxy | Destroy the reactive proxy: clear watchers and disable updates |
|
|
332
|
+
|
|
333
|
+
All native methods combined:
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
// Example: core methods
|
|
337
|
+
const state = restate({ a: 1, b: 2, count: 0 });
|
|
338
|
+
|
|
339
|
+
// Change tracking
|
|
340
|
+
state.$onChange((k,v) => console.log(k, v));
|
|
341
|
+
const unwatch = state.$watch('a', (k,v) => console.log('watched', k, v));
|
|
342
|
+
|
|
343
|
+
// Computed properties (built-in)
|
|
344
|
+
state.$computed({
|
|
345
|
+
sum: s => s.a + s.b
|
|
346
|
+
});
|
|
347
|
+
console.log(state.sum); // 3
|
|
348
|
+
|
|
349
|
+
// Custom methods (built-in)
|
|
350
|
+
state.$methods({
|
|
351
|
+
increment() { this.count++; }
|
|
352
|
+
});
|
|
353
|
+
state.increment(); // count = 1
|
|
354
|
+
|
|
355
|
+
// Batch updates
|
|
356
|
+
state.$set({ a: 2 });
|
|
357
|
+
|
|
358
|
+
// Cleanup
|
|
359
|
+
unwatch();
|
|
360
|
+
state.$destroy();
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Plugin System
|
|
364
|
+
|
|
365
|
+
`retate` is designed with a plugin architecture to keep the core library minimal and focused, while providing extensibility for advanced use cases. Rather than bundling every possible feature into the main library (which would bloat the bundle size), restate exposes a plugin API that allows you to add only the functionality you need.
|
|
366
|
+
|
|
367
|
+
This design philosophy ensures that:
|
|
368
|
+
|
|
369
|
+
- **The core stays lightweight** (~2.8 KB) with zero dependencies
|
|
370
|
+
- **You only pay for what you use** — plugins are optional and can be loaded on demand
|
|
371
|
+
- **Custom behavior is possible** — you can build plugins tailored to your specific needs
|
|
372
|
+
- **The API remains simple** — core methods stay consistent regardless of which plugins are active
|
|
373
|
+
|
|
374
|
+
Plugins hook into `restate`'s lifecycle events (like `beforeSet`, `afterSet`, `beforeNotify`), enabling you to hook into state changes, add methods and integrate custom behaviors that allow to implement features like persistence, immutability, validation, history tracking, and more.
|
|
375
|
+
|
|
376
|
+
Chain optional plugins for additional features:
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
380
|
+
import { PersistencePlugin } from './dist/esm/plugins/persistence.min.js';
|
|
381
|
+
import { ImmutablePlugin } from './dist/esm/plugins/immutable.min.js';
|
|
382
|
+
|
|
383
|
+
// Chainable registration:
|
|
384
|
+
const state = restate({ count: 0, items: [] })
|
|
385
|
+
.use(PersistencePlugin, { key: 'appState', debounce: 300 })
|
|
386
|
+
.use(ImmutablePlugin, { strict: false });
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Plugin Factory
|
|
390
|
+
|
|
391
|
+
To use a plugin call `state.use(pluginFactory, options)`. Under the hood it runs the `pluginFactory(option)` function:
|
|
392
|
+
|
|
393
|
+
```js
|
|
394
|
+
const plugin = pluginFactory(options);
|
|
395
|
+
core.use(plugin);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Plugin Object Shape
|
|
399
|
+
|
|
400
|
+
- `name` (string): Unique plugin identifier used in directives or logs.
|
|
401
|
+
- `hooks?` (object): Lifecycle hooks you can implement:
|
|
402
|
+
- `beforeSet(target, prop, value, path)`
|
|
403
|
+
- `afterSet(target, prop, value, path)`
|
|
404
|
+
- `beforeNotify(key, value)`
|
|
405
|
+
- `afterNotify(key, value)`
|
|
406
|
+
- `beforeBulk()`
|
|
407
|
+
- `afterBulk()`
|
|
408
|
+
- `onDestroy()`
|
|
409
|
+
- `methods?` (object): Custom methods to add to the reactive proxy:
|
|
410
|
+
|
|
411
|
+
```js
|
|
412
|
+
methods: {
|
|
413
|
+
customAction() { /* this === reactive proxy */ }
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- `install?` (function): Runs once when the plugin is registered.
|
|
418
|
+
|
|
419
|
+
### Example: Logging Plugin
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
function LogPlugin({ prefix = '' } = {}) {
|
|
423
|
+
return {
|
|
424
|
+
name: 'log',
|
|
425
|
+
hooks: {
|
|
426
|
+
afterNotify(key, value) {
|
|
427
|
+
console.log(`${prefix}${key} =>`, value);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const state = restate({ count: 0 })
|
|
434
|
+
.use(LogPlugin, { prefix: '[LOG] ' });
|
|
435
|
+
state.count = 1; // logs "[LOG] count => 1"
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Example: Reset Plugin
|
|
439
|
+
|
|
440
|
+
```js
|
|
441
|
+
function ResetPlugin() {
|
|
442
|
+
return {
|
|
443
|
+
name: 'reset',
|
|
444
|
+
methods: {
|
|
445
|
+
reset() { this.count = 0; }
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const state2 = restate({ count: 42 })
|
|
451
|
+
.use(ResetPlugin);
|
|
452
|
+
state2.reset();
|
|
453
|
+
console.log(state.count) // count is now 0
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
You can freely mix built-in and custom plugins:
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
const state3 = restate({ count: 5 })
|
|
460
|
+
.use(PersistencePlugin, { key: 'c3' })
|
|
461
|
+
.use(LogPlugin)
|
|
462
|
+
.use(ResetPlugin);
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Built-in Plugins
|
|
468
|
+
|
|
469
|
+
> **Note:** Built-in Plugins are audited and approuved by NativeWebDev team before being published. Always check and audit the code of Plugins you use with `restate` if they are not built-in plugins.
|
|
470
|
+
|
|
471
|
+
### PersistencePlugin
|
|
472
|
+
|
|
473
|
+
Auto-saves and restores state to storage.
|
|
474
|
+
|
|
475
|
+
Usage:
|
|
476
|
+
|
|
477
|
+
```js
|
|
478
|
+
state.use(PersistencePlugin, {
|
|
479
|
+
persist: true, // enable auto-persist (default)
|
|
480
|
+
persistKey: 'appState', // storage key (default 'restate')
|
|
481
|
+
persistStorage: 'localStorage', // or 'sessionStorage' or custom adapter
|
|
482
|
+
persistDebounce: 300, // ms debounce between saves
|
|
483
|
+
persistInclude: null, // array of paths to include
|
|
484
|
+
persistExclude: null, // array of paths to exclude
|
|
485
|
+
persistEncrypt: false, // password string for AES-256-GCM encryption
|
|
486
|
+
persistIntegrity: false, // enable HMAC-SHA256 integrity checking
|
|
487
|
+
persistVersion: 1, // version for migrations
|
|
488
|
+
persistMigrations: {}, // object mapping version→migrationFn
|
|
489
|
+
persistValidateSchema: null // (state) => boolean, validates before migrations
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
| Option | Type | Default | Description |
|
|
494
|
+
|----------------------|-----------------------|---------------|-------------|
|
|
495
|
+
| persist | boolean | true | Enable auto-persist |
|
|
496
|
+
| persistKey | string | 'restate' | Storage key name |
|
|
497
|
+
| persistStorage | string \| object | 'localStorage' | Storage adapter |
|
|
498
|
+
| persistDebounce | number | 300 | ms between saves |
|
|
499
|
+
| persistInclude | array \| null | null | Paths to include |
|
|
500
|
+
| persistExclude | array \| null | null | Paths to exclude |
|
|
501
|
+
| persistEncrypt | boolean \| string | false | Password for AES-256-GCM |
|
|
502
|
+
| persistIntegrity | boolean \| string | false | Enable HMAC verification |
|
|
503
|
+
| persistVersion | number | 1 | Schema version |
|
|
504
|
+
| persistMigrations | object | {} | Version→migration map |
|
|
505
|
+
| persistValidateSchema| function \| null | null | Schema validator |
|
|
506
|
+
|
|
507
|
+
Hooks:
|
|
508
|
+
|
|
509
|
+
- `afterNotify` → triggers debounced save
|
|
510
|
+
- `onDestroy` → clears timers and pending saves
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
### ImmutablePlugin
|
|
515
|
+
|
|
516
|
+
Enforces immutability on direct property sets (optional bulk updates).
|
|
517
|
+
|
|
518
|
+
Usage:
|
|
519
|
+
|
|
520
|
+
```js
|
|
521
|
+
state.use(ImmutablePlugin, {
|
|
522
|
+
strict: true // throw on direct sets (default)
|
|
523
|
+
});
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
| Plugin Option | Type | Default |
|
|
527
|
+
|--------------------|-------------------|-----------|
|
|
528
|
+
| strict | boolean | true |
|
|
529
|
+
| customErrorMessage | function \| null | null |
|
|
530
|
+
|
|
531
|
+
#### Non-Strict Mode
|
|
532
|
+
|
|
533
|
+
By setting `strict: false`, direct property mutations are allowed without throwing:
|
|
534
|
+
|
|
535
|
+
```js
|
|
536
|
+
import { ImmutablePlugin } from './dist/esm/plugins/immutable.min.js';
|
|
537
|
+
const state = restate({ count: 0, user: { name: 'Alice' } })
|
|
538
|
+
.use(ImmutablePlugin, { strict: false });
|
|
539
|
+
|
|
540
|
+
state.count = 1; // works without error
|
|
541
|
+
state.user.name = 'Bob'; // works without error
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
You can also toggle strict mode at runtime using the plugin methods:
|
|
545
|
+
|
|
546
|
+
```js
|
|
547
|
+
state.$enableImmutable(); // turn strict mode on
|
|
548
|
+
state.count = 2; // throws error
|
|
549
|
+
|
|
550
|
+
state.$disableImmutable(); // turn strict mode off
|
|
551
|
+
state.count = 3; // works without error
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
| ImmutablePlugin Method | Type | Default |
|
|
555
|
+
|--------------------|-------------------|-----------|
|
|
556
|
+
| $enableImmutable | boolean | true |
|
|
557
|
+
| $disableImmutable$ | function \| null | null |
|
|
558
|
+
|
|
559
|
+
#### Strict Mode
|
|
560
|
+
|
|
561
|
+
With `strict: true`, direct property mutations throw errors, but you can still apply updates using `$set`:
|
|
562
|
+
|
|
563
|
+
```js
|
|
564
|
+
import { ImmutablePlugin } from './dist/esm/plugins/immutable.min.js';
|
|
565
|
+
const state = restate({ count: 0 })
|
|
566
|
+
.use(ImmutablePlugin, { strict: true });
|
|
567
|
+
|
|
568
|
+
// Direct set throws
|
|
569
|
+
try { state.count = 1; } catch (e) { console.error(e.message); }
|
|
570
|
+
|
|
571
|
+
// update with $set works
|
|
572
|
+
state.$set({ count: 1 });
|
|
573
|
+
console.log(state.count); // 1
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
#### Interoperability with `$methods()`
|
|
577
|
+
|
|
578
|
+
You can register custom mutation methods under strict immutability by using `$set` within your methods:
|
|
579
|
+
|
|
580
|
+
```js
|
|
581
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
582
|
+
import { ImmutablePlugin } from './dist/esm/plugins/immutable.min.js';
|
|
583
|
+
|
|
584
|
+
const state = restate({ count: 0 })
|
|
585
|
+
.use(ImmutablePlugin, { strict: true });
|
|
586
|
+
|
|
587
|
+
// $methods is built-in - define methods that use $set internally
|
|
588
|
+
state.$methods({
|
|
589
|
+
inc() {
|
|
590
|
+
this.$set({ count: this.count + 1 });
|
|
591
|
+
},
|
|
592
|
+
reset() {
|
|
593
|
+
this.$set({ count: 0 });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
state.inc(); // count = 1
|
|
598
|
+
state.reset(); // count = 0
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
### HistoryPlugin
|
|
604
|
+
|
|
605
|
+
Tracks state changes and provides undo/redo functionality.
|
|
606
|
+
|
|
607
|
+
Usage:
|
|
608
|
+
|
|
609
|
+
```js
|
|
610
|
+
state.use(HistoryPlugin, {
|
|
611
|
+
trackHistory: true, // default
|
|
612
|
+
maxHistory: 50 // max snapshots to keep
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
| Option | Type | Default |
|
|
617
|
+
|--------------|---------|---------|
|
|
618
|
+
| trackHistory | boolean | true |
|
|
619
|
+
| maxHistory | number | 50 |
|
|
620
|
+
|
|
621
|
+
Methods of HistoryPlugin:
|
|
622
|
+
|
|
623
|
+
| Method | Parameters | Returns | Description |
|
|
624
|
+
|------------------|------------|-------------|-------------------------------------|
|
|
625
|
+
| `$undo()` | None | state proxy | Revert to previous snapshot |
|
|
626
|
+
| `$redo()` | None | state proxy | Advance to next snapshot |
|
|
627
|
+
| `$canUndo()` | None | boolean | Check availability of undo |
|
|
628
|
+
| `$canRedo()` | None | boolean | Check availability of redo |
|
|
629
|
+
| `$getHistory()` | None | object | Get history metadata and indices |
|
|
630
|
+
| `$clearHistory()`| None | state proxy | Clear history stack |
|
|
631
|
+
|
|
632
|
+
Hooks:
|
|
633
|
+
|
|
634
|
+
- `beforeSet`, `beforeBulk` → record history before mutation
|
|
635
|
+
- `onDestroy` → clear history
|
|
636
|
+
|
|
637
|
+
```js
|
|
638
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
639
|
+
import { HistoryPlugin } from './dist/esm/plugins/history.min.js';
|
|
640
|
+
|
|
641
|
+
// Initialize state with history tracking
|
|
642
|
+
const state = restate({ count: 0 })
|
|
643
|
+
.use(HistoryPlugin, { trackHistory: true, maxHistory: 10 });
|
|
644
|
+
|
|
645
|
+
// Make some changes
|
|
646
|
+
state.count = 1;
|
|
647
|
+
state.count = 2;
|
|
648
|
+
state.count = 3;
|
|
649
|
+
|
|
650
|
+
// Inspect history
|
|
651
|
+
console.log(state.$getHistory());
|
|
652
|
+
// { history: [...], currentIndex: 2, canUndo: true, canRedo: false }
|
|
653
|
+
|
|
654
|
+
// Undo and redo
|
|
655
|
+
state.$undo();
|
|
656
|
+
console.log(state.count); // 2
|
|
657
|
+
state.$undo();
|
|
658
|
+
console.log(state.count); // 1
|
|
659
|
+
state.$redo();
|
|
660
|
+
console.log(state.count); // 2
|
|
661
|
+
|
|
662
|
+
// Check availability
|
|
663
|
+
console.log(state.$canUndo()); // true
|
|
664
|
+
console.log(state.$canRedo()); // true
|
|
665
|
+
|
|
666
|
+
// Clear history if needed
|
|
667
|
+
state.$clearHistory();
|
|
668
|
+
console.log(state.$canUndo()); // false
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
```js
|
|
672
|
+
// Undo all changes
|
|
673
|
+
while(state.$canUndo()) { state.$undo(); }
|
|
674
|
+
console.log(state.count); // back to initial value
|
|
675
|
+
|
|
676
|
+
// Redo all changes
|
|
677
|
+
while(state.$canRedo()) { state.$redo(); }
|
|
678
|
+
console.log(state.count); // back to latest value
|
|
679
|
+
|
|
680
|
+
// Branching: new changes clear redo stack
|
|
681
|
+
state.count = 5;
|
|
682
|
+
state.$undo();
|
|
683
|
+
state.count = 20; // new branch
|
|
684
|
+
console.log(state.$canRedo()); // false
|
|
685
|
+
|
|
686
|
+
// Peek history details without mutating
|
|
687
|
+
const { history, currentIndex } = state.$getHistory();
|
|
688
|
+
console.log(`Step ${currentIndex+1} of ${history.length}`, history);
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
### AsyncPlugin
|
|
694
|
+
|
|
695
|
+
Provides async operations, loading/error state, caching, and retry logic.
|
|
696
|
+
|
|
697
|
+
Usage:
|
|
698
|
+
|
|
699
|
+
```js
|
|
700
|
+
// Basic usage (with safe defaults)
|
|
701
|
+
state.use(AsyncPlugin());
|
|
702
|
+
|
|
703
|
+
// With custom limits
|
|
704
|
+
state.use(AsyncPlugin({
|
|
705
|
+
maxRetries: 5, // Cap on retries (default: 10)
|
|
706
|
+
defaultTimeout: 10000, // 10s timeout (default: 30s)
|
|
707
|
+
maxCacheSize: 50, // Max cache entries (default: 100)
|
|
708
|
+
exponentialBackoff: true // Exponential backoff for retries (default: true)
|
|
709
|
+
}));
|
|
710
|
+
|
|
711
|
+
// Per-request options
|
|
712
|
+
await state.$async('data', fetchData, {
|
|
713
|
+
timeout: 5000, // 5s for this request (overrides default)
|
|
714
|
+
retries: 3, // Retries for this request (capped by maxRetries)
|
|
715
|
+
cache: true,
|
|
716
|
+
cacheTime: 60000
|
|
717
|
+
});
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
Methods added to store:
|
|
721
|
+
|
|
722
|
+
- `$async(key, asyncFn, options)` → manage an async task
|
|
723
|
+
- `$clearCache(key?)` → clear cached async results
|
|
724
|
+
- `$isLoading(key)` → boolean loading state
|
|
725
|
+
|
|
726
|
+
`$async` options:
|
|
727
|
+
|
|
728
|
+
- `timeout` (ms) — request timeout (default: 30s)
|
|
729
|
+
- `cache` (bool) & `cacheTime` (ms)
|
|
730
|
+
- `dedupe` (bool) for concurrent calls
|
|
731
|
+
- `retries`, `retryDelay` — retry attempts (capped by plugin's `maxRetries`)
|
|
732
|
+
- `optimistic` initial value
|
|
733
|
+
- `loadingKey`, `errorKey`, `dataKey` strings
|
|
734
|
+
|
|
735
|
+
Example:
|
|
736
|
+
|
|
737
|
+
```js
|
|
738
|
+
await state.$async('todos', () => fetchTodos(), {
|
|
739
|
+
cache: true,
|
|
740
|
+
cacheTime: 600000,
|
|
741
|
+
loadingKey: 'todosLoading',
|
|
742
|
+
errorKey: 'todosError'
|
|
743
|
+
});
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
**Basic loading & error handling**
|
|
747
|
+
|
|
748
|
+
```js
|
|
749
|
+
// Basic loading and error state
|
|
750
|
+
await state.$async('data', fetchData, {
|
|
751
|
+
loadingKey: 'loading',
|
|
752
|
+
errorKey: 'error'
|
|
753
|
+
});
|
|
754
|
+
console.log(state.loading); // false
|
|
755
|
+
console.log(state.error); // undefined
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
**Optimistic update**
|
|
759
|
+
|
|
760
|
+
```js
|
|
761
|
+
// Provide an optimistic initial value
|
|
762
|
+
await state.$async('count', () => api.getCount(), {
|
|
763
|
+
optimistic: 0,
|
|
764
|
+
dataKey: 'count'
|
|
765
|
+
});
|
|
766
|
+
console.log(state.count); // 0 then actual value
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
**Retry logic**
|
|
770
|
+
|
|
771
|
+
```js
|
|
772
|
+
// Retry on failure up to 3 times
|
|
773
|
+
await state.$async('fetchUser', () => fetchUser(), {
|
|
774
|
+
retries: 3,
|
|
775
|
+
retryDelay: 1000,
|
|
776
|
+
dataKey: 'user',
|
|
777
|
+
errorKey: 'userError'
|
|
778
|
+
});
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
**Dedupe concurrent calls**
|
|
782
|
+
|
|
783
|
+
```js
|
|
784
|
+
// Dedupe concurrent invocations
|
|
785
|
+
const p1 = state.$async('todos', () => fetchTodos(), { dedupe: true });
|
|
786
|
+
const p2 = state.$async('todos', () => fetchTodosNew(), { dedupe: true });
|
|
787
|
+
console.log(p1 === p2); // true
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
**Manual cache clear**
|
|
791
|
+
|
|
792
|
+
```js
|
|
793
|
+
// Manually clear cached data
|
|
794
|
+
state.$clearCache('todos');
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
**Loading state check**
|
|
798
|
+
|
|
799
|
+
```js
|
|
800
|
+
// Check if async task is in progress
|
|
801
|
+
if (state.$isLoading('todos')) {
|
|
802
|
+
console.log('Todos are still loading...');
|
|
803
|
+
}
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
---
|
|
807
|
+
|
|
808
|
+
### ValidatePlugin
|
|
809
|
+
|
|
810
|
+
Validate types or values on state updates using custom validator functions.
|
|
811
|
+
|
|
812
|
+
Usage:
|
|
813
|
+
|
|
814
|
+
```js
|
|
815
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
816
|
+
import { ValidatePlugin } from './dist/esm/plugins/validate.min.js';
|
|
817
|
+
|
|
818
|
+
const state = restate({ age: 0, name: '' })
|
|
819
|
+
.use(ValidatePlugin({
|
|
820
|
+
'age': v => Number.isInteger(v) && v >= 0
|
|
821
|
+
}));
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
Methods:
|
|
825
|
+
|
|
826
|
+
| Method | Parameters | Returns | Description |
|
|
827
|
+
|----------------------------|-------------------------------------|----------------|---------------------------------------|
|
|
828
|
+
| `$validators(newValidators)` | `object` mapping path→validator | reactive proxy | Add or update validators at runtime |
|
|
829
|
+
|
|
830
|
+
**Primitive validation**
|
|
831
|
+
|
|
832
|
+
```js
|
|
833
|
+
// initial setup
|
|
834
|
+
const state = restate({ age: 0 }).use(ValidatePlugin({
|
|
835
|
+
'age': v => Number.isInteger(v) && v >= 0
|
|
836
|
+
}));
|
|
837
|
+
|
|
838
|
+
state.age = 10; // valid
|
|
839
|
+
state.age = -5; // throws TypeError
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
**Runtime validators**
|
|
843
|
+
|
|
844
|
+
```js
|
|
845
|
+
state.$validators({
|
|
846
|
+
'name': v => typeof v === 'string' && v.trim().length > 0
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
state.name = 'Alice'; // valid
|
|
850
|
+
state.name = ''; // throws TypeError
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
**Nested property validation**
|
|
854
|
+
|
|
855
|
+
```js
|
|
856
|
+
const userState = restate({ user: { email: '' } })
|
|
857
|
+
.use(ValidatePlugin({
|
|
858
|
+
'user.email': v => /^[^@]+@[^@]+\.[^@]+$/.test(v)
|
|
859
|
+
}));
|
|
860
|
+
|
|
861
|
+
userState.user.email = 'bob@example.com'; // valid
|
|
862
|
+
userState.user.email = 'invalid-email'; // throws TypeError
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
**Boolean validation**
|
|
866
|
+
|
|
867
|
+
```js
|
|
868
|
+
const boolState = restate({ isAdmin: false })
|
|
869
|
+
.use(ValidatePlugin({
|
|
870
|
+
'isAdmin': v => typeof v === 'boolean'
|
|
871
|
+
}));
|
|
872
|
+
|
|
873
|
+
boolState.isAdmin = true; // valid
|
|
874
|
+
boolState.isAdmin = 'yes'; // throws TypeError
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
**Object validation**
|
|
878
|
+
|
|
879
|
+
```js
|
|
880
|
+
const configState = restate({ config: {} })
|
|
881
|
+
.use(ValidatePlugin({
|
|
882
|
+
'config': v =>
|
|
883
|
+
v && typeof v === 'object' && 'mode' in v && ['light','dark'].includes(v.mode)
|
|
884
|
+
}));
|
|
885
|
+
|
|
886
|
+
configState.config = { mode: 'dark' }; // valid
|
|
887
|
+
configState.config = { version: 1 }; // throws TypeError
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
**Array validation**
|
|
891
|
+
|
|
892
|
+
```js
|
|
893
|
+
const listState = restate({ items: [] })
|
|
894
|
+
.use(ValidatePlugin({
|
|
895
|
+
'items': arr => Array.isArray(arr) && arr.every(x => typeof x === 'number')
|
|
896
|
+
}));
|
|
897
|
+
|
|
898
|
+
listState.items = [1,2,3]; // valid
|
|
899
|
+
listState.items = ['a','b','c']; // throws TypeError
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
## Snippets
|
|
904
|
+
|
|
905
|
+
### Simple Persistence + Methods
|
|
906
|
+
|
|
907
|
+
```js
|
|
908
|
+
import { restate } from './dist/esm/restate.min.js';
|
|
909
|
+
import { PersistencePlugin } from './dist/esm/plugins/persistence.min.js';
|
|
910
|
+
|
|
911
|
+
// Initialize with persistence ($methods is built-in, no plugin needed)
|
|
912
|
+
const state = restate({ count: 0, items: [] })
|
|
913
|
+
.use(PersistencePlugin, { persistKey: 'appState', persistDebounce: 0 });
|
|
914
|
+
|
|
915
|
+
// Define methods that use $set internally (built-in)
|
|
916
|
+
state.$methods({
|
|
917
|
+
addItem(item) {
|
|
918
|
+
this.$set({ items: [...this.items, item] });
|
|
919
|
+
},
|
|
920
|
+
increment() {
|
|
921
|
+
this.$set({ count: this.count + 1 });
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// Use methods
|
|
926
|
+
state.increment();
|
|
927
|
+
state.addItem('apple');
|
|
928
|
+
|
|
929
|
+
console.log(state.count); // 1
|
|
930
|
+
console.log(state.items); // ['apple']
|
|
931
|
+
|
|
932
|
+
// On new session or reload, restore persisted state
|
|
933
|
+
const restored = restate({}).use(PersistencePlugin, { persistKey: 'appState', persistDebounce: 0 });
|
|
934
|
+
console.log(restored.count); // 1
|
|
935
|
+
console.log(restored.items); // ['apple']
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
---
|
|
939
|
+
|
|
940
|
+
## Security Analysis
|
|
941
|
+
|
|
942
|
+
### $computed (Built-in)
|
|
943
|
+
|
|
944
|
+
| Concern | Status | Description |
|
|
945
|
+
|---------|--------|-------------|
|
|
946
|
+
| Reserved key pollution | ✅ Fixed | Skips `__proto__`, `constructor`, `prototype` |
|
|
947
|
+
| Circular dependencies | ✅ Fixed | Detects and prevents infinite loops |
|
|
948
|
+
| Non-function values | ✅ Fixed | Validates that computed values are functions |
|
|
949
|
+
| Memory cleanup | ✅ OK | `$destroy()` clears all computed state |
|
|
950
|
+
|
|
951
|
+
**Protections implemented:**
|
|
952
|
+
|
|
953
|
+
```javascript
|
|
954
|
+
// Reserved keys are skipped with warning
|
|
955
|
+
state.$computed({
|
|
956
|
+
__proto__: () => 'blocked', // ⚠️ Skipped
|
|
957
|
+
constructor: () => 'blocked', // ⚠️ Skipped
|
|
958
|
+
validKey: (s) => s.a + s.b // ✅ Allowed
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Circular dependencies detected
|
|
962
|
+
state.$computed({
|
|
963
|
+
a: (s) => s.b,
|
|
964
|
+
b: (s) => s.a // ❌ Error: Circular dependency detected
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// Non-functions rejected
|
|
968
|
+
state.$computed({
|
|
969
|
+
notAFunction: 'string' // ⚠️ Skipped with warning
|
|
970
|
+
});
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
---
|
|
974
|
+
|
|
975
|
+
### AsyncPlugin
|
|
976
|
+
|
|
977
|
+
| Concern | Status | Description |
|
|
978
|
+
| ------ | ---------- | ------------- |
|
|
979
|
+
| No timeouts | ✅ Fixed | Default 30s timeout, configurable per-request |
|
|
980
|
+
| Uncapped cache | ✅ Fixed | LRU eviction with `maxCacheSize` (default: 100) |
|
|
981
|
+
| Retry amplification | ✅ Fixed | Configurable `maxRetries` cap (default: 10) + exponential backoff |
|
|
982
|
+
|
|
983
|
+
**Plugin-level configuration:**
|
|
984
|
+
|
|
985
|
+
```javascript
|
|
986
|
+
state.use(AsyncPlugin({
|
|
987
|
+
maxRetries: 10, // Cap on retries (prevents DoS)
|
|
988
|
+
defaultTimeout: 30000, // 30s default timeout
|
|
989
|
+
maxCacheSize: 100, // Max cache entries (LRU eviction)
|
|
990
|
+
exponentialBackoff: true // 1s, 2s, 4s, 8s... between retries
|
|
991
|
+
}));
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
### HistoryPlugin
|
|
995
|
+
|
|
996
|
+
| Concern | Status | Description |
|
|
997
|
+
| ------ | ---------- | ------------- |
|
|
998
|
+
| JSON.stringify comparison | ✅ Fixed | Replaced with `deepEqual()` function |
|
|
999
|
+
| Circular reference handling | ✅ Fixed | `deepClone()` and `deepEqual()` use WeakMap/WeakSet |
|
|
1000
|
+
|
|
1001
|
+
**Good:** Max history limit enforced, cleanup on destroy, circular refs supported.
|
|
1002
|
+
|
|
1003
|
+
### ImmutablePlugin
|
|
1004
|
+
|
|
1005
|
+
| Concern | Status | Description |
|
|
1006
|
+
|------|----------|-------------|
|
|
1007
|
+
| Double proxy wrapping | ✅ Fixed | Now uses `beforeSet` hook, no extra proxy |
|
|
1008
|
+
| `_wrap` override conflicts | ✅ Fixed | No longer overrides `_wrap` |
|
|
1009
|
+
|
|
1010
|
+
**Good:** Uses hook system, proper strict mode, clear error messages, no conflicts with other plugins.
|
|
1011
|
+
|
|
1012
|
+
### PersistencePlugin
|
|
1013
|
+
|
|
1014
|
+
| Risk | Severity | Status | Description |
|
|
1015
|
+
|------|----------|--------|-------------|
|
|
1016
|
+
| Plain text storage | Medium | ✅ Fixed | Sensitive data exposed in localStorage |
|
|
1017
|
+
| No integrity check | Low | ✅ Fixed | Stored data could be tampered |
|
|
1018
|
+
| Migration code execution | Low | ✅ Fixed | Custom migrations run user code |
|
|
1019
|
+
|
|
1020
|
+
**Fixes implemented:**
|
|
1021
|
+
|
|
1022
|
+
**1. Built-in AES-GCM encryption** — No more plain text storage:
|
|
1023
|
+
|
|
1024
|
+
```javascript
|
|
1025
|
+
// Simple: just provide a password
|
|
1026
|
+
const state = restate({ secrets: {} })
|
|
1027
|
+
.use(PersistencePlugin({
|
|
1028
|
+
persistKey: 'myApp',
|
|
1029
|
+
persistEncrypt: 'my-secret-password' // AES-256-GCM encryption
|
|
1030
|
+
}));
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
**2. HMAC-SHA256 integrity checking** — Detect tampering:
|
|
1034
|
+
|
|
1035
|
+
```javascript
|
|
1036
|
+
const state = restate({ data: {} })
|
|
1037
|
+
.use(PersistencePlugin({
|
|
1038
|
+
persistKey: 'myApp',
|
|
1039
|
+
persistEncrypt: 'password', // Encryption
|
|
1040
|
+
persistIntegrity: true // + HMAC verification
|
|
1041
|
+
}));
|
|
1042
|
+
// If localStorage is modified, loadState() returns false
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
**3. Schema validation before migrations** — Validate before executing user code:
|
|
1046
|
+
|
|
1047
|
+
```javascript
|
|
1048
|
+
const state = restate({ user: { id: '', name: '' } })
|
|
1049
|
+
.use(PersistencePlugin({
|
|
1050
|
+
persistKey: 'myApp',
|
|
1051
|
+
persistVersion: 2,
|
|
1052
|
+
persistMigrations: {
|
|
1053
|
+
2: (state) => ({ ...state, user: { ...state.user, email: '' } })
|
|
1054
|
+
},
|
|
1055
|
+
persistValidateSchema: (state) => {
|
|
1056
|
+
// Runs BEFORE migrations — reject malformed data
|
|
1057
|
+
return state && typeof state.user === 'object' && typeof state.user.id === 'string';
|
|
1058
|
+
}
|
|
1059
|
+
}));
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
**Good:** Encryption hooks available, debounced saves, path filtering, Web Crypto API (PBKDF2 key derivation).
|
|
1063
|
+
|
|
1064
|
+
### ValidatePlugin
|
|
1065
|
+
|
|
1066
|
+
| Risk | Severity | Status | Description |
|
|
1067
|
+
|------|----------|--------|-------------|
|
|
1068
|
+
| Value in error message | Low | ✅ Fixed | `JSON.stringify(value)` could expose secrets |
|
|
1069
|
+
| Nested proxy overhead | Low | ✅ Fixed | Old implementation wrapped proxies with more proxies |
|
|
1070
|
+
|
|
1071
|
+
**1. Values are now safely redacted** in error messages to prevent sensitive data exposure:
|
|
1072
|
+
|
|
1073
|
+
**2. Now uses `beforeSet` hook** instead of monkey-patching `_wrap()` and creating nested proxies:
|
|
1074
|
+
|
|
1075
|
+
```javascript
|
|
1076
|
+
// Old approach (problematic):
|
|
1077
|
+
// - Overrode core._wrap() method
|
|
1078
|
+
// - Wrapped core._proxy with another proxy in install()
|
|
1079
|
+
// - Double proxy overhead on every access
|
|
1080
|
+
|
|
1081
|
+
// New approach (clean):
|
|
1082
|
+
return {
|
|
1083
|
+
name: 'validation',
|
|
1084
|
+
hooks: {
|
|
1085
|
+
beforeSet(path, value, oldValue) {
|
|
1086
|
+
validateValue(path, value); // Throws TypeError if invalid
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
methods: { $validators(v) { /* ... */ } }
|
|
1090
|
+
};
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
Error messages now safely redact sensitive values:
|
|
1094
|
+
|
|
1095
|
+
```javascript
|
|
1096
|
+
// Before (exposed full value):
|
|
1097
|
+
// "Validation failed for 'user.password': invalid value "secretPassword123""
|
|
1098
|
+
|
|
1099
|
+
// After (redacted):
|
|
1100
|
+
// "Validation failed for 'user.password': received [string, 16 chars]"
|
|
1101
|
+
// "Validation failed for 'config': received [object, 5 keys]"
|
|
1102
|
+
// "Validation failed for 'items': received [array, 10 items]"
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
---
|
|
1106
|
+
|
|
1107
|
+
## Security Best Practices
|
|
1108
|
+
|
|
1109
|
+
> If you discover a security vulnerability, please report it privately to the maintainers rather than opening a public issue.
|
|
1110
|
+
|
|
1111
|
+
### What is already protected
|
|
1112
|
+
|
|
1113
|
+
The core automatically blocks prototype pollution in these operations:
|
|
1114
|
+
|
|
1115
|
+
- `$set()` / `$set({ key: value })` — skips `__proto__`, `constructor`, `prototype`
|
|
1116
|
+
- `$computed()` — skips reserved keys
|
|
1117
|
+
- Direct property assignment — goes through Proxy (safe)
|
|
1118
|
+
|
|
1119
|
+
**You don't need to sanitize data for normal state operations:**
|
|
1120
|
+
|
|
1121
|
+
```javascript
|
|
1122
|
+
// ✅ Safe - restate handles this internally
|
|
1123
|
+
state.$set({ user: apiResponse.user });
|
|
1124
|
+
state.config = userInput;
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
- ✅ No `eval()` or `Function()` constructor usage
|
|
1128
|
+
- ✅ No `innerHTML` or DOM manipulation
|
|
1129
|
+
- ✅ No network requests in core (AsyncPlugin is opt-in)
|
|
1130
|
+
- ✅ No file system access
|
|
1131
|
+
- ✅ No child process spawning
|
|
1132
|
+
|
|
1133
|
+
---
|
|
1134
|
+
|
|
1135
|
+
### When to sanitize
|
|
1136
|
+
|
|
1137
|
+
Sanitize at your application's **trust boundary** — where untrusted data enters your system:
|
|
1138
|
+
|
|
1139
|
+
```javascript
|
|
1140
|
+
// Helper function (copy this if needed)
|
|
1141
|
+
const sanitize = (obj) => {
|
|
1142
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
1143
|
+
const clean = {};
|
|
1144
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1145
|
+
if (['__proto__', 'constructor', 'prototype'].includes(k)) continue;
|
|
1146
|
+
clean[k] = typeof v === 'object' ? sanitize(v) : v; // Recursive for nested
|
|
1147
|
+
}
|
|
1148
|
+
return clean;
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
// ❌ DANGEROUS: Spreading untrusted data directly into objects
|
|
1152
|
+
const merged = { ...state.user, ...untrustedInput }; // Bypasses Proxy!
|
|
1153
|
+
|
|
1154
|
+
// ✅ SAFE: Sanitize before spreading
|
|
1155
|
+
const merged = { ...state.user, ...sanitize(untrustedInput) };
|
|
1156
|
+
|
|
1157
|
+
// ❌ DANGEROUS: Object.assign with untrusted data
|
|
1158
|
+
Object.assign(someObject, untrustedInput);
|
|
1159
|
+
|
|
1160
|
+
// ✅ SAFE: Sanitize first
|
|
1161
|
+
Object.assign(someObject, sanitize(untrustedInput));
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
---
|
|
1165
|
+
|
|
1166
|
+
### What operation to avoid
|
|
1167
|
+
|
|
1168
|
+
The following table lists all JavaScript operations that can bypass restate's Proxy traps:
|
|
1169
|
+
|
|
1170
|
+
| Operation | Bypasses Proxy? | Risk Level | Description | Safe Alternative |
|
|
1171
|
+
|-----------|-----------------|------------|-------------|------------------|
|
|
1172
|
+
| `{ ...obj }` (spread) | ✅ Yes | High | Creates plain object copy, no proxy traps | Use `state.$set()` or sanitize input |
|
|
1173
|
+
| `Object.assign(target, src)` | ✅ Yes | High | Copies properties directly to target | Sanitize source before assigning |
|
|
1174
|
+
| `JSON.parse(JSON.stringify(obj))` | ✅ Yes | High | Deep clone creates plain object | Use for serialization only, not mutation |
|
|
1175
|
+
| `structuredClone(obj)` | ✅ Yes | High | Deep clone bypasses proxy entirely | Use for cloning only, not mutation |
|
|
1176
|
+
| `Object.fromEntries(Object.entries(obj))` | ✅ Yes | Medium | Converts to/from entries, loses proxy | Avoid for state manipulation |
|
|
1177
|
+
| `_.cloneDeep(obj)` (Lodash) | ✅ Yes | High | Library clones create plain objects | Use native proxy-aware methods |
|
|
1178
|
+
| `Array.from(arr)` / `[...arr]` | ✅ Yes | Medium | Array spread creates plain array | Use `state.$set()` for array updates |
|
|
1179
|
+
| `.map()`, `.filter()`, `.slice()` | ⚠️ Partial | Low | Returns new plain array (reads are proxied) | Assign result back via `$set()` |
|
|
1180
|
+
| `Object.keys()` / `.values()` / `.entries()` | ❌ No | Low | Reads through proxy (safe for reading) | Safe to use |
|
|
1181
|
+
| `for...in` / `for...of` | ❌ No | Low | Iteration triggers proxy traps | Safe to use |
|
|
1182
|
+
| Destructuring `const { a } = state` | ⚠️ Partial | Low | Read is proxied, but `a` is now a plain value | Safe for reading, not for nested mutation |
|
|
1183
|
+
| `Object.getOwnPropertyDescriptor()` | ⚠️ Partial | Low | Can access descriptors directly | Avoid for untrusted keys |
|
|
1184
|
+
| `Reflect.get()` / `Reflect.set()` | ❌ No | Low | Works with proxy traps | Safe to use |
|
|
1185
|
+
| `delete obj.prop` | ❌ No | Low | Triggers proxy `deleteProperty` trap | Safe to use |
|
|
1186
|
+
| `'prop' in obj` | ❌ No | Low | Triggers proxy `has` trap | Safe to use |
|
|
1187
|
+
|
|
1188
|
+
**Legend:**
|
|
1189
|
+
- ✅ Yes = Completely bypasses proxy, creates unprotected data
|
|
1190
|
+
- ⚠️ Partial = Some operations are proxied, but result may not be
|
|
1191
|
+
- ❌ No = Works correctly with proxy traps
|
|
1192
|
+
|
|
1193
|
+
```javascript
|
|
1194
|
+
// Examples of bypassing operations
|
|
1195
|
+
|
|
1196
|
+
// 1. Spread operator - creates plain object
|
|
1197
|
+
const copy = { ...state.user }; // copy is NOT a proxy
|
|
1198
|
+
copy.__proto__ = malicious; // ❌ No protection!
|
|
1199
|
+
|
|
1200
|
+
// 2. JSON round-trip - full bypass
|
|
1201
|
+
const clone = JSON.parse(JSON.stringify(state));
|
|
1202
|
+
clone.polluted = untrustedData; // ❌ No protection!
|
|
1203
|
+
|
|
1204
|
+
// 3. structuredClone - full bypass
|
|
1205
|
+
const deep = structuredClone(state);
|
|
1206
|
+
deep.__proto__ = malicious; // ❌ No protection!
|
|
1207
|
+
|
|
1208
|
+
// 4. Array methods return plain arrays
|
|
1209
|
+
const filtered = state.items.filter(x => x.active);
|
|
1210
|
+
filtered.push(untrustedItem); // ❌ No proxy protection
|
|
1211
|
+
state.$set({ items: filtered }); // ✅ Safe: goes through proxy
|
|
1212
|
+
|
|
1213
|
+
// 5. Destructuring extracts plain values
|
|
1214
|
+
const { user } = state; // user is now plain object if nested
|
|
1215
|
+
user.name = 'hacked'; // ⚠️ May not trigger watchers
|
|
1216
|
+
|
|
1217
|
+
// ✅ SAFE: Always use $set() for mutations
|
|
1218
|
+
state.$set({ user: { ...state.user, name: 'Alice' } });
|
|
1219
|
+
state.$set(s => { s.items.push(newItem); });
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
**Key insight:** The danger is when you use JavaScript operations (`...spread`, `Object.assign`, `JSON.parse`, `structuredClone`, etc.) that bypass restate's Proxy protection. If all data flows through `state.$set()` or direct property assignment on the proxy, you're protected.
|
|
1223
|
+
|
|
1224
|
+
---
|
|
1225
|
+
|
|
1226
|
+
### Encryption for persisted data
|
|
1227
|
+
|
|
1228
|
+
Choose your encryption key strategy based on your security needs:
|
|
1229
|
+
|
|
1230
|
+
```javascript
|
|
1231
|
+
// 🟡 OBFUSCATION ONLY - Hardcoded key (visible in source code)
|
|
1232
|
+
// Use for: UI preferences, non-sensitive settings
|
|
1233
|
+
state.use(PersistencePlugin({
|
|
1234
|
+
persistEncrypt: 'app-obfuscation-key',
|
|
1235
|
+
persistIntegrity: true
|
|
1236
|
+
}));
|
|
1237
|
+
// ⚠️ Anyone viewing your bundle can extract this key
|
|
1238
|
+
|
|
1239
|
+
// 🟢 REAL SECURITY - User-derived key (recommended for sensitive data)
|
|
1240
|
+
// Use for: Auth tokens, personal data, sensitive information
|
|
1241
|
+
const userPassword = await promptUserForPassword();
|
|
1242
|
+
state.use(PersistencePlugin({
|
|
1243
|
+
persistEncrypt: userPassword, // Key derived via PBKDF2
|
|
1244
|
+
persistIntegrity: true
|
|
1245
|
+
}));
|
|
1246
|
+
// ✅ Only the user knows the key - truly encrypted
|
|
1247
|
+
|
|
1248
|
+
// 🟢 SESSION KEY - Server-provided (good with auth infrastructure)
|
|
1249
|
+
const { sessionKey } = await authenticatedFetch('/api/session-key');
|
|
1250
|
+
state.use(PersistencePlugin({
|
|
1251
|
+
persistEncrypt: sessionKey,
|
|
1252
|
+
persistIntegrity: true
|
|
1253
|
+
}));
|
|
1254
|
+
// ✅ Key not in source code, but requires backend support
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
| Key Strategy | Security Level | Best For |
|
|
1258
|
+
|--------------|----------------|----------|
|
|
1259
|
+
| Hardcoded / env var | 🟡 Obfuscation | UI state, preferences |
|
|
1260
|
+
| User-derived (password) | 🟢 Real encryption | Sensitive user data |
|
|
1261
|
+
| Server session key | 🟢 Good | Apps with auth backend |
|
|
1262
|
+
|
|
1263
|
+
---
|
|
1264
|
+
|
|
1265
|
+
### Validate all external inputs
|
|
1266
|
+
|
|
1267
|
+
```javascript
|
|
1268
|
+
state.use(ValidatePlugin({
|
|
1269
|
+
'user.email': v => /^[^@]+@[^@]+\.[^@]+$/.test(v),
|
|
1270
|
+
'user.age': v => Number.isInteger(v) && v >= 0 && v <= 150
|
|
1271
|
+
}));
|
|
1272
|
+
```
|
|
1273
|
+
|
|
1274
|
+
---
|
|
1275
|
+
|
|
1276
|
+
### Limit watcher scope
|
|
1277
|
+
|
|
1278
|
+
```javascript
|
|
1279
|
+
state.$watch('user.profile.*', fn); // ✅ Specific paths
|
|
1280
|
+
// Avoid: state.$watch('**', fn); // ❌ Too broad, performance impact
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
---
|
|
1284
|
+
|
|
1285
|
+
### Set reasonable depth limits
|
|
1286
|
+
|
|
1287
|
+
```javascript
|
|
1288
|
+
const state = restate(data, { maxDepth: 10 });
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
## Libraries Comparison
|
|
1292
|
+
|
|
1293
|
+
| Library | Paradigm | Computed | Immutability | Key API | Size (gzipped) | Scope |
|
|
1294
|
+
|---------|----------|----------|--------------|---------|----------------|-------|
|
|
1295
|
+
| **restate** | Proxy + plugins | ✅ Built-in | Optional strict | `$set`, `$onChange`, `$watch`, `$computed`, `$methods` | ~2.8 KB core | Framework-agnostic |
|
|
1296
|
+
| Redux | Flux / functional | ❌ Needs reselect | Immutable by design | `createStore`, `dispatch`, `reducer` | ~2.5 KB + reselect ~0.6 KB | Framework-agnostic |
|
|
1297
|
+
| Vuex | Flux for Vue | ✅ Getters | Immutable via patterns | `state`, `getters`, `mutations`, `actions` | ~12 KB | Vue.js |
|
|
1298
|
+
| Zustand | Hooks | ⚠️ Manual | Mutable | `setState`, `getState`, `subscribe` | ~1.6 KB | React |
|
|
1299
|
+
| MobX | Observables | ✅ @computed | Mutable | Decorators, `action`, `autorun` | ~20 KB | Framework-agnostic |
|
|
1300
|
+
| XState | State machines | ❌ Derived only | Immutable transitions | `createMachine`, `interpret` | ~8 KB | Framework-agnostic |
|
|
1301
|
+
|
|
1302
|
+
All libraries have different trade-offs: `restate` provides fine-grained change tracking with built-in computed properties and custom methods, plus an extensible plugin system for additional features. It enables a middle ground between full immutability and direct mutation with minimal boilerplate.
|
|
1303
|
+
|
|
1304
|
+
## License
|
|
1305
|
+
|
|
1306
|
+
`restate` is **dual-licensed** to support both open-source and commercial use.
|
|
1307
|
+
|
|
1308
|
+
### Open-Source / Non-Commercial Use
|
|
1309
|
+
|
|
1310
|
+
`restate` is available under the **MIT License** for:
|
|
1311
|
+
|
|
1312
|
+
- ✅ Open-source projects (OSI-approved licenses)
|
|
1313
|
+
- ✅ Personal projects and non-revenue generating applications
|
|
1314
|
+
- ✅ Educational purposes and academic research
|
|
1315
|
+
- ✅ Non-profit organizations
|
|
1316
|
+
- ✅ Internal company tools (non-revenue generating)
|
|
1317
|
+
- ✅ Prototypes and MVPs
|
|
1318
|
+
|
|
1319
|
+
See [LICENSE](./LICENSE) and [LICENSE-NONCOMMERCIAL.md](./LICENSE-NONCOMMERCIAL.md) for full details.
|
|
1320
|
+
|
|
1321
|
+
---
|
|
1322
|
+
|
|
1323
|
+
### Commercial Use
|
|
1324
|
+
|
|
1325
|
+
A **commercial license** is required for:
|
|
1326
|
+
|
|
1327
|
+
- ❌ Proprietary software and closed-source commercial applications
|
|
1328
|
+
- ❌ SaaS products and revenue-generating applications
|
|
1329
|
+
- ❌ Enterprise deployments and large-scale corporate use
|
|
1330
|
+
- ❌ White-label products sold or licensed to third parties
|
|
1331
|
+
|
|
1332
|
+
**Commercial licenses include:**
|
|
1333
|
+
|
|
1334
|
+
- Legal protection and indemnification
|
|
1335
|
+
- Priority support and SLA
|
|
1336
|
+
- Updates and bug fixes during license term
|
|
1337
|
+
- Custom licensing terms for enterprise needs
|
|
1338
|
+
|
|
1339
|
+
See [EULA-COMMERCIAL.md](./EULA-COMMERCIAL.md) for commercial licensing terms.
|
|
1340
|
+
|
|
1341
|
+
---
|
|
1342
|
+
|
|
1343
|
+
### Licensing Inquiries
|
|
1344
|
+
|
|
1345
|
+
**For commercial licensing, pricing, or questions:**
|
|
1346
|
+
|
|
1347
|
+
> ynck.chrl@protonmail.com
|
|
1348
|
+
|
|
1349
|
+
I'll be happy to discuss licensing options that fit your needs.
|