@spfn/core 0.3.0-beta.27 → 0.3.0-beta.29
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/codegen/index.d.ts +65 -2
- package/dist/codegen/index.js +160 -15
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +2 -2
- package/dist/db/index.d.ts +39 -11
- package/dist/db/index.js +34 -14
- package/dist/db/index.js.map +1 -1
- package/dist/{error-TsUIjp_x.d.ts → error-CIZlYJlL.d.ts} +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/index.js +10 -0
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +27 -17
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +3 -3
- package/dist/event/sse/index.js +115 -34
- 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/ops/index.d.ts +3 -3
- package/dist/ops/index.js +15 -5
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +2 -2
- package/dist/route/index.js +15 -5
- package/dist/route/index.js.map +1 -1
- package/dist/{router-4YoIy5fN.d.ts → router-C57y-vz1.d.ts} +12 -0
- package/dist/{router-D5B0cpVN.d.ts → router-CX5uVjmU.d.ts} +29 -1
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +130 -39
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +73 -11
- package/dist/state/client.js +231 -31
- package/dist/state/client.js.map +1 -1
- package/dist/state/index.js +19 -4
- package/dist/state/index.js.map +1 -1
- package/dist/state/react.d.ts +18 -3
- package/dist/state/react.js +11 -5
- package/dist/state/react.js.map +1 -1
- package/dist/surface/index.d.ts +3 -3
- package/dist/surface/index.js +15 -5
- package/dist/surface/index.js.map +1 -1
- package/dist/{types-04gu3uMj.d.ts → types-CurMab2Q.d.ts} +19 -2
- package/dist/{types-CqTsDETV.d.ts → types-DncqXfWN.d.ts} +1 -1
- package/package.json +1 -1
- package/src/codegen/README.md +89 -2
- package/src/db/transaction/README.md +58 -35
- package/src/event/README.md +61 -14
- package/src/state/README.md +228 -35
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context } from 'hono';
|
|
2
|
-
import { a as EventDef,
|
|
2
|
+
import { a as EventDef, o as EventRouterBase, I as InferEventNames, i as InferEventPayload } from './router-CX5uVjmU.js';
|
|
3
3
|
import { c as SSETokenStore, S as SSETokenManager } from './token-manager-Ckjt2-qq.js';
|
|
4
4
|
|
|
5
5
|
/**
|
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.29",
|
|
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/codegen/README.md
CHANGED
|
@@ -52,14 +52,16 @@ Types:
|
|
|
52
52
|
- `OrchestratorOptions`
|
|
53
53
|
- `RouteMapGeneratorConfig` (config shape for the built-in route-map generator)
|
|
54
54
|
- `ContractGeneratorConfig` (config shape for the built-in contract generator)
|
|
55
|
-
- `
|
|
55
|
+
- `StatesGeneratorConfig` (config shape for the built-in states generator)
|
|
56
|
+
- `ContractGeneratorError`, `StatesGeneratorError`, `ConditionalRegistrationError`,
|
|
57
|
+
`assertUnconditionalRegistration`
|
|
56
58
|
- `RouteContractMapping`, `ResourceRoutes`, `ClientGenerationOptions`, `GenerationStats`
|
|
57
59
|
(legacy client-generation types — exported but not used by any shipped generator)
|
|
58
60
|
|
|
59
61
|
> **Renamed: `defineCodegenConfig` → `defineConfig`.** The old name does **not** exist.
|
|
60
62
|
> Older docs also show an `api-client` / `createApi`-emitting generator and
|
|
61
63
|
> `codegen.config.ts` — those are **removed**. The built-in generators today are
|
|
62
|
-
> `@spfn/core:route-map` and `@spfn/core:
|
|
64
|
+
> `@spfn/core:route-map`, `@spfn/core:contract` and `@spfn/core:states`, configured in `.spfnrc.ts`. Do not
|
|
63
65
|
> import `defineCodegenConfig` or configure a `name: 'api-client'` generator.
|
|
64
66
|
|
|
65
67
|
---
|
|
@@ -320,6 +322,83 @@ make the feature unusable.
|
|
|
320
322
|
|
|
321
323
|
---
|
|
322
324
|
|
|
325
|
+
## Built-in: `@spfn/core:states`
|
|
326
|
+
|
|
327
|
+
Loads your router and emits the ids and the manifest of every state it mounts —
|
|
328
|
+
`src/generated/states.ts`, a file with no import that a browser root and a server component
|
|
329
|
+
can both execute. `createStateClient<typeof states>` is a type import, erased at build, and
|
|
330
|
+
importing the router itself is not an option (each state's `read()` closes over repositories
|
|
331
|
+
and the database), so this file is the only runtime record of which states the app declared.
|
|
332
|
+
Compare its `stateManifest` with the `manifest` of `GET /state`: an id the server's manifest
|
|
333
|
+
lacks is a deploy skew between the Next.js server and the API, reported at the root instead
|
|
334
|
+
of surfacing as `state.<id>` being `undefined` inside a screen. See
|
|
335
|
+
[`../state/README.md`](../state/README.md) → "Declared ids at runtime".
|
|
336
|
+
|
|
337
|
+
### `StatesGeneratorConfig`
|
|
338
|
+
|
|
339
|
+
| Field | Type | Required | Default | Description |
|
|
340
|
+
|-------|------|----------|---------|-------------|
|
|
341
|
+
| `name` | `'@spfn/core:states'` | yes | — | Generator identifier (literal). |
|
|
342
|
+
| `routerPath` | `string` | yes | — | Router file, relative to project root — the one `route-map` reads. Throws at construction if missing. |
|
|
343
|
+
| `outputPath` | `string` | no | `'./src/generated/states.ts'` | Where the file is written (parent dirs auto-created). |
|
|
344
|
+
|
|
345
|
+
### What it reads
|
|
346
|
+
|
|
347
|
+
The router module is loaded exactly as `route-map` loads it (`appRouter` → `default` →
|
|
348
|
+
`router`, tsconfig aliases, `NODE_ENV` pinned), then walked:
|
|
349
|
+
|
|
350
|
+
- **The marker.** `defineStateRouter` sets a non-enumerable `Router._stateManifest` on the
|
|
351
|
+
router it derives — the very object the snapshot route answers as `manifest` — so the ids
|
|
352
|
+
written here and the manifest the server serves come from one value. Each state router
|
|
353
|
+
contributes its entries in declaration order; `key` lists the key fields in schema order.
|
|
354
|
+
- **Mounted routers at every depth.** Every nested router under `routes` and every entry of
|
|
355
|
+
`_packageRouters`, recursively — app `routes` first, then `_packageRouters` in mount order.
|
|
356
|
+
A router reached by two trails (`.packages()` copies a mounted router's own package routers
|
|
357
|
+
up) is collected once: the walk is keyed on the router object.
|
|
358
|
+
- **Chained routers.** `.use()`, `.packages()` and `.contractVersion()` carry the marker onto
|
|
359
|
+
the router they rebuild, so `states.router.use([audit])` is still found.
|
|
360
|
+
- An id that is not a JS identifier is emitted quoted, through the same key rule `route-map`
|
|
361
|
+
uses.
|
|
362
|
+
|
|
363
|
+
### When it refuses
|
|
364
|
+
|
|
365
|
+
Throws `StatesGeneratorError` (or the loader's and the guard's errors) — logged on `watch`,
|
|
366
|
+
fatal on `build` and on `spfn codegen run`:
|
|
367
|
+
|
|
368
|
+
- The module will not load, or no router is found under the three export names.
|
|
369
|
+
- The router registers **conditionally** — same `ConditionalRegistrationError` guard as
|
|
370
|
+
`route-map`.
|
|
371
|
+
- **Two state routers declare the same id.** The message names both trails.
|
|
372
|
+
- **No state router is mounted** anywhere in the walk. The generator is configured but nothing
|
|
373
|
+
it reads mounts a `defineStateRouter(...).router`; the message names the router path. Mount
|
|
374
|
+
the state router or drop the generator.
|
|
375
|
+
|
|
376
|
+
A mounted state router that declares **no state** — `defineStateRouter({})` — is not refused: it
|
|
377
|
+
is an app that has declared none yet. The file is written with `stateIds = [] as const` and an
|
|
378
|
+
empty `stateManifest`, which matches the empty manifest the server serves.
|
|
379
|
+
|
|
380
|
+
A **missing router file** is only a warning: the generator returns and writes nothing.
|
|
381
|
+
|
|
382
|
+
### Generated output
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
// src/generated/states.ts — DO NOT EDIT (header names the NODE_ENV it was written under)
|
|
386
|
+
export const stateIds = ['consent', 'member', 'workspaces'] as const;
|
|
387
|
+
|
|
388
|
+
export type StateId = (typeof stateIds)[number];
|
|
389
|
+
|
|
390
|
+
export const stateManifest: Readonly<Record<StateId, { readonly key: readonly string[]; readonly write: boolean }>> = {
|
|
391
|
+
consent: { key: [], write: true },
|
|
392
|
+
member: { key: ['workspaceId'], write: false },
|
|
393
|
+
workspaces: { key: [], write: false },
|
|
394
|
+
};
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`watchPatterns` are `[routerPath, 'src/server/states.ts', 'src/server/states/**/*.ts',
|
|
398
|
+
'src/server/routes/**/*.ts']` and `runOn` is `['watch', 'manual', 'build']`.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
323
402
|
## CLI
|
|
324
403
|
|
|
325
404
|
Provided by the `spfn` CLI (`@spfn/cli`), not by `@spfn/core` itself:
|
|
@@ -625,10 +704,18 @@ interface RouteMapGeneratorConfig
|
|
|
625
704
|
outputPath?: string; // default './src/generated/route-map.ts'
|
|
626
705
|
additionalRouteDirs?: string[]; // deprecated: watched, never collected from
|
|
627
706
|
}
|
|
707
|
+
|
|
708
|
+
interface StatesGeneratorConfig
|
|
709
|
+
{
|
|
710
|
+
name: '@spfn/core:states';
|
|
711
|
+
routerPath: string;
|
|
712
|
+
outputPath?: string; // default './src/generated/states.ts'
|
|
713
|
+
}
|
|
628
714
|
```
|
|
629
715
|
|
|
630
716
|
## Related
|
|
631
717
|
|
|
632
718
|
- [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the router loaded and walked)
|
|
633
719
|
- [@spfn/core/nextjs](../nextjs/README.md) — RPC proxy (`createRpcProxy`) that consumes `routeMap`
|
|
720
|
+
- [@spfn/core/state](../state/README.md) — `defineStateRouter`, whose manifest `@spfn/core:states` writes
|
|
634
721
|
- [@spfn/core/env](../env/README.md) — environment configuration
|
|
@@ -44,7 +44,7 @@ From `@spfn/core/db`:
|
|
|
44
44
|
- `runInTransaction(callback, options?)` — run a callback in a transaction (scripts/CLI; no Hono).
|
|
45
45
|
- `onBeforeCommit(callback)` — run work inside the root transaction, just before it commits.
|
|
46
46
|
- `onAfterCommit(callback)` — defer a side effect until after the root transaction commits.
|
|
47
|
-
- `onAfterRollback(callback)` — compensate after the
|
|
47
|
+
- `onAfterRollback(callback)` — compensate after the work it was registered with rolled back.
|
|
48
48
|
- `getTransaction()` — current transaction `TransactionDB`, or `null`.
|
|
49
49
|
- `getTransactionContext()` — current `TransactionContext` (`tx`, `txId`, `level`, callbacks), or `null`.
|
|
50
50
|
- `runWithTransaction(tx, txId, callback)` — bind a `tx` into `AsyncLocalStorage` for a callback.
|
|
@@ -305,9 +305,12 @@ Behavior to know:
|
|
|
305
305
|
against a row the outer transaction locked.
|
|
306
306
|
- **One commit.** Nested writes are durable only when the **root** commits. There is no
|
|
307
307
|
intermediate commit at a savepoint boundary.
|
|
308
|
-
- The inner level
|
|
309
|
-
|
|
310
|
-
|
|
308
|
+
- The inner level has **hook queues of its own**: a hook belongs to the savepoint that
|
|
309
|
+
registered it. A savepoint that resolves hands its hooks to its parent; one that rolls
|
|
310
|
+
back runs its `onAfterRollback` callbacks right there — inside the **parent's** context,
|
|
311
|
+
the root still open — and drops its `onBeforeCommit` / `onAfterCommit` callbacks, even
|
|
312
|
+
when the root goes on to commit. See
|
|
313
|
+
[Hooks belong to the frame that registered them](#hooks-belong-to-the-frame-that-registered-them).
|
|
311
314
|
- **One at a time.** Nested calls made off the same transaction are serialized: a frame
|
|
312
315
|
opens only after the previous frame on that connection has closed. See
|
|
313
316
|
[Concurrent nested calls](#concurrent-nested-calls-are-serialized).
|
|
@@ -453,23 +456,25 @@ logging the SQL it emits — worth knowing before nesting deeply or in a loop:
|
|
|
453
456
|
|
|
454
457
|
## Transaction hooks
|
|
455
458
|
|
|
456
|
-
Three hooks attach work to
|
|
459
|
+
Three hooks attach work to a transaction's lifecycle. All three take
|
|
457
460
|
`() => void | Promise<void>`, are registered the same way from anywhere inside the async
|
|
458
|
-
call chain, and queue on the root
|
|
459
|
-
`AfterRollbackCallback` are the exported
|
|
461
|
+
call chain, and queue on the **frame** — root or savepoint — they were registered in
|
|
462
|
+
(`BeforeCommitCallback`, `AfterCommitCallback`, `AfterRollbackCallback` are the exported
|
|
463
|
+
type aliases). A frame's queues reach the root only through savepoints that resolved; see
|
|
464
|
+
[Hooks belong to the frame that registered them](#hooks-belong-to-the-frame-that-registered-them).
|
|
460
465
|
|
|
461
466
|
| Hook | Runs | Still inside the tx? | If the callback throws |
|
|
462
467
|
|------|------|----------------------|------------------------|
|
|
463
468
|
| `onBeforeCommit(cb)` | after the root callback resolves, **before** `COMMIT` | **yes** — may run statements | aborts: later callbacks skipped, transaction rolls back, error propagates, `afterRollback` fires |
|
|
464
469
|
| `onAfterCommit(cb)` | after the root transaction committed | no | logged, never thrown |
|
|
465
|
-
| `onAfterRollback(cb)` | after the root transaction rolled back | no | logged, never thrown; the **original** error keeps propagating unchanged |
|
|
470
|
+
| `onAfterRollback(cb)` | after the root transaction rolled back — or, registered in a savepoint that rolled back, right after that savepoint did | no after a root rollback; after a savepoint rollback it runs inside the **parent's** still-open transaction | logged, never thrown; the **original** error keeps propagating unchanged |
|
|
466
471
|
|
|
467
472
|
Registration context:
|
|
468
473
|
|
|
469
474
|
| Context | `onBeforeCommit` | `onAfterCommit` | `onAfterRollback` |
|
|
470
475
|
|---------|------------------|-----------------|-------------------|
|
|
471
476
|
| Inside root transaction | Queued; runs before the root's commit | Queued; runs after the root commits | Queued; runs if the root rolls back |
|
|
472
|
-
| Inside a nested transaction | Queued on the
|
|
477
|
+
| Inside a nested transaction | Queued on the savepoint; passed to the parent if it resolves, dropped if it rolls back | Queued on the savepoint; passed to the parent if it resolves, dropped if it rolls back | Queued on the savepoint; passed to the parent if it resolves, **run at once** if it rolls back |
|
|
473
478
|
| Outside any transaction | Runs immediately on a microtask **+ `warn` log** — nothing left to commit, so a throw aborts nothing | Runs immediately on a microtask (already "committed") | **No-op + `warn` log** — there is no rollback to wait for |
|
|
474
479
|
|
|
475
480
|
### `onBeforeCommit(callback)`
|
|
@@ -567,11 +572,16 @@ async function importAvatar(userId: string, file: Blob)
|
|
|
567
572
|
|
|
568
573
|
- Fires on **any** root rollback: a thrown handler error, a thrown `onBeforeCommit`
|
|
569
574
|
callback, a statement timeout, a constraint violation.
|
|
570
|
-
-
|
|
571
|
-
the
|
|
575
|
+
- Registered inside a savepoint, it fires when **that savepoint** rolls back — at that
|
|
576
|
+
moment, whatever the root does afterwards — and not again at the root.
|
|
577
|
+
- After a **root** rollback, callbacks run **outside** the transaction context
|
|
578
|
+
(`getTransaction()` is `null`) — DB work in them uses a fresh connection, like
|
|
579
|
+
`onAfterCommit`. After a **savepoint** rollback the root is still open and they run
|
|
580
|
+
inside the **parent's** context: `getTransaction()` is the parent's `tx`, and DB work in
|
|
581
|
+
them joins the parent transaction — it commits only if the parent does.
|
|
572
582
|
- They are **awaited**, in registration order, before the causing error leaves
|
|
573
|
-
`runInTransaction` / the middleware
|
|
574
|
-
them short.
|
|
583
|
+
`runInTransaction` / the middleware (for a savepoint: the nested call). A callback that
|
|
584
|
+
hangs delays that error, so keep them short.
|
|
575
585
|
- Errors are logged and swallowed: a failing callback never replaces the error that caused
|
|
576
586
|
the rollback, never stops the remaining callbacks, and neither does a failure of the log
|
|
577
587
|
call itself.
|
|
@@ -580,28 +590,41 @@ async function importAvatar(userId: string, file: Blob)
|
|
|
580
590
|
to the client, and these callbacks fire — so a compensation should be idempotent and
|
|
581
591
|
safe to run against data that did, in the end, land.
|
|
582
592
|
|
|
583
|
-
### Hooks
|
|
584
|
-
|
|
585
|
-
Every
|
|
586
|
-
decides
|
|
587
|
-
|
|
588
|
-
- A
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
- **
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
593
|
+
### Hooks belong to the frame that registered them
|
|
594
|
+
|
|
595
|
+
Every frame — the root and each savepoint — has queues of its own, and its **own** fate
|
|
596
|
+
decides what happens to the hooks registered in it:
|
|
597
|
+
|
|
598
|
+
- **A savepoint that resolves** appends its three queues to its parent's, after what the
|
|
599
|
+
parent registered before the nested call and before what it registers after. Its hooks
|
|
600
|
+
now share the parent's fate: `onBeforeCommit` still runs before the **root's** `COMMIT`
|
|
601
|
+
(not at the savepoint — the driver never releases one), `onAfterCommit` after the root
|
|
602
|
+
commits, `onAfterRollback` if the root rolls back. Promotion repeats level by level, so a
|
|
603
|
+
hook fires exactly once, and only if every savepoint between it and the root resolved.
|
|
604
|
+
- **A savepoint that rolls back** drops its `onBeforeCommit` and `onAfterCommit`
|
|
605
|
+
callbacks — the work they guard or announce is gone — and runs its `onAfterRollback`
|
|
606
|
+
callbacks immediately, awaited before the error leaves the nested call, inside the
|
|
607
|
+
parent's still-open transaction. Hooks promoted into it from savepoints nested inside it
|
|
608
|
+
go the same way. A compensation may call `runInTransaction` itself: that opens a savepoint
|
|
609
|
+
on the parent, and a hook the compensation registers belongs to the parent.
|
|
610
|
+
- **A statement that fails inside a savepoint's `onAfterRollback` aborts the parent.** The
|
|
611
|
+
root-rollback pass runs outside any transaction, so a failing statement there touches
|
|
612
|
+
nothing; the savepoint pass runs inside the parent's transaction, so a failing statement
|
|
613
|
+
puts it into the aborted state (`25P02`). The callback's error is logged and swallowed as
|
|
614
|
+
always, but the root's next statement then fails with `25P02` (current transaction is
|
|
615
|
+
aborted). If the root runs none, its `COMMIT` fails with the callback's error. Either way,
|
|
616
|
+
everything the root wrote rolls back. Wrap DB work in a compensation in its own
|
|
617
|
+
`runInTransaction` (a savepoint) when it may fail.
|
|
618
|
+
- **The root** runs whatever reached it. A savepoint failure the root catches and survives
|
|
619
|
+
fires none of the **root's** own `afterRollback` callbacks: the root did not roll back.
|
|
620
|
+
- A `requiresNew: true` call is a root on its own connection: hooks registered inside it
|
|
621
|
+
belong to it and fire on its own commit or rollback, never handed to the outer
|
|
622
|
+
transaction.
|
|
623
|
+
- **A hook registered after its frame has settled is dropped**, the same as one registered
|
|
624
|
+
after a root has settled. A frame's queues are handed on (or discarded) once, at the
|
|
625
|
+
moment it settles; detached work it started — a `setTimeout`, an un-awaited promise —
|
|
626
|
+
that registers a hook later appends to a queue nobody reads again. Register hooks in the
|
|
627
|
+
awaited call chain of the frame they belong to.
|
|
605
628
|
|
|
606
629
|
### Why there is no `onBeforeRollback`
|
|
607
630
|
|
package/src/event/README.md
CHANGED
|
@@ -45,12 +45,13 @@ From `@spfn/core/event`:
|
|
|
45
45
|
- `.contract({ since, deprecatedIn?, removedIn? })` on an event and `.contract({ auth, streamPath? })`
|
|
46
46
|
on an event router — publish the event in `contracts/current.json` (see
|
|
47
47
|
[Contracted events](#contracted-events-for-separately-compiled-clients))
|
|
48
|
-
- `.canSubscribe(predicate)`, `.filter(predicate)`, `.
|
|
49
|
-
event — who may subscribe over SSE, which frames a connection receives,
|
|
50
|
-
connection (see
|
|
48
|
+
- `.canSubscribe(predicate)`, `.filter(predicate)`, `.initial(producer)`, `.grant(subject)`,
|
|
49
|
+
`.revoke(subject)` on an event — who may subscribe over SSE, which frames a connection receives,
|
|
50
|
+
the frames it receives right after `connected`, and changing an open connection (see
|
|
51
|
+
[Per-event permission](#per-event-permission-cansubscribe-filter-initial-grant--revoke))
|
|
51
52
|
- Types: `EventDef`, `EventHandler`, `EventSubscribeOptions`, `InferEventPayload`, `PubSubCache`, `JobQueueSender`,
|
|
52
53
|
`EventContract`, `EventRouterContract`, `SubscribePredicate`, `SubscribeContext`,
|
|
53
|
-
`SubscribeRequest`, `FramePredicate`, `FrameContext`,
|
|
54
|
+
`SubscribeRequest`, `FramePredicate`, `FrameContext`, `InitialProducer`,
|
|
54
55
|
`EventRouterDef`, `InferEventNames`, `InferEventPayloads`,
|
|
55
56
|
`InferRouterEventPayload` (the two-arg router variant — see note)
|
|
56
57
|
|
|
@@ -61,7 +62,7 @@ From `@spfn/core/event/sse`:
|
|
|
61
62
|
- Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEMessage`,
|
|
62
63
|
`SSEHandlerConfig`, `SSEHandlerAuthConfig`, `SSEAuthConfig`, `SSEClientConfig`,
|
|
63
64
|
`SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSESubscriptionChange`,
|
|
64
|
-
`SSEConnectionState`, `SSEUnsubscribe`
|
|
65
|
+
`SSEConnectionState`, `SSEUnsubscribe`, `ConnectedPayload`
|
|
65
66
|
|
|
66
67
|
From `@spfn/core/event/sse/client`:
|
|
67
68
|
|
|
@@ -139,6 +140,7 @@ Promise<void>` otherwise. Handlers run via `Promise.allSettled` — see
|
|
|
139
140
|
| `contract(contract)` | `(EventContract) => EventDef` | publish in the contract; returns the same event; a second call throws |
|
|
140
141
|
| `canSubscribe(predicate)` | `<P>(SubscribePredicate<P>) => EventDef` | who may subscribe over SSE (once per connection); a second call throws |
|
|
141
142
|
| `filter(predicate)` | `(FramePredicate<TPayload>) => EventDef` | which frames a connection receives (sync, per frame); a second call throws |
|
|
143
|
+
| `initial(producer)` | `<P>(InitialProducer<TPayload, P>) => EventDef` | the frames a connection receives right after `connected`, before any live frame (once per connection with a subject); a second call throws |
|
|
142
144
|
| `grant(subject, match?)` | `Promise<void>` | activate the event on the subject's open SSE connections (those matching `match`, when given) where it is denied |
|
|
143
145
|
| `revoke(subject, match?)` | `Promise<void>` | deactivate the event on the subject's open SSE connections (those matching `match`, when given) where it is allowed |
|
|
144
146
|
|
|
@@ -274,7 +276,9 @@ Client Server
|
|
|
274
276
|
│ │ → authorize(subject, events)
|
|
275
277
|
│ │ → resolvePrincipal(subject, request) (once)
|
|
276
278
|
│ │ → event.canSubscribe(ctx) (once per event)
|
|
277
|
-
│
|
|
279
|
+
│ │ → event.initial(ctx) (once per allowed event; live frames held)
|
|
280
|
+
│ ◄═══════════════════════════════ │ SSE stream opens (connected: subscribed + denied + initial)
|
|
281
|
+
│ event: <name> / data: {…} │ ← each initial frame, then the held live frames
|
|
278
282
|
│ event: <name> / data: {…} │ ← filter(subject, payload) + event.filter(payload, ctx) per emission
|
|
279
283
|
```
|
|
280
284
|
|
|
@@ -393,7 +397,7 @@ Return `false` to skip. Payload is typed per event.
|
|
|
393
397
|
})
|
|
394
398
|
```
|
|
395
399
|
|
|
396
|
-
### Per-event permission (`canSubscribe`, `filter`, `grant` / `revoke`)
|
|
400
|
+
### Per-event permission (`canSubscribe`, `filter`, `initial`, `grant` / `revoke`)
|
|
397
401
|
|
|
398
402
|
The global hooks above are one function for the whole router. When the rule belongs to one event,
|
|
399
403
|
declare it where the event is defined:
|
|
@@ -403,7 +407,8 @@ export const auditLogged = defineEvent('audit.logged', payload)
|
|
|
403
407
|
.canSubscribe<AuthPrincipal>(({ subject, principal, request }) => principal?.role === 'admin');
|
|
404
408
|
|
|
405
409
|
export const runtimeChanged = defineEvent('runtime.changed', payload)
|
|
406
|
-
.filter((payload, { subject, request }) => payload.workspaceId === request.query.workspaceId)
|
|
410
|
+
.filter((payload, { subject, request }) => payload.workspaceId === request.query.workspaceId)
|
|
411
|
+
.initial(async ({ request }) => [await runtimes.current(request.query.workspaceId)]);
|
|
407
412
|
```
|
|
408
413
|
|
|
409
414
|
- **`canSubscribe(ctx)`** — once per connection per requested event, only when the connection has a
|
|
@@ -414,11 +419,26 @@ export const runtimeChanged = defineEvent('runtime.changed', payload)
|
|
|
414
419
|
- **`filter(payload, ctx)`** — per frame, with or without a subject; it matches the payload against
|
|
415
420
|
the connect request. It runs beside the global `filter` map — both must return `true`. Sync. A
|
|
416
421
|
throw drops that frame (warn) and leaves the connection alive.
|
|
417
|
-
- **`
|
|
422
|
+
- **`initial(ctx)`** — the event's current frames for one connection, written right after the
|
|
423
|
+
`connected` frame and before any live frame, so a client that opens the stream needs no read of
|
|
424
|
+
its own. It runs once per connection **with a subject**, only for an event `canSubscribe` allowed,
|
|
425
|
+
with the same `{ subject, principal, request }`; the producers of one connection run in
|
|
426
|
+
parallel, and their frames are written in the order the events were requested. It returns an
|
|
427
|
+
array of payloads (`[]` sends nothing but still counts as served). Its frames skip both filters —
|
|
428
|
+
they were produced for this connection — so the producer must return only what this subject may
|
|
429
|
+
see: neither the global `filter` nor `.filter()` runs on its frames. They number their
|
|
430
|
+
`messageId`s like any frame. The
|
|
431
|
+
`connected` frame's `initial` lists exactly the events whose producer returned; a producer that
|
|
432
|
+
throws or rejects is logged at warn with the event name (never the error) and sends nothing,
|
|
433
|
+
and the stream stays open. While the producers run, live frames — and `grant` / `revoke` frames
|
|
434
|
+
and pings — are held, then written after the initial frames in arrival order; a `revoke` landing
|
|
435
|
+
meanwhile still stops the subscription at once. A `grant` later never runs it. A connection
|
|
436
|
+
without a subject, or with no allowed event that declares it, gets `initial: []`.
|
|
437
|
+
- **`request`** in all three is a snapshot taken once at connect: `{ query, headers }` with lowercased
|
|
418
438
|
header names — never the live Hono context. It is the connect request as sent: it holds the
|
|
419
439
|
`cookie` / `authorization` headers and the consumed one-time `token` in `query`, so the app must
|
|
420
440
|
never log the snapshot.
|
|
421
|
-
-
|
|
441
|
+
- Each is set once per event: an `EventDef` is a shared singleton, so a second call throws.
|
|
422
442
|
|
|
423
443
|
**`resolvePrincipal` — why the stream has no `AuthContext`.** The subject comes from the one-time
|
|
424
444
|
token, and the stream is mounted without the app's middleware, so there is no request auth object
|
|
@@ -441,10 +461,17 @@ resolved' }`.
|
|
|
441
461
|
kept → per frame, the global `filter` and the event's `filter`. An event `authorize` dropped is
|
|
442
462
|
never asked and counts as denied too.
|
|
443
463
|
|
|
444
|
-
**Cost.** At connect: one predicate call per requested event (plus one `resolvePrincipal`)
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
464
|
+
**Cost.** At connect: one predicate call per requested event (plus one `resolvePrincipal`), and
|
|
465
|
+
one `initial` producer call per allowed event that declares one. Per frame: sync filters only —
|
|
466
|
+
never storage. The handler sets no timeout on them: a hanging `canSubscribe` or
|
|
467
|
+
`resolvePrincipal` holds the connect open, and a hanging `initial` holds the `connected` frame and
|
|
468
|
+
every live frame behind it, so the app owns the timeout. A client that leaves meanwhile is
|
|
469
|
+
forgotten at once; the late verdict opens nothing, and late initial frames are not written. The
|
|
470
|
+
initial frames and the held live frames go through the connection's bounded queue like any frame:
|
|
471
|
+
a held frame counts against `maxQueue` from the moment it is held, together with the frames
|
|
472
|
+
already queued, and past it the connection is closed as a slow consumer — while the producers
|
|
473
|
+
still run, too — and the client reconnects. A producer's duration thus counts as the client's
|
|
474
|
+
slowness: frames that pile up while it runs count as frames the client has not read.
|
|
448
475
|
|
|
449
476
|
**`grant` / `revoke` — changing an open connection.**
|
|
450
477
|
|
|
@@ -501,6 +528,22 @@ client.subscribe({
|
|
|
501
528
|
});
|
|
502
529
|
```
|
|
503
530
|
|
|
531
|
+
The `connected` frame — always the first of a connection — carries
|
|
532
|
+
`{ subscribedEvents, deniedEvents, initial, timestamp }` (`ConnectedPayload`; `initial` is absent
|
|
533
|
+
from a server that predates `.initial()`). `onConnected` receives it on every connection, the first
|
|
534
|
+
and each reconnect, after `onOpen`:
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
client.subscribe({
|
|
538
|
+
events: ['runtimeChanged'],
|
|
539
|
+
handlers: { runtimeChanged: (p) => update(p) },
|
|
540
|
+
onConnected: ({ deniedEvents, initial }) =>
|
|
541
|
+
{
|
|
542
|
+
// initial: the events whose current frames follow this one; read the others yourself
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
504
547
|
### SSE auth config (`SSEAuthConfig<TRouter>`)
|
|
505
548
|
|
|
506
549
|
| Option | Type | Default | Description |
|
|
@@ -891,6 +934,8 @@ interface EventDef<TPayload = void> {
|
|
|
891
934
|
canSubscribe: <P = unknown>(predicate: SubscribePredicate<P>) => EventDef<TPayload>;
|
|
892
935
|
readonly _filter?: FramePredicate<TPayload>;
|
|
893
936
|
filter: (predicate: FramePredicate<TPayload>) => EventDef<TPayload>;
|
|
937
|
+
readonly _initial?: InitialProducer<TPayload, any>;
|
|
938
|
+
initial: <P = unknown>(producer: InitialProducer<TPayload, P>) => EventDef<TPayload>;
|
|
894
939
|
grant: (subject: string, match?: SubscriptionMatch) => Promise<void>;
|
|
895
940
|
revoke: (subject: string, match?: SubscriptionMatch) => Promise<void>;
|
|
896
941
|
}
|
|
@@ -901,6 +946,8 @@ interface SubscribeContext<P = unknown> { subject: string; principal: P | undefi
|
|
|
901
946
|
type SubscribePredicate<P = unknown> = (ctx: SubscribeContext<P>) => boolean | Promise<boolean>;
|
|
902
947
|
interface FrameContext { subject: string | undefined; request: SubscribeRequest; }
|
|
903
948
|
type FramePredicate<TPayload> = (payload: TPayload, ctx: FrameContext) => boolean;
|
|
949
|
+
type InitialProducer<TPayload, P = unknown> = (ctx: SubscribeContext<P>) => TPayload[] | Promise<TPayload[]>;
|
|
950
|
+
interface ConnectedPayload { subscribedEvents: string[]; deniedEvents: string[]; initial?: string[]; timestamp: number; }
|
|
904
951
|
|
|
905
952
|
interface EventContract { since: string; deprecatedIn?: string; removedIn?: string; }
|
|
906
953
|
|