@spfn/core 0.3.0-beta.25 → 0.3.0-beta.26
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 +4 -1
- package/dist/event/sse/client.d.ts +1 -1
- package/dist/event/sse/client.js +12 -1
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +2 -2
- package/dist/server/index.d.ts +1 -1
- package/dist/state/client.d.ts +144 -0
- package/dist/state/client.js +478 -0
- package/dist/state/client.js.map +1 -0
- package/dist/state/index.d.ts +17 -2
- package/dist/state/index.js +10 -3
- package/dist/state/index.js.map +1 -1
- package/dist/state/react.d.ts +34 -0
- package/dist/state/react.js +44 -0
- package/dist/state/react.js.map +1 -0
- package/dist/{types-DqXYtI69.d.ts → types-04gu3uMj.d.ts} +7 -0
- package/package.json +21 -2
- package/src/event/README.md +21 -0
- package/src/state/README.md +198 -9
package/src/state/README.md
CHANGED
|
@@ -10,17 +10,21 @@ declaration the read route, the write route, its entry in a snapshot route and t
|
|
|
10
10
|
The server is the single source of truth: every value a client receives is what `read()`
|
|
11
11
|
returned, read on the server; the browser never computes one.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
The browser side — `@spfn/core/state/client` and the React hook `@spfn/core/state/react` —
|
|
14
|
+
is described under [Browser](#browser).
|
|
15
15
|
|
|
16
16
|
## Import paths
|
|
17
17
|
|
|
18
|
-
One entry point, server only.
|
|
19
|
-
|
|
20
18
|
```typescript
|
|
19
|
+
// server
|
|
21
20
|
import { defineState, defineStateRouter } from '@spfn/core/state';
|
|
22
21
|
import type { InferStateValue, InferStateKey, InferStateInput } from '@spfn/core/state';
|
|
23
22
|
import { Type } from '@sinclair/typebox'; // for .key() / .value() / .data() / .write() schemas
|
|
23
|
+
|
|
24
|
+
// browser — the router by type only
|
|
25
|
+
import { createStateClient } from '@spfn/core/state/client';
|
|
26
|
+
import { useServerState } from '@spfn/core/state/react';
|
|
27
|
+
import type { states } from '@/server/states';
|
|
24
28
|
```
|
|
25
29
|
|
|
26
30
|
---
|
|
@@ -32,7 +36,7 @@ From `@spfn/core/state`:
|
|
|
32
36
|
- Builder: `defineState(id)` → `StateBuilder`
|
|
33
37
|
- Router: `defineStateRouter(states, { path?, subject? })` → `StateRouter`
|
|
34
38
|
- Types: `StateBuilder`, `StateRouter`, `StateRouterOptions`, `StateRoutes`, `StateEvents`,
|
|
35
|
-
`StatesSnapshot`, `SubjectResolver`, `InferStateValue`, `InferStateKey`, `InferStateInput`,
|
|
39
|
+
`StatesSnapshot`, `StatesManifest`, `SubjectResolver`, `InferStateValue`, `InferStateKey`, `InferStateInput`,
|
|
36
40
|
`StateShape`, `StateValue`, `StateKey`, `StateResult`, `StateSnapshot`, `StateFrame`,
|
|
37
41
|
`ReadContext`, `PermissionContext`, `WriteContext`, `ChangedPayload`, `GrantTarget`,
|
|
38
42
|
`RevokeTarget`, `StateContract`, `StateWriteOptions`, `RoutableState`
|
|
@@ -180,7 +184,11 @@ SSE `getSubject` default. No subject → `UnauthorizedError` (401). The principa
|
|
|
180
184
|
- **Snapshot** `GET <path>`: subject → one `at` → for each state, in declaration order: a keyed
|
|
181
185
|
state whose key fields are not all in the query is omitted; `canRead` false → omitted;
|
|
182
186
|
otherwise `read()`. A keyed state left out never stops a later keyless one. A `read()` that
|
|
183
|
-
throws fails the whole request — an error is not a permission.
|
|
187
|
+
throws fails the whole request — an error is not a permission. The answer is
|
|
188
|
+
`{ at, states, manifest }`: `manifest` is `{ [id]: { key: string[]; write: boolean } }` for
|
|
189
|
+
every state in declaration order — the key fields (`[]` when keyless) and whether it has a
|
|
190
|
+
write — built once when the router is built. It is what the [browser client](#browser)
|
|
191
|
+
knows of the router at runtime.
|
|
184
192
|
|
|
185
193
|
Every `read()` result is checked against `{ value, data }` as declared — `data` exactly when
|
|
186
194
|
`.data()` is, no other field — with a schema built once per state when the router is built.
|
|
@@ -334,17 +342,198 @@ change" by the client (implemented in the client half). There is no version coun
|
|
|
334
342
|
- the SSE event `state.<id>.changed` with `since` / `deprecatedIn` / `removedIn`.
|
|
335
343
|
|
|
336
344
|
A state without `.contract()` appears nowhere, like a route without one. The snapshot route is
|
|
337
|
-
not contracted. A `value` literal removed later is `response.type-changed` on the read
|
|
345
|
+
not contracted, its `manifest` included. A `value` literal removed later is `response.type-changed` on the read
|
|
338
346
|
operation and `event.payload.type-changed` on the event, as any route and event.
|
|
339
347
|
|
|
340
348
|
---
|
|
341
349
|
|
|
350
|
+
## Browser
|
|
351
|
+
|
|
352
|
+
`@spfn/core/state/client` is the browser side of a state router: it calls the derived read,
|
|
353
|
+
write and snapshot routes, keeps one SSE stream open for every state, and decides per
|
|
354
|
+
`(id, key)` which of a snapshot, a frame, a read or a write answer is current.
|
|
355
|
+
`@spfn/core/state/react` puts one state into a component. The router is known **by type
|
|
356
|
+
only** — `import type { states }` — so no server code reaches the browser bundle;
|
|
357
|
+
`@spfn/core/state/client` imports nothing from `@spfn/core/state` at runtime.
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
// src/client/state.ts
|
|
361
|
+
import { createStateClient } from '@spfn/core/state/client';
|
|
362
|
+
import { createAuthSSEClient } from '@spfn/core/event/sse/client';
|
|
363
|
+
import type { states } from '@/server/states'; // type only
|
|
364
|
+
|
|
365
|
+
export const state = createStateClient<typeof states>({
|
|
366
|
+
sse: createAuthSSEClient(), // the state client's own
|
|
367
|
+
api: { baseUrl: '/api/rpc' }, // passed to createApi
|
|
368
|
+
});
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
```tsx
|
|
372
|
+
'use client';
|
|
373
|
+
import { useServerState } from '@spfn/core/state/react';
|
|
374
|
+
import { state } from '@/client/state';
|
|
375
|
+
|
|
376
|
+
function Shell({ workspaceId }: { workspaceId: string })
|
|
377
|
+
{
|
|
378
|
+
const consent = useServerState(state, 'consent'); // keyless
|
|
379
|
+
const runtime = useServerState(state, 'runtime', { workspaceId }); // keyed
|
|
380
|
+
|
|
381
|
+
if (consent.status === 'loading' || runtime.status === 'loading')
|
|
382
|
+
{
|
|
383
|
+
return <Spinner />;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return consent.value === 'pending'
|
|
387
|
+
? <Terms onAgree={marketing => consent.write({ termsVersion: '2026-10', marketing })} />
|
|
388
|
+
: <Workspace runtime={runtime.value} />;
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### The manifest
|
|
393
|
+
|
|
394
|
+
A type cannot be read at runtime, and the client needs three facts the router type carries:
|
|
395
|
+
every state id (the stream requests every state event), each keyed state's key fields (to tell
|
|
396
|
+
which key a snapshot entry belongs to) and which states have a write. The snapshot route
|
|
397
|
+
answers them as `manifest`, so the app declares the router once, on the server.
|
|
398
|
+
|
|
399
|
+
The first `subscribe` — or `get`, `write`, `snapshot` — therefore reads the snapshot **first**:
|
|
400
|
+
the client installs its manifest, then applies its states, then opens the stream with
|
|
401
|
+
`events=` from the manifest, in its order. Until then a `subscribe` waits (it never throws),
|
|
402
|
+
and `current()` is `undefined`; a `subscribe` while that snapshot is in flight is served by it,
|
|
403
|
+
not by a second read. Once the manifest is known, a `subscribe` whose id it does not name, or
|
|
404
|
+
whose key fields differ from the state's, is refused: the error, naming the state and both sets
|
|
405
|
+
of fields, goes to `onError`, and the subscription is dropped without reopening the stream. A
|
|
406
|
+
`write` on a state the manifest marks `write: false` rejects naming the state, before any
|
|
407
|
+
request. A first snapshot that fails goes to `onError` (or rejects the call that needed it).
|
|
408
|
+
While subscriptions wait on it, it is read again every `retryDelay` (default 3000 ms, the SSE
|
|
409
|
+
client's `reconnectDelay`), each failure to `onError`, until it succeeds, `close()` runs, or
|
|
410
|
+
nothing is subscribed; a `get`, `write` or `snapshot` that reads it first serves the waiting
|
|
411
|
+
subscriptions too — whichever call brings the manifest opens the stream. `close()` while it is
|
|
412
|
+
in flight discards its answer, and a `get` or `write` waiting on it rejects: the client was
|
|
413
|
+
closed.
|
|
414
|
+
|
|
415
|
+
### `createStateClient<typeof states>({ sse, api?, onError?, retryDelay? })`
|
|
416
|
+
|
|
417
|
+
| Member | Behaviour |
|
|
418
|
+
| --- | --- |
|
|
419
|
+
| `get(id, key?)` | `GET` `get<Id>State`, key fields in the query. The answer is applied under the ordering rule and returned |
|
|
420
|
+
| `snapshot(query?)` | `GET getStates`, by default with the stream's query. Each state in the answer is applied, and each subscribed state it omits is marked `'revoked'` (below); the snapshot is returned. A rejected fetch rejects and changes nothing |
|
|
421
|
+
| `subscribe(id, key?, listener)` | Registers the listener for `(id, key)` and returns its unsubscribe (idempotent). The client's first subscriber reads the snapshot, then opens the stream (below) |
|
|
422
|
+
| `write(id, input, key?)` | `put<Id>State` with the input as the body, key fields in the query. The answer is applied at once — before the frame the write also sends — and returned. Typed only for a state with a write, and requires the key on a keyed one |
|
|
423
|
+
| `current(id, key?)` | Synchronous: `{ at, value, data?, status }`, `{ status: 'revoked' }` for a state revoked before any value arrived, or `undefined`. The same frozen object until a change is applied |
|
|
424
|
+
| `close()` | Closes the stream, drops every listener. Idempotent |
|
|
425
|
+
|
|
426
|
+
`onError` receives a failure of something the client ran on its own — the first snapshot, the
|
|
427
|
+
snapshot after the stream opened, the read after a grant — and a refused `subscribe` (default
|
|
428
|
+
`console.error`). Calls the app makes reject.
|
|
429
|
+
|
|
430
|
+
A listener that throws is logged at warn with the state id and does not stop the others.
|
|
431
|
+
|
|
432
|
+
Give the state client an `SSEClient` of its own: an `SSEClient` holds one subscription, and
|
|
433
|
+
the state client's supersedes any other made on it.
|
|
434
|
+
|
|
435
|
+
### Ordering
|
|
436
|
+
|
|
437
|
+
Per `(id, key)`, every arrival — a snapshot entry, a frame, a read or a write answer — goes
|
|
438
|
+
through one rule. `at` is the time the server's read started, so a larger `at` is a later value.
|
|
439
|
+
|
|
440
|
+
| Arrival | Result |
|
|
441
|
+
| --- | --- |
|
|
442
|
+
| no current entry, or `at` newer than the current | applied, listeners notified |
|
|
443
|
+
| `at` equal to the current | one read stamps one `at`, so this is the same value: applied and notified only when `value` or `data` differ (deep-equal) or the entry is `'revoked'` |
|
|
444
|
+
| `at` older than the current | dropped |
|
|
445
|
+
| a snapshot older than one state's entry | that state keeps its entry; the others take the snapshot |
|
|
446
|
+
| a write answer | applied like a frame — its own frame, arriving later with the same `at` and value, changes nothing |
|
|
447
|
+
| a frame for a key not subscribed here | dropped (the server already filters by the connect query; this is the second guard) |
|
|
448
|
+
|
|
449
|
+
The ordering rule decides the value; the status is decided apart (next section): an arrival
|
|
450
|
+
on a revoked `(id, key)` supplies its value and leaves it `'revoked'`.
|
|
451
|
+
|
|
452
|
+
An applied change replaces the entry with a new frozen object; nothing else does. That is
|
|
453
|
+
what `useSyncExternalStore` needs: `getSnapshot` returns the same reference while nothing
|
|
454
|
+
changed.
|
|
455
|
+
|
|
456
|
+
### The stream
|
|
457
|
+
|
|
458
|
+
- **One stream per client**, opened when the first subscriber arrives and closed when the last
|
|
459
|
+
leaves. Subscriptions made in the same task (components mounting together, a StrictMode
|
|
460
|
+
unmount-remount) are batched into one change.
|
|
461
|
+
- **Snapshot first**: the first stream opens after the first snapshot, whose manifest it needs.
|
|
462
|
+
- **Every event requested**: `events=` lists `state.<id>.changed` for every state of the
|
|
463
|
+
manifest, in its order, never only the subscribed ones. The server's `grant` activates only an event the
|
|
464
|
+
connection requested, so a state subscribed later would otherwise be ungrantable.
|
|
465
|
+
- **Keys in the query**: the connect query is the union of the subscribed keys' fields (SSE
|
|
466
|
+
`subscribe({ query })`). A new key value, or the last subscriber of one leaving, closes the
|
|
467
|
+
stream and opens it with the new query.
|
|
468
|
+
- **Snapshot on every open** — the first, a reconnect, a reopen on a key change. A frame sent
|
|
469
|
+
while no stream was open is lost, whether its key changed or not; the snapshot read after
|
|
470
|
+
the open covers that gap. (It runs on the stream's `open`, not at `onReconnect`: a snapshot
|
|
471
|
+
read before the new stream is live would leave the same gap.)
|
|
472
|
+
- **One value per key field per connection.** A connect query carries one `workspaceId`; when
|
|
473
|
+
two subscribed keys disagree on a field, the most recently subscribed key is carried. A key
|
|
474
|
+
the stream does not carry is read once (`get`) when the stream opens and receives no frames
|
|
475
|
+
until it is carried again. A screen that must follow two workspaces live uses a second
|
|
476
|
+
state client.
|
|
477
|
+
|
|
478
|
+
### `'revoked'` — the server does not serve this to you
|
|
479
|
+
|
|
480
|
+
One status says the server stopped serving a `(id, key)` to this subject, however the client
|
|
481
|
+
learned it. The mark is kept per `(id, key)` apart from the value, so it holds even when no
|
|
482
|
+
value has arrived yet.
|
|
483
|
+
|
|
484
|
+
| Arrival | Client |
|
|
485
|
+
| --- | --- |
|
|
486
|
+
| `subscription.revoked` for `state.<id>.changed` | the entry of `id` for **the key the connection carries** (keyless: its one entry) gets `status: 'revoked'`, value kept, listeners notified. The server scopes a revoke by the connect query, so a shadowed key of the same state is untouched |
|
|
487
|
+
| a snapshot that omits a subscribed state — keyless, or keyed with its key in the query | `'revoked'`, value kept if one exists, listeners notified. This is also a state denied at connect (`canRead` false, absent from the snapshot), and a reconnect after access was lost while the stream was down, when no revoke could arrive. A keyed state whose key the query did not carry is not marked |
|
|
488
|
+
| a frame, a read or a snapshot started before the revoke | supplies its value under the ordering rule; the status stays `'revoked'` |
|
|
489
|
+
| `subscription.granted` for it | clears the mark for the carried key and reads it once (`get`); the server also sends a frame, before or after the control frame. Both arrive `'ready'`, and either order ends at the latest value |
|
|
490
|
+
| the snapshot of a connection opened after the revoke, holding the state | clears the mark: that connection's own connect check served it |
|
|
491
|
+
| the read of a shadowed key when a connection opened after the revoke, answered | clears the mark the same way: the read route served it |
|
|
492
|
+
| an answer of such a read older than the entry — a frame or a `write()` answer of the new connection landed first | its value is dropped under the ordering rule, while the mark is still lifted: the lift goes by the connection, not by `at` |
|
|
493
|
+
|
|
494
|
+
Every automatic read is tagged with the connection it was started for, which is how a
|
|
495
|
+
snapshot in flight across a revoke is told from one read after it.
|
|
496
|
+
|
|
497
|
+
### `useServerState(client, id, key?)`
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
// → { status: 'loading' | 'ready' | 'revoked'; at?: string; value?: V; data?: D; write?: (input) => Promise<StateFrame> }
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
- Built on `useSyncExternalStore`: `subscribe` is `client.subscribe(id, key, cb)`,
|
|
504
|
+
`getSnapshot` is `client.current(id, key)`, and the server snapshot is `undefined` — a
|
|
505
|
+
server render is `'loading'` and opens nothing.
|
|
506
|
+
- The key is compared by its fields (stable-stringified), so `{ workspaceId }` written inline
|
|
507
|
+
does not resubscribe on every render; another `workspaceId` unsubscribes and subscribes. A
|
|
508
|
+
key never seen is `'loading'` until it has an entry; a key seen before shows its cached
|
|
509
|
+
entry as `'ready'` until the reopen snapshot lands (entries are not evicted).
|
|
510
|
+
- `write` is typed only for a state with a write, bound to the hook's key. At runtime it is on
|
|
511
|
+
every result — the type decides, so no render waits on the manifest — and on a state
|
|
512
|
+
without a write it rejects naming the state.
|
|
513
|
+
- `react` (`^18 || ^19`) is an **optional peer** of `@spfn/core`: only `@spfn/core/state/react`
|
|
514
|
+
imports it. `@spfn/core/state/client` is framework-neutral.
|
|
515
|
+
|
|
516
|
+
A page's first paint still comes from a server component reading the state's route with the
|
|
517
|
+
app's `api.` client; the hook takes over in the browser.
|
|
518
|
+
|
|
519
|
+
### Types
|
|
520
|
+
|
|
521
|
+
From `@spfn/core/state/client`: `createStateClient`, `StateClient<TStates>`,
|
|
522
|
+
`StateClientConfig`, `StateFrame<V, D>`, `StateEntry<V, D>` (a frame plus `status`, or
|
|
523
|
+
`{ status: 'revoked' }` alone), `StateListener`, `StateId`, `StateKeyArgs`, `WritableStateId`,
|
|
524
|
+
`StateCatalog`, `StateFrameOf`, `InferStateData`, and — re-exported type-only from the server
|
|
525
|
+
module — `InferStateValue`, `InferStateKey`, `InferStateInput`. From `@spfn/core/state/react`:
|
|
526
|
+
`useServerState`, `ServerState<TStates, Id>`.
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
342
530
|
## Out of scope
|
|
343
531
|
|
|
344
532
|
- Browser-only state (an open panel, a draft), session and network state.
|
|
345
533
|
- Optimistic updates: the client applies what the server read, never a guess.
|
|
346
|
-
-
|
|
347
|
-
a
|
|
534
|
+
- In the browser client: optimistic updates, offline queueing of writes, more than one SSE
|
|
535
|
+
connection per client, WebSocket, and Next.js server components (a server component reads
|
|
536
|
+
the route with the app's `api.` client as any other initial data).
|
|
348
537
|
- De-duplicating `read()` across subscribers of a per-subject keyed state (each subject has
|
|
349
538
|
its own value; a shared key is read once).
|
|
350
539
|
- A route-level "refuse until state X is Y" gate.
|