@spfn/core 0.3.0-beta.32 → 0.3.0-beta.33
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/dist/event/index.d.ts +2 -2
- package/dist/event/index.js +32 -4
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +12 -4
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +40 -5
- package/dist/event/sse/index.js +437 -28
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +223 -37
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +8 -1
- package/dist/state/client.js +15 -5
- package/dist/state/client.js.map +1 -1
- package/dist/state/react.d.ts +3 -1
- package/dist/state/react.js.map +1 -1
- package/dist/{token-manager-Ckjt2-qq.d.ts → token-manager-CvOsAbnK.d.ts} +47 -3
- package/dist/{types-CurMab2Q.d.ts → types-BrO7j6mR.d.ts} +17 -3
- package/dist/{types-DncqXfWN.d.ts → types-DH6d2eOb.d.ts} +1 -1
- package/package.json +1 -1
- package/src/event/README.md +66 -6
- package/src/state/README.md +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spfn/core",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.33",
|
|
4
4
|
"description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/event/README.md
CHANGED
|
@@ -59,7 +59,9 @@ From `@spfn/core/event/sse`:
|
|
|
59
59
|
|
|
60
60
|
- `createSSEHandler(router, config?, tokenManager?)` — Hono GET handler
|
|
61
61
|
- `SSETokenManager` (class), `CacheTokenStore` (class)
|
|
62
|
-
-
|
|
62
|
+
- `closeSSEConnectionsByKey(keyId)` — close the streams a device key opened, on every pod
|
|
63
|
+
(see [Closing the streams of a revoked device key](#closing-the-streams-of-a-revoked-device-key))
|
|
64
|
+
- Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEKeyVerifier`, `SSEMessage`,
|
|
63
65
|
`SSEHandlerConfig`, `SSEHandlerAuthConfig`, `SSEAuthConfig`, `SSEClientConfig`,
|
|
64
66
|
`SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSESubscriptionChange`,
|
|
65
67
|
`SSEConnectionState`, `SSEUnsubscribe`, `ConnectedPayload`
|
|
@@ -269,10 +271,10 @@ can't ride along on the stream request. SPFN uses a **token exchange**: an authe
|
|
|
269
271
|
```
|
|
270
272
|
Client Server
|
|
271
273
|
│ POST /events/token (Bearer JWT) │ ← protected by config.middlewares (e.g. authenticate)
|
|
272
|
-
│ ───────────────────────────────► │ getSubject(c) → issue one-time token
|
|
274
|
+
│ ───────────────────────────────► │ getSubject(c), getKeyId(c) → issue one-time token
|
|
273
275
|
│ ◄───────────────────────────── │ { token: "..." } (default TTL 30s)
|
|
274
276
|
│ GET /events/stream?token=…&events=…
|
|
275
|
-
│ ───────────────────────────────► │
|
|
277
|
+
│ ───────────────────────────────► │ consume token (one-time) → verifyKey(keyId) when tagged
|
|
276
278
|
│ │ → authorize(subject, events)
|
|
277
279
|
│ │ → resolvePrincipal(subject, request) (once)
|
|
278
280
|
│ │ → event.canSubscribe(ctx) (once per event)
|
|
@@ -295,7 +297,7 @@ export default defineServerConfig()
|
|
|
295
297
|
auth: { enabled: true },
|
|
296
298
|
})
|
|
297
299
|
.build();
|
|
298
|
-
// → POST /events/token (mints token; subject = c.get('auth').userId by default)
|
|
300
|
+
// → POST /events/token (mints token; subject = c.get('auth').userId, keyId = c.get('auth').keyId by default)
|
|
299
301
|
// → GET /events/stream?token=…&events=…
|
|
300
302
|
```
|
|
301
303
|
|
|
@@ -359,6 +361,54 @@ export default defineServerConfig()
|
|
|
359
361
|
```
|
|
360
362
|
|
|
361
363
|
When `tokenManager` is provided, `store`/`tokenTtl` are ignored (the external manager owns them).
|
|
364
|
+
`@spfn/auth`'s manager also checks the device key at consume (`verifyKey`, below), so sharing it
|
|
365
|
+
is what makes a token minted before a sign-out useless after it.
|
|
366
|
+
|
|
367
|
+
### Closing the streams of a revoked device key
|
|
368
|
+
|
|
369
|
+
A stream outlives the token that opened it: the token is checked once, at open, and pings keep
|
|
370
|
+
the stream alive. So the token carries the **device key** it was minted for, and revoking that
|
|
371
|
+
key ends what it opened.
|
|
372
|
+
|
|
373
|
+
- **The token is tagged.** The token endpoint passes `getKeyId(c)` (default
|
|
374
|
+
`c.get('auth')?.keyId`; an app without `auth` is fine) to `issue(subject, { keyId })`. An
|
|
375
|
+
empty, `null` or `undefined` key leaves the token untagged — such a stream is never closed by
|
|
376
|
+
key. A non-device value (`'service'`) is stored as given; nothing asks to close it.
|
|
377
|
+
- **A token minted before the revocation is refused at open.** `SSETokenManagerConfig.verifyKey`
|
|
378
|
+
runs when a tagged token is consumed; `false` or a throw refuses it (`401`). The token is
|
|
379
|
+
consumed first, so a refused token is never retried. The throw is warned without its text.
|
|
380
|
+
- **`closeSSEConnectionsByKey(keyId)` ends the open ones.** It closes, on this process, every
|
|
381
|
+
stream whose token carried `keyId` — every `createSSEHandler` of the process, not one — and a
|
|
382
|
+
connect of that key still in admission ends at open, with no `connected` frame and no
|
|
383
|
+
subscription. With a cache (resolved lazily from `getCache()`, so a pod serving no events
|
|
384
|
+
publishes too) it then publishes `{ kind: 'close', keyId }` on the same control channel as
|
|
385
|
+
`grant` / `revoke`, `<channelPrefix>$control`
|
|
386
|
+
([Multi-instance broadcast](#multi-instance-broadcast-cross-pod-fan-out)), so every other pod
|
|
387
|
+
whose events are wired through the cache closes its own — whatever events it serves; a pod
|
|
388
|
+
skips its own echo, and a close without a string `keyId` is malformed and ignored. A failed publish is warned and swallowed: the promise
|
|
389
|
+
always resolves. A close this pod cannot publish while the transport's breaker is open, or
|
|
390
|
+
whose publish fails, closes this pod's streams at once and is re-sent when the breaker
|
|
391
|
+
cooldown ends and whenever the next publish succeeds; while it is pending, the key's streams on
|
|
392
|
+
other pods stay open. A held close is lost only when the Redis outage outlasts this pod (its
|
|
393
|
+
shutdown disposes held closes) or when more than 1000 closes are held (the oldest are dropped);
|
|
394
|
+
those streams then stay open until they disconnect, and their next token mint is refused. At most 1000 closes wait; past
|
|
395
|
+
that the oldest is dropped, with a warning that gives the count. The log says how many closed and why — never the key id.
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
import { closeSSEConnectionsByKey } from '@spfn/core/event/sse';
|
|
399
|
+
import { onAfterCommit } from '@spfn/core/db';
|
|
400
|
+
|
|
401
|
+
// after your own revocation statement
|
|
402
|
+
onAfterCommit(() => closeSSEConnectionsByKey(revokedKeyId));
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`@spfn/auth` does this for every key revocation (sign out one device, sign out everywhere,
|
|
406
|
+
logout, password change and reset, account deletion, rotation, renewal, re-login): an app on
|
|
407
|
+
`tokenManager: () => getOneTimeTokenManager()` gets it with no configuration change. The client
|
|
408
|
+
whose stream closed reconnects within `reconnectDelay`; its token mint then answers `401` and
|
|
409
|
+
it stops (`onClose('unauthorized')`, see the [client](#createauthsseclientconfig--with-auth-recommended)).
|
|
410
|
+
A device whose key was *replaced* (rotation, renewal) reconnects with the new key, which the
|
|
411
|
+
response installed meanwhile.
|
|
362
412
|
|
|
363
413
|
### Authorization hooks
|
|
364
414
|
|
|
@@ -553,6 +603,7 @@ client.subscribe({
|
|
|
553
603
|
| `store` | `SSETokenStore` | auto (Cache → InMemory) | token storage backend |
|
|
554
604
|
| `tokenManager` | `SSETokenManager \| () => SSETokenManager` | — | reuse an external manager (lazy resolver recommended); overrides `store`/`tokenTtl` |
|
|
555
605
|
| `getSubject` | `(c: Context) => string \| null` | `c.get('auth')?.userId ?? null` | extract subject on the token endpoint |
|
|
606
|
+
| `getKeyId` | `(c: Context) => string \| null \| undefined` | `c.get('auth')?.keyId` | the device key the token is tagged with; empty → untagged ([revoked keys](#closing-the-streams-of-a-revoked-device-key)) |
|
|
556
607
|
| `authorize` | `(subject, events[]) => events[] \| Promise<…>` | — | subscription authorization (once on connect) |
|
|
557
608
|
| `filter` | `{ [event]?: (subject, payload) => boolean }` | — | per-event payload filter |
|
|
558
609
|
| `resolvePrincipal` | `(subject, request) => unknown \| Promise<unknown>` | — | principal for every `canSubscribe` (once per connection); a throw ⇒ `403` |
|
|
@@ -659,6 +710,13 @@ export const { GET, POST } = createRpcProxy({
|
|
|
659
710
|
});
|
|
660
711
|
```
|
|
661
712
|
|
|
713
|
+
**A `401` from the token mint is final.** When `acquireToken` rejects with an error whose
|
|
714
|
+
`statusCode` (a registered class such as `UnauthorizedError`) or `status` (the api client's
|
|
715
|
+
`ApiError`, and `createAuthSSEClient`'s own error) is `401`, the client closes the
|
|
716
|
+
subscription and calls `onClose('unauthorized')` once, instead of reconnecting — the session
|
|
717
|
+
is gone (its device key was revoked), and retrying would mint and fail every `reconnectDelay`
|
|
718
|
+
forever. Any other rejection reconnects as before. `onClose` stays assignable from `() => void`.
|
|
719
|
+
|
|
662
720
|
### `subscribeToEvents(events, handlers, config?)` — one-shot helper
|
|
663
721
|
|
|
664
722
|
```typescript
|
|
@@ -742,7 +800,8 @@ payload can't serialize, the event is **still delivered to this pod's own subscr
|
|
|
742
800
|
asymmetric case where the publish succeeds but **this pod's subscriber socket is down** (its
|
|
743
801
|
echo — the only path to same-pod streams — wouldn't arrive). A sustained publish outage trips
|
|
744
802
|
a short circuit breaker so emits fast-path to local instead of each paying the publish timeout.
|
|
745
|
-
A publish never crashes `emit` or kills a stream.
|
|
803
|
+
A publish never crashes `emit` or kills a stream. Events are not re-sent; a key close is (see
|
|
804
|
+
`closeSSEConnectionsByKey`). ioredis auto-reconnects and re-subscribes
|
|
746
805
|
(autoResubscribe, default true). A per-event SUBSCRIBE failure at
|
|
747
806
|
startup degrades that event to in-process (logged) rather than aborting boot. Same-pod
|
|
748
807
|
delivery costs one Redis round-trip — for a single-replica deploy with a cache, prefer
|
|
@@ -848,7 +907,8 @@ fully decoupled.
|
|
|
848
907
|
this: on error with `acquireToken` set, it closes and reconnects through its own path to
|
|
849
908
|
mint a fresh token. Don't disable `reconnect` expecting native retry to work with auth.
|
|
850
909
|
- **Stream-time errors aren't subscription errors.** `authorize` returning `[]` ⇒ `403` at
|
|
851
|
-
connect; invalid event names ⇒ `400`; missing/expired token
|
|
910
|
+
connect; invalid event names ⇒ `400`; missing/expired token, or one whose key `verifyKey`
|
|
911
|
+
refuses ⇒ `401`. `onError` on the
|
|
852
912
|
client fires for transport-level drops, not for these HTTP rejections — check the network
|
|
853
913
|
response when a connection never opens.
|
|
854
914
|
- **Subject default.** Without a custom `getSubject`, the token endpoint reads
|
package/src/state/README.md
CHANGED
|
@@ -608,7 +608,7 @@ export function Shell({ initial, workspaceId }: { initial: StateClientInitial<ty
|
|
|
608
608
|
| `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 |
|
|
609
609
|
| `connection()` | Synchronous: the connection status ([Connection status](#connection-status)), a frozen object, the same reference until it changes |
|
|
610
610
|
| `subscribeConnection(listener)` | Registers `listener(status)`, called with the new status on every change, after `connection()` returns it; returns its unsubscribe (idempotent). The same function subscribed twice is two subscriptions |
|
|
611
|
-
| `retry()` | When `'failed'` or `'
|
|
611
|
+
| `retry()` | When `'failed'`, `'reconnecting'` or `'closed'` (unauthorized — after the user signed in again): reads the first snapshot now (its retry count reset), or, once the manifest is known, reopens the stream now. A no-op when `'connected'`, while the first snapshot is in flight, and with nothing subscribed — after `close()` too, until a new `subscribe` |
|
|
612
612
|
| `close()` | Closes the stream, drops every listener — connection listeners too — and stops listening to `online`; the status is back to `'reconnecting'` 0. Idempotent. A `subscribe` after it starts the client again, without `online`: `retry()` restarts it as before |
|
|
613
613
|
|
|
614
614
|
`onError` receives a failure of something the client ran on its own — the first snapshot, the
|
|
@@ -695,6 +695,7 @@ first snapshot — so it says where they stand. `connection()` is one of:
|
|
|
695
695
|
| `{ status: 'connected' }` | a stream is open (the SSE `onOpen`) |
|
|
696
696
|
| `{ status: 'reconnecting', attempt }` | no stream open — not yet, or nothing subscribed — or the SSE client retrying a dropped one: `attempt` is its count (`onReconnect`), reset when a stream opens; `0` before the first stream opens, while the first snapshot is read or retried, and after `retry()` |
|
|
697
697
|
| `{ status: 'failed' }` | the retries are exhausted: the SSE client's `maxReconnectAttempts` (its `onClose`, the attempts spent), or `maxRetries` for the first snapshot. Nothing more is tried until a restart (below) or, with the manifest unknown, a `get`, `write` or `snapshot` that brings it |
|
|
698
|
+
| `{ status: 'closed', reason: 'unauthorized' }` | the stream's token mint answered `401` (the SSE client's `onClose('unauthorized')`): the session is gone — its device key was revoked, e.g. "sign out this device" from another one. No retry is set, and neither `online` nor a `subscribe` reopens it; only the app's own `retry()`, once the user signed in again, does |
|
|
698
699
|
|
|
699
700
|
- **Start value** `{ status: 'reconnecting', attempt: 0 }`, a client built with `initial` too:
|
|
700
701
|
its entries are ready, its stream is not.
|
|
@@ -715,10 +716,17 @@ first snapshot — so it says where they stand. `connection()` is one of:
|
|
|
715
716
|
- **`close()`** sets `'reconnecting'` 0, drops the connection listeners and removes the
|
|
716
717
|
`online` listener; its own teardown is not `'failed'`. A `subscribe` after it starts the
|
|
717
718
|
client again, and `retry()` restarts it; `online` is not listened to again.
|
|
719
|
+
- **`'closed'` / `'unauthorized'`** is not a network failure, so nothing restarts it on its
|
|
720
|
+
own: not `online`, not a `subscribe`. `retry()` reopens the stream (`'reconnecting'` 0); the
|
|
721
|
+
last unsubscribe resets the client to the start value, as from any status.
|
|
718
722
|
|
|
719
723
|
```tsx
|
|
720
724
|
const connection = useConnectionStatus(state);
|
|
721
725
|
|
|
726
|
+
if (connection.status === 'closed')
|
|
727
|
+
{
|
|
728
|
+
return <Banner>You were signed out on this device.</Banner>; // connection.reason === 'unauthorized'
|
|
729
|
+
}
|
|
722
730
|
if (connection.status === 'failed')
|
|
723
731
|
{
|
|
724
732
|
return <Banner action={() => state.retry()}>The server has a problem.</Banner>;
|