@kavo/sse 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/sse-transport.d.ts +99 -0
- package/dist/sse-transport.d.ts.map +1 -0
- package/dist/sse-transport.js +315 -0
- package/dist/sse-transport.js.map +1 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @kavo/sse
|
|
2
|
+
|
|
3
|
+
The first `RealtimeTransport` implementation (`@kavo/core`, ADR-0023):
|
|
4
|
+
plain HTTP `text/event-stream`, no client or server library required —
|
|
5
|
+
SSE is one-directional, so there is no socket to open and nothing to
|
|
6
|
+
depend on beyond Node's own `http` types.
|
|
7
|
+
|
|
8
|
+
**May depend on:** `@kavo/core` only, no peer. **Never on:** `@kavo/nest`
|
|
9
|
+
or any other framework — same rule `packages/orms/*` follows.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createTransport } from "@kavo/sse";
|
|
15
|
+
import { createKavo } from "@kavo/core";
|
|
16
|
+
|
|
17
|
+
// Filled in below, after `createCrud` — the callback only runs once a
|
|
18
|
+
// subscribe request actually arrives, so the forward reference is fine.
|
|
19
|
+
let bookService: ReturnType<typeof kavo.createCrud<Book>>;
|
|
20
|
+
|
|
21
|
+
const sse = createTransport({
|
|
22
|
+
subscribableFields: (entityName) => (entityName === "Book" ? ["title", "status", "price"] : undefined),
|
|
23
|
+
// Enables subscribe-time filtering (issue #160) for an entity — omit an
|
|
24
|
+
// entry and a `filter[...]` query param on that entity is rejected with
|
|
25
|
+
// 400 rather than silently ignored. Typically `service.engine.metadata`/
|
|
26
|
+
// `service.engine.config` off the `createCrud` service already returned.
|
|
27
|
+
filterableEntities: (entityName) =>
|
|
28
|
+
entityName === "Book" ? { metadata: bookService.engine.metadata, config: bookService.engine.config } : undefined,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const kavo = createKavo({
|
|
32
|
+
infrastructure,
|
|
33
|
+
realtimeTransports: [sse],
|
|
34
|
+
defaults: {
|
|
35
|
+
realtime: { enabled: true, events: { created: true, updated: true, patched: true, deleted: true } },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
bookService = kavo.createCrud(Book);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`@kavo/sse` has **no authentication of its own** — `handleRequest` accepts
|
|
43
|
+
any subscribe request that otherwise validates. A deployment that needs to
|
|
44
|
+
gate who may open a stream does so in front of it: a reverse proxy, or the
|
|
45
|
+
host framework's own guard/middleware on the mounted route, since
|
|
46
|
+
`handleRequest` is just an ordinary `(req, res)` handler.
|
|
47
|
+
|
|
48
|
+
Mount `sse.handleRequest` on a plain Node HTTP route (or any host
|
|
49
|
+
framework's request/response — Express, Nest, Fastify's raw
|
|
50
|
+
req/res, … — since `IncomingMessage`/`ServerResponse` is what all of
|
|
51
|
+
them extend or wrap):
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
http.createServer((req, res) => {
|
|
55
|
+
if (req.url?.startsWith("/realtime")) {
|
|
56
|
+
void sse.handleRequest(req, res);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// ... the rest of the app's routing
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A client subscribes to one entity/id, or to the whole entity, with
|
|
64
|
+
`EventSource` (or any HTTP client that reads a chunked `text/event-stream`
|
|
65
|
+
body):
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// Item channel: every event for Book id 42.
|
|
69
|
+
const one = new EventSource("/realtime?channel=Book.42");
|
|
70
|
+
|
|
71
|
+
// Collection channel: every event for every Book (issue #160).
|
|
72
|
+
const all = new EventSource("/realtime?channel=Book");
|
|
73
|
+
|
|
74
|
+
// Collection channel, scoped with the same filter grammar REST uses.
|
|
75
|
+
const published = new EventSource("/realtime?channel=Book&filter[status][eq]=published");
|
|
76
|
+
|
|
77
|
+
all.addEventListener("updated", (message) => {
|
|
78
|
+
const event = JSON.parse(message.data); // RealtimeEventDto
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`channel` is required: `<entity>.<id>` for an item-level subscription, or
|
|
83
|
+
the bare `<entity>` for a collection-level one (every event for that
|
|
84
|
+
entity). A collection-channel subscribe request may add a `filter` query
|
|
85
|
+
string in the exact `filter[field][operator]=value` grammar REST list
|
|
86
|
+
requests use (doc 18) — evaluated in memory per event, no DB round trip.
|
|
87
|
+
Filtering is opt-in per entity via `filterableEntities`; an entity with no
|
|
88
|
+
entry there rejects any `filter[...]` param with `400` rather than
|
|
89
|
+
silently ignoring it. See doc 18 §4.3 for what a filtered subscriber
|
|
90
|
+
receives when a write moves a row across the filter boundary, and for the
|
|
91
|
+
unconditional `"deleted"`-event bypass.
|
|
92
|
+
|
|
93
|
+
Once `subscribableFields` is configured for an entity, it bounds every
|
|
94
|
+
outgoing `item` **unconditionally** — not only when a subscriber names
|
|
95
|
+
`fields` — the same way `allowlists.selectable`
|
|
96
|
+
bounds a REST response whether or not the caller asked for a subset. An
|
|
97
|
+
optional `fields` query param (comma-separated) narrows further within
|
|
98
|
+
that bound; a field outside it (or outside `subscribableFields`, when no
|
|
99
|
+
`fields` param is given) gets a `400` the same way `allowlists.selectable`
|
|
100
|
+
rejects an unlisted field over REST.
|
|
101
|
+
|
|
102
|
+
A connection that cannot keep up with its publish rate (the writable
|
|
103
|
+
buffer on its response exceeds `bufferLimitBytes`, default 64 KiB) is
|
|
104
|
+
closed rather than left to block delivery to every other subscriber.
|
|
105
|
+
|
|
106
|
+
## Known limitations
|
|
107
|
+
|
|
108
|
+
- **No resume-on-reconnect.** Every SSE frame carries an `id:`, but
|
|
109
|
+
nothing reads `Last-Event-ID` yet — a dropped connection means missed
|
|
110
|
+
events, not replayed ones. This matters more for SSE than it will for
|
|
111
|
+
`@kavo/websocket`: browsers' native `EventSource` **auto-reconnects by
|
|
112
|
+
default** on a dropped connection, with no application code asking for
|
|
113
|
+
it, so a client silently starts receiving only new events after a gap it
|
|
114
|
+
never signaled. Building resume via the `since` cursor is a future issue.
|
|
115
|
+
- **No multi-node fan-out.** The channel registry is one process's
|
|
116
|
+
in-memory `Map` — a subscriber connected to one instance of a
|
|
117
|
+
horizontally-scaled app never sees a write handled by another instance.
|
|
118
|
+
- **No "leave" event on an ordinary write.** A write that makes a row stop
|
|
119
|
+
matching a filtered subscriber's filter is not delivered at all — only a
|
|
120
|
+
genuine `"deleted"` reliably tells that subscriber a row is gone. See
|
|
121
|
+
doc 18 §4.3.
|
|
122
|
+
- **No filtering by which fields changed** — a subscribe-time filter
|
|
123
|
+
matches row data (`RealtimeEventDto.item`), not the write's diff
|
|
124
|
+
(`RealtimeEventDto.changed`).
|
|
125
|
+
- **No authentication or authorization.** `@kavo/sse` accepts any subscribe
|
|
126
|
+
request that otherwise validates — gating who may open a stream is the
|
|
127
|
+
host app's job (a reverse proxy, or a guard on the mounted route), and
|
|
128
|
+
row/tenant-level subscriber scoping is a future issue (`authorize`, out
|
|
129
|
+
of scope here — see `RealtimeTransport`'s own doc). A `filter` narrows
|
|
130
|
+
_which_ events a subscriber receives, not _whether_ they were authorized
|
|
131
|
+
to receive them.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,mBAAmB,GACzB,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,GAIhB,MAAM,oBAAoB,CAAC"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { EntityMetadata, RealtimeFieldSelector, RealtimeTransport, ResolvedEntityConfig } from "@kavo/core";
|
|
3
|
+
/**
|
|
4
|
+
* What a subscribe request needs to parse and validate a `filter` query
|
|
5
|
+
* string for one entity — the same two pieces `DefaultFilterParser` and
|
|
6
|
+
* `Filter.parse` already need for REST: `metadata` for column-kind-aware
|
|
7
|
+
* value coercion, `config` for the `filterable` allowlist and the
|
|
8
|
+
* `query.maxFilterDepth`/`maxInValues` limits. `@kavo/sse` has no config
|
|
9
|
+
* resolution of its own (same reason `subscribableFields` is a callback,
|
|
10
|
+
* not a lookup this package performs itself), so the host app supplies
|
|
11
|
+
* both — typically `service.engine.metadata`/`service.engine.config` off
|
|
12
|
+
* the `DefaultKavoService` `createCrud` already returned for that entity.
|
|
13
|
+
* That property is concretely typed per entity (`ResolvedEntityConfig<
|
|
14
|
+
* Book>`), while this interface — serving every entity through one
|
|
15
|
+
* callback — is not; assigning the former to the latter needs the same
|
|
16
|
+
* erasure cast `kavo.ts`'s own `catalog.register` already uses internally
|
|
17
|
+
* (`config as unknown as ResolvedEntityConfig`), not a sign of a type
|
|
18
|
+
* mismatch.
|
|
19
|
+
*/
|
|
20
|
+
export interface FilterableEntity {
|
|
21
|
+
readonly metadata: EntityMetadata;
|
|
22
|
+
readonly config: ResolvedEntityConfig;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What `handleRequest` needs to scope one subscription. `@kavo/sse` has no
|
|
26
|
+
* authentication of its own — every subscribe request that otherwise
|
|
27
|
+
* validates is accepted. A deployment that needs to gate who may open a
|
|
28
|
+
* stream does so in front of `handleRequest` (a reverse proxy, a host
|
|
29
|
+
* framework's own guard/middleware on the mounted route, …) — see
|
|
30
|
+
* `RealtimeTransport`'s doc comment on why subscriber-level access control
|
|
31
|
+
* is out of this seam entirely.
|
|
32
|
+
*/
|
|
33
|
+
export interface SseTransportOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Per-entity `RealtimeSettings.subscribableFields`, the same allowlist an
|
|
36
|
+
* app already configured via `createCrud` — this package has no config
|
|
37
|
+
* resolution of its own, so the caller supplies the lookup. A request
|
|
38
|
+
* naming a `fields` query param outside the allowlist is rejected with
|
|
39
|
+
* `400` before the stream opens, the same way `allowlists.selectable`
|
|
40
|
+
* rejects an unlisted field over REST. Returning `undefined` (including
|
|
41
|
+
* when the callback itself is omitted) means no allowlist is configured
|
|
42
|
+
* for that entity, so any requested field is accepted.
|
|
43
|
+
*
|
|
44
|
+
* Once configured, this allowlist is also enforced **unconditionally** on
|
|
45
|
+
* every outgoing `item` for that entity — not only when a subscriber
|
|
46
|
+
* names `fields` — the same way `allowlists.selectable` bounds a REST
|
|
47
|
+
* response whether or not the caller asked for a subset. A `fields`
|
|
48
|
+
* query param narrows further *within* that bound; it can never widen
|
|
49
|
+
* past it.
|
|
50
|
+
*/
|
|
51
|
+
subscribableFields?(entityName: string): RealtimeFieldSelector | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Enables subscribe-time filtering (`filter[field][operator]=value`,
|
|
54
|
+
* issue #160) for one entity. Returning `undefined` (including when the
|
|
55
|
+
* callback itself is omitted) means that entity does not support
|
|
56
|
+
* subscribe-time filtering — a request naming any `filter[...]`/`filter`
|
|
57
|
+
* query param for it is rejected with `400` before the stream opens,
|
|
58
|
+
* rather than silently ignoring the filter and delivering everything.
|
|
59
|
+
*/
|
|
60
|
+
filterableEntities?(entityName: string): FilterableEntity | undefined;
|
|
61
|
+
/** See `DEFAULT_BUFFER_LIMIT_BYTES`. */
|
|
62
|
+
bufferLimitBytes?: number;
|
|
63
|
+
}
|
|
64
|
+
/** A `RealtimeTransport` plus the HTTP entry point a host wires a route to. */
|
|
65
|
+
export interface SseTransport extends RealtimeTransport {
|
|
66
|
+
/**
|
|
67
|
+
* Handles one incoming SSE subscribe request:
|
|
68
|
+
* `GET ?channel=<entity>.<id>` (item channel) or `GET ?channel=<entity>`
|
|
69
|
+
* (collection channel, issue #160) with `Accept: text/event-stream`.
|
|
70
|
+
* Host-framework-agnostic — takes Node's own
|
|
71
|
+
* `IncomingMessage`/`ServerResponse`, which every Node HTTP framework's
|
|
72
|
+
* request/response either extends or wraps directly. Resolves once the
|
|
73
|
+
* response is settled (an error status was written, or the stream was
|
|
74
|
+
* opened) — the connection itself then lives for as long as the client
|
|
75
|
+
* keeps it open, torn down by the request's own `close`.
|
|
76
|
+
*/
|
|
77
|
+
handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
78
|
+
/** Number of currently open subscriptions, across every channel. */
|
|
79
|
+
readonly connectionCount: number;
|
|
80
|
+
/** Ends every open connection and forgets them. For graceful shutdown and tests. */
|
|
81
|
+
close(): void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The first real `RealtimeTransport` implementation (issue #155, over the
|
|
85
|
+
* seam #154 added; issue #160 / ADR-0024 added collection and filtered subscriptions):
|
|
86
|
+
* plain HTTP `text/event-stream`, no client or server library required.
|
|
87
|
+
* The channel a connection opens is either the item-level `<entity>.<id>`
|
|
88
|
+
* string `RealtimeEventDto.channel` carries, or the bare `<entity>` name
|
|
89
|
+
* (`RealtimeEventDto.entity`) for every event on that entity — `publish`
|
|
90
|
+
* fans an event out to every connection registered under either string, so
|
|
91
|
+
* one write reaches both an item-level and a collection-level subscriber
|
|
92
|
+
* from the same call.
|
|
93
|
+
*
|
|
94
|
+
* One process, one in-memory channel registry: a subscriber connected to
|
|
95
|
+
* this instance never sees a write handled by another instance of a
|
|
96
|
+
* horizontally-scaled app (see the package README's "Known limitations").
|
|
97
|
+
*/
|
|
98
|
+
export declare function createTransport(options: SseTransportOptions): SseTransport;
|
|
99
|
+
//# sourceMappingURL=sse-transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse-transport.d.ts","sourceRoot":"","sources":["../src/sse-transport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EACV,cAAc,EAId,qBAAqB,EACrB,iBAAiB,EACjB,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAcpB;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;;;;;;;;;OAgBG;IACH,kBAAkB,CAAC,CAAC,UAAU,EAAE,MAAM,GAAG,qBAAqB,GAAG,SAAS,CAAC;IAC3E;;;;;;;OAOG;IACH,kBAAkB,CAAC,CAAC,UAAU,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAAC;IACtE,wCAAwC;IACxC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAa,SAAQ,iBAAiB;IACrD;;;;;;;;;;OAUG;IACH,aAAa,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,oEAAoE;IACpE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,oFAAoF;IACpF,KAAK,IAAI,IAAI,CAAC;CACf;AA0HD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAiM1E"}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { DefaultFilterParser, QueryValidationException, evaluateFilter } from "@kavo/core";
|
|
2
|
+
/**
|
|
3
|
+
* Bytes buffered in a connection's underlying socket before it is dropped
|
|
4
|
+
* rather than blocking `publish` for every other subscriber. `res.
|
|
5
|
+
* writableLength` (Node's `http.ServerResponse` is a `stream.Writable`) is
|
|
6
|
+
* the amount queued but not yet flushed to the OS — a slow reader grows
|
|
7
|
+
* this, a healthy one keeps it near zero. 64 KiB is generous for
|
|
8
|
+
* `RealtimeEventDto`-sized JSON frames while still catching a genuinely
|
|
9
|
+
* stuck client quickly.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_BUFFER_LIMIT_BYTES = 64 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Same array-or-`exclude` semantics `resolveFieldSelector` (core,
|
|
14
|
+
* internal) applies for REST's `selectable`/`filterable`/`sortable` — but
|
|
15
|
+
* with no "base" field list to fall back on, because `RealtimeFieldSelector`
|
|
16
|
+
* carries none (`settings.ts`'s own doc on why: this schema has no `Entity`
|
|
17
|
+
* type parameter to check a field name against). An explicit array is a
|
|
18
|
+
* positive allowlist; `{ exclude }` is checked negatively instead — "not
|
|
19
|
+
* excluded" rather than "in some base set minus excluded" — since there is
|
|
20
|
+
* no base set here to subtract from.
|
|
21
|
+
*/
|
|
22
|
+
function isFieldAllowed(selector, field) {
|
|
23
|
+
if (selector === undefined)
|
|
24
|
+
return true;
|
|
25
|
+
// `"exclude" in selector`, not `Array.isArray` — `Array.isArray`'s guard is
|
|
26
|
+
// `arg is any[]`, and a `readonly string[]` is not assignable to `any[]`,
|
|
27
|
+
// so it fails to narrow the union in the negative branch. Same reason
|
|
28
|
+
// core's own `resolveFieldSelector` (resolve-entity-config.ts) checks it
|
|
29
|
+
// this way.
|
|
30
|
+
if ("exclude" in selector)
|
|
31
|
+
return !selector.exclude.includes(field);
|
|
32
|
+
return selector.includes(field);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Bounds an outgoing `item` to `selector` (unconditional, once configured)
|
|
36
|
+
* and then further to `fields` (a subscriber-requested subset, already
|
|
37
|
+
* validated ⊆ the allowlist at subscribe time). `undefined` for both means
|
|
38
|
+
* no narrowing — the item is delivered whole, today's behavior for an
|
|
39
|
+
* entity that never configured `subscribableFields`.
|
|
40
|
+
*/
|
|
41
|
+
function narrowItem(item, selector, fields) {
|
|
42
|
+
if (item === null || typeof item !== "object")
|
|
43
|
+
return item;
|
|
44
|
+
const record = item;
|
|
45
|
+
let allowed;
|
|
46
|
+
if (fields !== undefined) {
|
|
47
|
+
allowed = fields;
|
|
48
|
+
}
|
|
49
|
+
else if (selector !== undefined) {
|
|
50
|
+
const keys = Object.keys(record);
|
|
51
|
+
allowed = "exclude" in selector ? keys.filter((key) => !selector.exclude.includes(key)) : selector;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
return item;
|
|
55
|
+
}
|
|
56
|
+
const narrowed = {};
|
|
57
|
+
for (const key of allowed)
|
|
58
|
+
if (key in record)
|
|
59
|
+
narrowed[key] = record[key];
|
|
60
|
+
return narrowed;
|
|
61
|
+
}
|
|
62
|
+
function sendJson(res, status, body) {
|
|
63
|
+
const payload = JSON.stringify(body);
|
|
64
|
+
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) });
|
|
65
|
+
res.end(payload);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `id:` set even though nothing consumes it yet (resume-on-reconnect is a
|
|
69
|
+
* future issue) — a monotonically increasing counter, shared across every
|
|
70
|
+
* channel on this transport instance, so it stays meaningful the day resume
|
|
71
|
+
* is built instead of forcing a wire-format change then. Allocated once per
|
|
72
|
+
* `publish` call (not once per connection/frame) so every subscriber of one
|
|
73
|
+
* logical write sees the same `id:`, even though the `data:` they receive
|
|
74
|
+
* may differ once field-narrowing is applied.
|
|
75
|
+
*/
|
|
76
|
+
function frame(id, event) {
|
|
77
|
+
return `id: ${id}\nevent: ${event.event}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every `filter[...]`/`filter` query param, flattened the way
|
|
81
|
+
* `DefaultFilterParser` expects: one entry per literal key, repeated keys
|
|
82
|
+
* (the `filter[status][in][]=a&filter[status][in][]=b` form) collapsed
|
|
83
|
+
* into one array value under that key. Every other query param
|
|
84
|
+
* (`channel`, `fields`, …) rides along harmlessly — the parser only ever
|
|
85
|
+
* reads keys starting with `filter[` or the bare `filter` key.
|
|
86
|
+
*/
|
|
87
|
+
function collectRawParams(searchParams) {
|
|
88
|
+
const raw = {};
|
|
89
|
+
for (const key of new Set(searchParams.keys())) {
|
|
90
|
+
const values = searchParams.getAll(key);
|
|
91
|
+
raw[key] = values.length > 1 ? values : values[0];
|
|
92
|
+
}
|
|
93
|
+
return raw;
|
|
94
|
+
}
|
|
95
|
+
function hasFilterParams(searchParams) {
|
|
96
|
+
for (const key of searchParams.keys()) {
|
|
97
|
+
if (key === "filter" || key.startsWith("filter["))
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
/** Every field name a condition in `expression` compares against, deduplicated. */
|
|
103
|
+
function collectConditionFields(expression) {
|
|
104
|
+
const fields = new Set();
|
|
105
|
+
const visit = (node) => {
|
|
106
|
+
if (node.kind === "condition") {
|
|
107
|
+
fields.add(node.field);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const child of node.children)
|
|
111
|
+
visit(child);
|
|
112
|
+
};
|
|
113
|
+
visit(expression);
|
|
114
|
+
return [...fields];
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The first real `RealtimeTransport` implementation (issue #155, over the
|
|
118
|
+
* seam #154 added; issue #160 / ADR-0024 added collection and filtered subscriptions):
|
|
119
|
+
* plain HTTP `text/event-stream`, no client or server library required.
|
|
120
|
+
* The channel a connection opens is either the item-level `<entity>.<id>`
|
|
121
|
+
* string `RealtimeEventDto.channel` carries, or the bare `<entity>` name
|
|
122
|
+
* (`RealtimeEventDto.entity`) for every event on that entity — `publish`
|
|
123
|
+
* fans an event out to every connection registered under either string, so
|
|
124
|
+
* one write reaches both an item-level and a collection-level subscriber
|
|
125
|
+
* from the same call.
|
|
126
|
+
*
|
|
127
|
+
* One process, one in-memory channel registry: a subscriber connected to
|
|
128
|
+
* this instance never sees a write handled by another instance of a
|
|
129
|
+
* horizontally-scaled app (see the package README's "Known limitations").
|
|
130
|
+
*/
|
|
131
|
+
export function createTransport(options) {
|
|
132
|
+
const bufferLimitBytes = options.bufferLimitBytes ?? DEFAULT_BUFFER_LIMIT_BYTES;
|
|
133
|
+
const channels = new Map();
|
|
134
|
+
let nextEventId = 1;
|
|
135
|
+
function subscribe(connection) {
|
|
136
|
+
let subscribers = channels.get(connection.channel);
|
|
137
|
+
if (!subscribers) {
|
|
138
|
+
subscribers = new Set();
|
|
139
|
+
channels.set(connection.channel, subscribers);
|
|
140
|
+
}
|
|
141
|
+
subscribers.add(connection);
|
|
142
|
+
}
|
|
143
|
+
function unsubscribe(connection) {
|
|
144
|
+
const subscribers = channels.get(connection.channel);
|
|
145
|
+
if (!subscribers)
|
|
146
|
+
return;
|
|
147
|
+
subscribers.delete(connection);
|
|
148
|
+
if (subscribers.size === 0)
|
|
149
|
+
channels.delete(connection.channel);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `"deleted"` bypasses the filter unconditionally — `event.item` is
|
|
153
|
+
* `null`, so there is nothing to evaluate, and the alternative (silently
|
|
154
|
+
* excluding it) leaves a filtered subscriber with a row in its view that
|
|
155
|
+
* is permanently gone. See `RealtimeTransport`'s doc for the
|
|
156
|
+
* confidentiality tradeoff this implies. Every other event id evaluates
|
|
157
|
+
* normally: a write that makes a row start matching a filter is
|
|
158
|
+
* delivered as whatever its real event id is (`"entering" a filtered
|
|
159
|
+
* view reuses the ordinary event, not a synthesized `"created"`); a
|
|
160
|
+
* write that makes it stop matching simply is not delivered — there is
|
|
161
|
+
* no before-image available to detect that transition without an extra
|
|
162
|
+
* read the engine does not already do, so it is a documented limitation
|
|
163
|
+
* rather than a silently-wrong "leave" event.
|
|
164
|
+
*/
|
|
165
|
+
function matches(connection, event) {
|
|
166
|
+
if (event.event === "deleted")
|
|
167
|
+
return true;
|
|
168
|
+
return evaluateFilter(connection.filter, (event.item ?? {}));
|
|
169
|
+
}
|
|
170
|
+
function deliverTo(subscribers, event, id) {
|
|
171
|
+
if (!subscribers || subscribers.size === 0)
|
|
172
|
+
return;
|
|
173
|
+
// Direct `for...of` over the live `Set`, not a snapshot copy: deleting
|
|
174
|
+
// the current entry mid-iteration (the `unsubscribe` below, for a
|
|
175
|
+
// connection that can't keep up) is well-defined under the Set
|
|
176
|
+
// iteration protocol — already-visited and about-to-be-visited
|
|
177
|
+
// entries are unaffected.
|
|
178
|
+
for (const connection of subscribers) {
|
|
179
|
+
if (!matches(connection, event))
|
|
180
|
+
continue;
|
|
181
|
+
if (connection.res.writableLength > bufferLimitBytes) {
|
|
182
|
+
// Can't keep up: dropped rather than left to block publish to
|
|
183
|
+
// every other subscriber of this channel.
|
|
184
|
+
unsubscribe(connection);
|
|
185
|
+
connection.res.end();
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const outgoing = {
|
|
189
|
+
...event,
|
|
190
|
+
item: (narrowItem(event.item, connection.selector, connection.fields) ?? null),
|
|
191
|
+
};
|
|
192
|
+
connection.res.write(frame(id, outgoing));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
name: "sse",
|
|
197
|
+
get connectionCount() {
|
|
198
|
+
let total = 0;
|
|
199
|
+
for (const subscribers of channels.values())
|
|
200
|
+
total += subscribers.size;
|
|
201
|
+
return total;
|
|
202
|
+
},
|
|
203
|
+
async publish(event) {
|
|
204
|
+
const itemSubscribers = channels.get(event.channel);
|
|
205
|
+
const collectionSubscribers = channels.get(event.entity);
|
|
206
|
+
if ((!itemSubscribers || itemSubscribers.size === 0) &&
|
|
207
|
+
(!collectionSubscribers || collectionSubscribers.size === 0)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Allocated once per publish, not once per connection — every
|
|
211
|
+
// subscriber of one logical write shares the same `id:` even though
|
|
212
|
+
// the `data:` they receive may differ (field narrowing).
|
|
213
|
+
const id = nextEventId++;
|
|
214
|
+
deliverTo(itemSubscribers, event, id);
|
|
215
|
+
deliverTo(collectionSubscribers, event, id);
|
|
216
|
+
},
|
|
217
|
+
async handleRequest(req, res) {
|
|
218
|
+
if (req.method !== "GET") {
|
|
219
|
+
sendJson(res, 400, { error: "SSE subscription requires GET" });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const accept = req.headers.accept;
|
|
223
|
+
if (typeof accept !== "string" || !accept.includes("text/event-stream")) {
|
|
224
|
+
sendJson(res, 400, { error: "requires 'Accept: text/event-stream'" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const url = new URL(req.url ?? "", "http://kavo.invalid");
|
|
228
|
+
const channel = url.searchParams.get("channel");
|
|
229
|
+
if (!channel) {
|
|
230
|
+
sendJson(res, 400, {
|
|
231
|
+
error: "a 'channel' query parameter of the form '<entity>' or '<entity>.<id>' is required",
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const dot = channel.indexOf(".");
|
|
236
|
+
if (dot === 0 || dot === channel.length - 1) {
|
|
237
|
+
sendJson(res, 400, {
|
|
238
|
+
error: "a 'channel' query parameter of the form '<entity>' or '<entity>.<id>' is required",
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const entityName = dot === -1 ? channel : channel.slice(0, dot);
|
|
243
|
+
const selector = options.subscribableFields?.(entityName);
|
|
244
|
+
const fieldsParam = url.searchParams.get("fields");
|
|
245
|
+
let fields;
|
|
246
|
+
if (fieldsParam !== null) {
|
|
247
|
+
const requested = fieldsParam
|
|
248
|
+
.split(",")
|
|
249
|
+
.map((field) => field.trim())
|
|
250
|
+
.filter((field) => field.length > 0);
|
|
251
|
+
const disallowed = requested.filter((field) => !isFieldAllowed(selector, field));
|
|
252
|
+
if (disallowed.length > 0) {
|
|
253
|
+
sendJson(res, 400, { error: `field(s) not subscribable: ${disallowed.join(", ")}` });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
fields = requested;
|
|
257
|
+
}
|
|
258
|
+
let filter = null;
|
|
259
|
+
if (hasFilterParams(url.searchParams)) {
|
|
260
|
+
const filterable = options.filterableEntities?.(entityName);
|
|
261
|
+
if (filterable === undefined) {
|
|
262
|
+
sendJson(res, 400, { error: `entity '${entityName}' does not support subscribe-time filtering` });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const parser = new DefaultFilterParser(filterable.metadata);
|
|
266
|
+
let parsed;
|
|
267
|
+
try {
|
|
268
|
+
parsed = parser.parse(collectRawParams(url.searchParams), filterable.config);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error instanceof QueryValidationException) {
|
|
272
|
+
sendJson(res, 400, { error: "invalid filter", issues: error.issues });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
filter = parsed.root;
|
|
278
|
+
if (filter !== null) {
|
|
279
|
+
const knownFields = new Set(filterable.metadata.fields.map((field) => field.name));
|
|
280
|
+
const conditionFields = collectConditionFields(filter);
|
|
281
|
+
const unevaluable = conditionFields.find((field) => !knownFields.has(field));
|
|
282
|
+
if (unevaluable !== undefined) {
|
|
283
|
+
sendJson(res, 400, {
|
|
284
|
+
error: `filter field '${unevaluable}' cannot be evaluated for a realtime subscription (relation and computed fields are not supported — only the entity's own columns)`,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const disallowed = conditionFields.filter((field) => !isFieldAllowed(selector, field));
|
|
289
|
+
if (disallowed.length > 0) {
|
|
290
|
+
sendJson(res, 400, { error: `filter field(s) not subscribable: ${disallowed.join(", ")}` });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
res.writeHead(200, {
|
|
296
|
+
"Content-Type": "text/event-stream",
|
|
297
|
+
"Cache-Control": "no-cache",
|
|
298
|
+
Connection: "keep-alive",
|
|
299
|
+
});
|
|
300
|
+
res.flushHeaders();
|
|
301
|
+
const connection = { res, channel, entityName, filter, fields, selector };
|
|
302
|
+
subscribe(connection);
|
|
303
|
+
req.on("close", () => unsubscribe(connection));
|
|
304
|
+
res.on("error", () => unsubscribe(connection));
|
|
305
|
+
},
|
|
306
|
+
close() {
|
|
307
|
+
for (const subscribers of channels.values()) {
|
|
308
|
+
for (const connection of subscribers)
|
|
309
|
+
connection.res.end();
|
|
310
|
+
}
|
|
311
|
+
channels.clear();
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
//# sourceMappingURL=sse-transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse-transport.js","sourceRoot":"","sources":["../src/sse-transport.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kavo/sse",
|
|
3
|
+
"version": "0.7.2",
|
|
4
|
+
"description": "Kavo SSE realtime transport — implements @kavo/core's RealtimeTransport over Server-Sent Events, plain HTTP with no required peer library.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/kavo-labs/kavo.git",
|
|
12
|
+
"directory": "packages/realtime/sse"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/kavo-labs/kavo/tree/main/packages/realtime/sse#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -b"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@kavo/core": "workspace:^"
|
|
34
|
+
}
|
|
35
|
+
}
|