@lenso/realtime 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +420 -2
- package/dist/config.d.ts +3 -0
- package/dist/contracts.d.ts +119 -0
- package/dist/index-6fg21p7s.js +563 -0
- package/dist/index-htkqerky.js +81 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -0
- package/dist/memory.d.ts +5 -0
- package/dist/memory.js +62 -0
- package/dist/plugin.d.ts +30 -0
- package/dist/plugin.js +68 -0
- package/dist/redis.d.ts +11 -0
- package/dist/redis.js +275 -0
- package/dist/wire.d.ts +10 -0
- package/examples/notes.ts +65 -0
- package/examples/run-notes.ts +43 -0
- package/examples/tasks.ts +10 -0
- package/package.json +53 -4
package/README.md
CHANGED
|
@@ -1,3 +1,421 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lenso/realtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Resource-authorized, bounded SSE subscriptions for ordinary async services and Fetch hosts.
|
|
4
|
+
This package is not a queue, a general event bus, an Agent token stream, or a WebSocket adapter.
|
|
5
|
+
Only `packages/realtime` is owned by this delivery. Core, Engine, Web, Manage, root configuration
|
|
6
|
+
and the shared lockfile are unchanged.
|
|
7
|
+
|
|
8
|
+
## Minimal use
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createRealtime } from "@lenso/realtime";
|
|
12
|
+
import { createMemoryProvider } from "@lenso/realtime/memory";
|
|
13
|
+
|
|
14
|
+
// Execute during application setup, not when importing trusted configuration.
|
|
15
|
+
const realtime = await createRealtime({
|
|
16
|
+
provider: createMemoryProvider(),
|
|
17
|
+
async authorize(identity, resource, signal) {
|
|
18
|
+
// Host-owned policy must verify the opaque principal/session and load the
|
|
19
|
+
// actual resource. Do not treat subject/scope strings as proof of authority.
|
|
20
|
+
const allowed = await policy.canRead(identity.principal, resource, signal);
|
|
21
|
+
return allowed ? { validUntil: identity.expiresAt } : false;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
// Register realtime.close() with the host immediately after acquiring it.
|
|
25
|
+
const connection = realtime.connect(verifiedIdentity, { signal: requestSignal });
|
|
26
|
+
const subscription = await connection.subscribe({
|
|
27
|
+
scope: verifiedIdentity.scope,
|
|
28
|
+
type: "note",
|
|
29
|
+
id: noteId,
|
|
30
|
+
});
|
|
31
|
+
const snapshot = await connection.snapshot(subscription, (signal) =>
|
|
32
|
+
notes.read(verifiedIdentity.principal, noteId, signal),
|
|
33
|
+
);
|
|
34
|
+
// snapshot is returned to the caller, NOT implicitly sent through SSE.
|
|
35
|
+
// If !snapshot.stable, refetch rather than treating it as an atomic snapshot.
|
|
36
|
+
const response = connection.response();
|
|
37
|
+
|
|
38
|
+
// Trusted business code only, after its write has committed:
|
|
39
|
+
await realtime.publish(subscription.resource, "note.updated", { revision: 2 });
|
|
40
|
+
subscription.unsubscribe();
|
|
41
|
+
connection.close();
|
|
42
|
+
await realtime.close();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
No authentication, User table, Organization dependency, HTTP publisher or automatic
|
|
46
|
+
operation exposure is introduced. Keep the instance/publisher in trusted service setup;
|
|
47
|
+
give entries a connection, not a publishing capability.
|
|
48
|
+
|
|
49
|
+
**Identity:** `{scope, subject, principal, expiresAt}` comes from a verified host entry.
|
|
50
|
+
`principal` is opaque host/Auth evidence, not business JSON. `subject` is an admission key,
|
|
51
|
+
not an authorization grant. The host defines `scope`: a single application, tenant,
|
|
52
|
+
workspace or another domain. **Resource:** `{scope, type, id}` is resolved by the server
|
|
53
|
+
from the actual record. A mismatch with the identity scope is rejected before authorization.
|
|
54
|
+
Each component is nonempty, at most 256 UTF-8 bytes, without Unicode normalization.
|
|
55
|
+
Topics are collision-free percent-encoded server constructions, never client channel names.
|
|
56
|
+
Provider access is privileged infrastructure access, not a tenant boundary.
|
|
57
|
+
|
|
58
|
+
**Subscription:** an opaque handle with `id`, immutable `resource`, `unsubscribe()` and
|
|
59
|
+
`renew()`. Another connection cannot use its handle for snapshot access. Reservations
|
|
60
|
+
count toward limits before async authorization; cancellation/revocation cannot resurrect
|
|
61
|
+
a late grant. A connection exposes subscribe/snapshot/iterate/response/close, not publish.
|
|
62
|
+
Use either one iterator or one Response per connection, not both.
|
|
63
|
+
|
|
64
|
+
## Authorization and lifetime
|
|
65
|
+
|
|
66
|
+
- The business authorizer runs at subscription and explicit renewal only. It returns
|
|
67
|
+
`false` or an absolute `validUntil` timestamp. There is no per-message DB query.
|
|
68
|
+
- A grant expires at the earliest of the policy deadline, identity/session expiry,
|
|
69
|
+
and authorization **start time** + `authorizationLeaseMs`. The maximum is 30 seconds,
|
|
70
|
+
including time spent waiting for authorization. Slow grants cannot extend that bound.
|
|
71
|
+
- Hosts with ongoing streams call `handle.renew()` before expiry, using the same
|
|
72
|
+
revalidating business policy. Renewal does not renew the session. Without renewal,
|
|
73
|
+
the handle closes. A raw EventSource bridge without a renewal endpoint may simply
|
|
74
|
+
close/reconnect and reauthorize every lease; do not silently lengthen leases.
|
|
75
|
+
- After committing an ACL change or deletion, call `revokeResource()` or `deleteResource()`.
|
|
76
|
+
They immediately purge/abort local handles, then publish control to other instances.
|
|
77
|
+
Remote delivery is best effort; a lost notice is bounded by the lease. Persisted
|
|
78
|
+
policy must reject new subscriptions/renewals. Notices are not durable ACL storage.
|
|
79
|
+
- `revokeSubject(scope, subject)` is a local fast path for logout/session revocation.
|
|
80
|
+
It does not claim distributed session cancellation. Other instances must revalidate
|
|
81
|
+
the revoked session at renewal and stop no later than their last grant's deadline.
|
|
82
|
+
- **Revocation bound:** no new package delivery/dequeue after the grant deadline;
|
|
83
|
+
idle subscription cleanup within deadline + `sweepMs` (default 250 ms, maximum 1 s),
|
|
84
|
+
assuming a running event loop and correct policy. Thus at most 30 s of authorization
|
|
85
|
+
and 31 s to reclaim an idle handle. Already emitted bytes cannot be recalled.
|
|
86
|
+
- `AbortSignal`, body cancellation, iterator return, unsubscribe, session expiry,
|
|
87
|
+
resource revocation/deletion, provider failure and host close remove subscriptions,
|
|
88
|
+
abort pending work, purge private queued events and settle blocked readers.
|
|
89
|
+
Only fixed lifecycle diagnostics are emitted; no token, identity, private topic,
|
|
90
|
+
backend exception or business payload is logged.
|
|
91
|
+
- Authorization/snapshot callbacks must cooperate with their signal and release their
|
|
92
|
+
own resources. Their default deadline is 5 s. Timed-out underlying work retains a
|
|
93
|
+
pending slot until it settles; the package cannot forcibly stop arbitrary JS/DB work.
|
|
94
|
+
Shutdown aborts it but does not wait forever for an uncooperative callback.
|
|
95
|
+
|
|
96
|
+
## Wire contract and reconnect
|
|
97
|
+
|
|
98
|
+
Each UTF-8 SSE frame uses `event: <kind>`, optional `id: <cursor>`, and one JSON `data`
|
|
99
|
+
line containing:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
{
|
|
103
|
+
version: 1,
|
|
104
|
+
kind: "ready" | "update" | "gap" | "closed" | "heartbeat",
|
|
105
|
+
subscription?: string,
|
|
106
|
+
cursor?: string,
|
|
107
|
+
type?: string, // business event name, e.g. note.updated
|
|
108
|
+
data?: Json, // update invalidation metadata
|
|
109
|
+
reason?: string
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`ready` means authorized subscription and provider watermark established; it requires
|
|
114
|
+
a fresh authoritative snapshot, not that the application has received one. `update`
|
|
115
|
+
is an **invalidation hint**, not an ordered patch to apply blindly. `closed` names
|
|
116
|
+
`revoked`, `deleted`, `expired`, `shutdown` or `unsubscribed`. A connection-level terminal
|
|
117
|
+
`gap` names `overflow` or `provider`, and is followed by EOF. Per-handle gaps name
|
|
118
|
+
`sequence`, `generation`, `out-of-order`, `snapshot-race`, `reconnect` or `cursor-expired`.
|
|
119
|
+
The client closes its EventSource when intentionally unsubscribing or permanently denied;
|
|
120
|
+
native EventSource otherwise retries EOF. The first frame supplies configurable `retry`.
|
|
121
|
+
Custom clients should use jittered exponential retry, starting at `retryMs`, capped by
|
|
122
|
+
their host policy; the server/provider never automatically retry ambiguous publishes.
|
|
123
|
+
|
|
124
|
+
Payloads must be acyclic, plain finite JSON. No BigInt, undefined, Date/classes, accessors,
|
|
125
|
+
symbols, serialization hooks, non-enumerable properties, sparse/custom-property arrays,
|
|
126
|
+
NaN or Infinity. Event structure is limited to 32 levels and 10,000 visited values.
|
|
127
|
+
`maxPayloadBytes` bounds the serialized **ProviderEvent** (including kind/type), default
|
|
128
|
+
16 KiB, hard maximum 32 KiB. SSE metadata adds at most a 512-byte envelope budget and
|
|
129
|
+
a small framing overhead, below Web's default 64 KiB chunk budget. Provider wire limit
|
|
130
|
+
is 48 KiB. Mismatched publisher/receiver payload configurations fail the receiving
|
|
131
|
+
instance closed with a provider gap; use the same limits across the deployment.
|
|
132
|
+
|
|
133
|
+
**Cursor:** opaque `generation.sequence.issuedAt` detection token. Sequence is a
|
|
134
|
+
nonnegative safe integer, ordered only inside one resource/generation; generation is
|
|
135
|
+
reset on watermark expiry/provider state loss. Cursor age defaults to 10 minutes.
|
|
136
|
+
`issuedAt` is the observation/token issuance time, not the publication time. Different
|
|
137
|
+
instances observing the same generation/sequence may mint different token strings;
|
|
138
|
+
the watermark identity is generation + sequence, not string equality across instances.
|
|
139
|
+
Malformed, future-dated or older reconnect tokens produce `cursor-expired`. All other
|
|
140
|
+
reconnect tokens produce `reconnect`, even if they equal the current cursor.
|
|
141
|
+
Tokens confer no authorization and are never accepted as read positions.
|
|
142
|
+
There is **no persistent event log, event retention or replay**. No exactly-once claim.
|
|
143
|
+
|
|
144
|
+
Client consistency rules:
|
|
145
|
+
|
|
146
|
+
1. Subscribe first. The package's `snapshot(handle, read)` samples provider cursors
|
|
147
|
+
before/after an authorized business read. Changed cursor => `stable:false` and
|
|
148
|
+
`snapshot-race` gap. Refetch. Revoke/expiry during a read prevents its value returning.
|
|
149
|
+
2. For an ordinary HTTP snapshot endpoint, start consuming SSE first, mark dirty on
|
|
150
|
+
every update/gap while the snapshot request is in flight, and refetch if dirty.
|
|
151
|
+
Continue treating future updates as invalidations. Never fetch first and subscribe later.
|
|
152
|
+
3. On every reconnect, generation change, sequence gap, overflow, stale cursor or
|
|
153
|
+
provider replacement: discard assumptions about incremental state and obtain a snapshot.
|
|
154
|
+
Equal duplicate cursors are suppressed; older sequence events cause gap and are dropped.
|
|
155
|
+
4. `stable` means no **published** change during that read, not a transaction spanning
|
|
156
|
+
the business DB and Redis. A write committed before delayed publication is eventually
|
|
157
|
+
invalidated when its event arrives. Failed/missing publication cannot be inferred from
|
|
158
|
+
the cursor. Hosts must preserve publish-after-commit ordering; if their product needs
|
|
159
|
+
atomic reliable publication, an owned outbox/event log is a separate integration,
|
|
160
|
+
not implemented here. Do not retry an already committed business write on publish failure.
|
|
161
|
+
|
|
162
|
+
## Configuration and limits
|
|
163
|
+
|
|
164
|
+
`resolveRealtimeConfig` is the same validator used by `/plugin` and direct async setup.
|
|
165
|
+
`createRealtimePlugin({id, requires, config, provider, authorize})` uses existing Core
|
|
166
|
+
`bindConfig`/`definePluginConfig`; config accepts values or ordered Core sources.
|
|
167
|
+
The provider factory executes in setup, not at config import. Cleanup is registered
|
|
168
|
+
before the next fallible step. Provider selection is host configuration:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
provider: () =>
|
|
172
|
+
settings.provider === "redis"
|
|
173
|
+
? createRedisProvider({ url: bindings.REDIS_URL, namespace: settings.namespace })
|
|
174
|
+
: createMemoryProvider();
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Import `/redis` only in a Bun host; Redis URL/credentials belong in trusted runtime
|
|
178
|
+
bindings, never manifests, JSON operations, logs or committed configuration. No new
|
|
179
|
+
environment/configuration system is created.
|
|
180
|
+
|
|
181
|
+
| Instance-local limit | Default |
|
|
182
|
+
| ------------------------------------------------------- | -----------------: |
|
|
183
|
+
| Connections / connections per scoped subject | 1,000 / 8 |
|
|
184
|
+
| Subscriptions per connection / per scoped subject | 16 / 64 |
|
|
185
|
+
| Subscribers per topic / active topics | 1,000 / 1,000 |
|
|
186
|
+
| Pending service operations | 64 |
|
|
187
|
+
| Buffered events / encoded envelope bytes per connection | 64 / 131,072 |
|
|
188
|
+
| ProviderEvent payload bytes | 16,384 |
|
|
189
|
+
| Authorization lease / sweep interval | 30,000 / 250 ms |
|
|
190
|
+
| Heartbeat / SSE reconnect starting delay | 15,000 / 2,000 ms |
|
|
191
|
+
| Cursor maximum age / callback timeout | 600,000 / 5,000 ms |
|
|
192
|
+
|
|
193
|
+
Limits are positive safe integers; unknown keys are rejected. `sweepMs` must not exceed
|
|
194
|
+
the lease. Heartbeat and retry intervals are configurable. Byte budget must be at least
|
|
195
|
+
payload limit + 512 bytes. One instance timer handles all leases and heartbeats.
|
|
196
|
+
Slow consumers never block publish: when either queue budget is exceeded, discard the
|
|
197
|
+
queue, retain one small terminal overflow gap, remove all references and disconnect.
|
|
198
|
+
Host/proxy/socket buffers are outside this package; configure their own limits and idle
|
|
199
|
+
deadlines. This package's admission counts are not distributed rate limits; a multi-node
|
|
200
|
+
deployment still needs host ingress/subject abuse controls.
|
|
201
|
+
|
|
202
|
+
## Providers and host matrix
|
|
203
|
+
|
|
204
|
+
**Memory:** independent per-provider Map, no global shared singleton and no cross-process
|
|
205
|
+
broadcast. Maximum 10,000 watermarks; inactivity TTL 10 minutes. Expired keys are reclaimed
|
|
206
|
+
on admission, or all on close; the map is always bounded. A current call/publication
|
|
207
|
+
refreshes metadata TTL. New provider/expired watermark means new generation. Delivery is
|
|
208
|
+
synchronous, once per active service callback in normal operation, with no persistence.
|
|
209
|
+
|
|
210
|
+
**Redis:** chosen instead of PostgreSQL LISTEN/NOTIFY or DO because standalone Redis
|
|
211
|
+
and Bun are available for real local testing; native Pub/Sub supplies broadcast, not
|
|
212
|
+
competing consumption. No new client dependency or lockfile change.
|
|
213
|
+
|
|
214
|
+
- One dedicated namespace subscription socket plus one command socket **per instance**,
|
|
215
|
+
independent of browser connection count. All active topics share the subscription;
|
|
216
|
+
each instance receives its namespace's messages and filters locally. Trusted instances
|
|
217
|
+
sharing a namespace can see all its provider payloads, so scope is not a Redis ACL.
|
|
218
|
+
- Subscribe ACK before start resolves. Atomic Lua increments the per-topic watermark
|
|
219
|
+
and publishes one delivery. The originating instance also receives it through Pub/Sub;
|
|
220
|
+
no optimistic local update is duplicated.
|
|
221
|
+
- Standalone Redis endpoint only. No verified Redis Cluster, Sentinel failover, sharded
|
|
222
|
+
topology or load-balanced Pub/Sub. Configure the same endpoint/namespace on all nodes.
|
|
223
|
+
A Redis ACL must allow connection/subscription, EVAL, EXISTS/HSET/HGET/HINCRBY/PEXPIRE
|
|
224
|
+
and PUBLISH. Use the host's Redis security/TLS/network owner, not client-side credentials.
|
|
225
|
+
- Normal connected delivery is ephemeral, best effort/at-most-once transport. Per-topic
|
|
226
|
+
Lua sequence gives order at Redis; service detects duplicate/out-of-order delivery
|
|
227
|
+
without promising exactly-once application effects.
|
|
228
|
+
- No offline queue or automatic reconnect. Any socket loss, command error/deadline or
|
|
229
|
+
malformed wire is terminal, closes both sockets and notifies once. Replace the failed
|
|
230
|
+
instance/provider, reconnect and snapshot. A failed/timed-out publish has unknown outcome;
|
|
231
|
+
no retry or rollback. No events survive for a disconnected subscriber.
|
|
232
|
+
- Expiring metadata only, **not retained events**. `watermarkTtlMs` 600,000,
|
|
233
|
+
`commandTimeoutMs` 5,000, `connectionTimeoutMs` 2,000, `maxPendingOperations` 64.
|
|
234
|
+
Trusted publication controls the global TTL key cardinality; active-topic admission
|
|
235
|
+
is instance-local. Production Redis memory/traffic quotas remain deployment-owned.
|
|
236
|
+
|
|
237
|
+
| Host/path | Memory | Redis | Validation |
|
|
238
|
+
| --------------------------------- | ---------------------------- | ---------------------------- | --------------------------------------------------------- |
|
|
239
|
+
| Bun Fetch + Web raw hook | Single long-lived instance | Multi-instance | Real HTTP SSE/cancel; real Redis subprocesses |
|
|
240
|
+
| oRPC 2.0.0-beta.42 async iterator | Same service/iterator | Same service | Existing Web path consumed/cancelled |
|
|
241
|
+
| Workers Fetch adapter | Request-local lifecycle only | Unsupported Bun entry | Local adapter test/browser bundle, no platform deployment |
|
|
242
|
+
| Other Fetch hosts (Node/Deno) | Portable primitives | Unsupported native Bun entry | Browser bundle only; no Node/Deno runtime claim |
|
|
243
|
+
|
|
244
|
+
Workers currently assemble one application per request. A per-request memory provider
|
|
245
|
+
does **not** broadcast to another request/isolate; no useful distributed Workers provider
|
|
246
|
+
is delivered. No upgrade/WebSocket capability or DO binding/deployment is invented.
|
|
247
|
+
No PostgreSQL LISTEN connection or platform portability claim is made.
|
|
248
|
+
|
|
249
|
+
Web's raw hook can select an explicitly authorized resource route, obtain the exact
|
|
250
|
+
Realtime plugin instance, connect with `WebContext.signal`, register connection cleanup
|
|
251
|
+
with `WebContext.onCleanup`, subscribe, and return `connection.response()`. Returning
|
|
252
|
+
undefined leaves existing oRPC routes unchanged. Raw routes must independently enforce
|
|
253
|
+
Auth, method, Origin/Host and input policy. Web deadlines cover the entire SSE body;
|
|
254
|
+
omit `timeoutMs` for a long stream or intentionally allow expiry/reconnect. Do not put
|
|
255
|
+
a never-ending producer into `waitUntil` if it can only settle during cleanup.
|
|
256
|
+
|
|
257
|
+
oRPC already consumes an async generator: `try { for await (const event of connection)
|
|
258
|
+
yield event; } finally { connection.close(); }`, with its procedure signal and existing
|
|
259
|
+
Auth middleware. Its wire format is oRPC's format, not this package's raw SSE framing;
|
|
260
|
+
use its client and optionally `withEventMeta` for IDs/retry. No Web refactor is needed.
|
|
261
|
+
|
|
262
|
+
## Notes and Tasks
|
|
263
|
+
|
|
264
|
+
`examples/notes.ts` bridges an authorized ordinary async `NotesPort` into subscribe-first
|
|
265
|
+
snapshot and publish-after-update invalidation. The existing Notes service can implement
|
|
266
|
+
that port with its read/update audience-specific Auth actors; the port does not fabricate
|
|
267
|
+
an Auth actor from a subject string. No dependency on another parallel package is required.
|
|
268
|
+
`examples/run-notes.ts` is a runnable trusted in-process Notes fixture with two readers,
|
|
269
|
+
not a demo app or production login. `examples/tasks.ts` is a thin post-commit status hook;
|
|
270
|
+
it neither polls Tasks nor changes their queue contract.
|
|
271
|
+
|
|
272
|
+
```sh
|
|
273
|
+
bun run --cwd packages/realtime build
|
|
274
|
+
bun packages/realtime/examples/run-notes.ts
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Integration owner handoff
|
|
278
|
+
|
|
279
|
+
1. Landing integrates this manifest into the shared Bun lockfile and records a package
|
|
280
|
+
changeset. Both candidate and clean-source frozen installs are verified without changing
|
|
281
|
+
the lock hash or existing dependency resolutions. CI and release verification install
|
|
282
|
+
Redis binaries so the real provider tests run. These preparation steps do not publish
|
|
283
|
+
the package, prepare release versions or authorize deployment.
|
|
284
|
+
2. Select the provider factory/runtime bindings and existing Core config sources in the
|
|
285
|
+
application, with exact `requires` references for Web/business dependencies. No new
|
|
286
|
+
Core/Engine interface is needed.
|
|
287
|
+
3. Add application-owned raw or oRPC authorized ingress, snapshot transport and lease
|
|
288
|
+
renewal policy. Hook committed Notes updates/deletes and ACL/session invalidation into
|
|
289
|
+
the trusted service. Existing Auth must revalidate session and resource policy.
|
|
290
|
+
4. Manage can explicitly select finite `stats` or host-owned revoke operations via existing
|
|
291
|
+
Operations/Manage declarations, audience binding and approval policy. This package
|
|
292
|
+
intentionally exposes no stream, publisher, principal-bearing method or revoke route
|
|
293
|
+
automatically. No Manage/core contribution or registry patch is necessary.
|
|
294
|
+
5. Configure proxy buffering/idle timeouts, ingress and distributed abuse limits. Validate
|
|
295
|
+
production Redis topology/TLS/ACL and any Workers bindings separately. For atomic
|
|
296
|
+
durable replay/publication, propose an owned event log/outbox with retention and recovery
|
|
297
|
+
tests before changing these semantics.
|
|
298
|
+
|
|
299
|
+
## Verification evidence
|
|
300
|
+
|
|
301
|
+
The implementation measurements and checks below are local, not deployment/release approval. Environment:
|
|
302
|
+
Bun 1.4.2, macOS 27.0.1 / Darwin 27.0.0 arm64, Apple M2 Pro, Redis 8.10.2.
|
|
303
|
+
Tests start owned loopback Redis processes with persistence disabled and isolated temporary
|
|
304
|
+
directories, then stop/remove only those owned resources. If `redis-server` is absent,
|
|
305
|
+
integration explicitly skips and prints the missing executable; no simulated Redis pass.
|
|
306
|
+
|
|
307
|
+
| Command/check | Final result |
|
|
308
|
+
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
309
|
+
| `bun run --cwd packages/realtime build` | Passed, portable root/memory/plugin and Bun Redis JS + declarations |
|
|
310
|
+
| `bun run --cwd packages/realtime typecheck` | Passed, source/tests/integration/examples/measurement |
|
|
311
|
+
| `bun run --cwd packages/realtime test` | 50 passed, 0 failed, 303 assertions; Bun's `test` filter also includes Redis integration |
|
|
312
|
+
| `bun run --cwd packages/realtime test:redis` | 12 passed, 0 failed, 180 assertions |
|
|
313
|
+
| `bun packages/realtime/examples/run-notes.ts` | Snapshot + the same note revision delivered to two readers |
|
|
314
|
+
| `node_modules/.bin/oxlint packages/realtime --deny-warnings` | 0 errors, 0 warnings |
|
|
315
|
+
| `node_modules/.bin/oxfmt --check packages/realtime` | Passed |
|
|
316
|
+
| `bun run --cwd packages/realtime measure` | Passed with the measurements below |
|
|
317
|
+
| `cd packages/realtime && bun pm pack --destination "$DELTA_SCRATCH_DIR"` | Local tarball produced, 18 files; no publishing |
|
|
318
|
+
| Extracted tarball smoke | All four public JS/type export targets exist; imports, memory subscription/publication and packaged Notes example passed |
|
|
319
|
+
|
|
320
|
+
Packed `/plugin` smoke borrowed the already built local Core optional peer through a
|
|
321
|
+
temporary symlink. This is a local artifact/peer compatibility check, **not a registry
|
|
322
|
+
installation or release verification**. No package was uploaded. Examples import public
|
|
323
|
+
package entries and work without packing `src`.
|
|
324
|
+
|
|
325
|
+
Dependency builds actually run:
|
|
326
|
+
`bun run --cwd packages/lenso build`, `bun run --cwd packages/web build`,
|
|
327
|
+
`bun run --cwd packages/workers build`, `bun run --cwd packages/log build`,
|
|
328
|
+
`bun run --cwd packages/otel build`. All passed.
|
|
329
|
+
|
|
330
|
+
Affected host checks:
|
|
331
|
+
|
|
332
|
+
```sh
|
|
333
|
+
bun test packages/web/test/stream.test.ts \
|
|
334
|
+
packages/web/test/openapi-stream.test.ts packages/web/test/bun.test.ts \
|
|
335
|
+
packages/web/test/http.test.ts packages/workers/test/fetch.test.ts \
|
|
336
|
+
packages/workers/test/rpc.test.ts packages/workers/test/config.test.ts
|
|
337
|
+
bun run --cwd packages/web typecheck
|
|
338
|
+
bun run --cwd packages/workers typecheck
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
40 host tests passed, 0 failed, 205 assertions; both host typechecks passed. The first Web
|
|
342
|
+
typecheck failed because `@lenso/otel/bun` declarations had not been built; it passed after
|
|
343
|
+
the Log/Otel prerequisite builds. Initial Realtime adapter tests exposed the oRPC initial
|
|
344
|
+
comment frame and incorrect test cleanup registration; final tests consume the actual wire
|
|
345
|
+
and use request cleanup correctly. Review caught and regression-tested near-node-limit
|
|
346
|
+
envelope serialization, deferred local control delivery during snapshots, and hidden
|
|
347
|
+
serialization hooks. All final checks above include those fixes.
|
|
348
|
+
|
|
349
|
+
Coverage includes allow/deny, cross-scope rejection/isolation, resource/session/subject
|
|
350
|
+
revocation, lease renewal/expiry and slow grants, two-reader fan-out, unsubscribe/abort/body
|
|
351
|
+
cancel/iterator return/host stop, bounded event/byte overflow, JSON/size limits, reconnect and
|
|
352
|
+
expired cursor, subscribe-first snapshot races and revocation during snapshots, provider
|
|
353
|
+
failure/startup rollback, duplicate/sequence/out-of-order/generation changes, pending
|
|
354
|
+
operation saturation, heartbeat/retry, and per-subject/topic/instance admission.
|
|
355
|
+
Redis evidence includes two distinct child PIDs receiving identical ordered provider
|
|
356
|
+
deliveries/cursors, plus two independently constructed Realtime instances receiving updates
|
|
357
|
+
and revocation through Redis. No shared in-process Map substitutes for Redis.
|
|
358
|
+
Provider restart recovery is a new instance + snapshot, not replay: server termination,
|
|
359
|
+
fresh/expired generations, and absence of late-listener history are tested independently.
|
|
360
|
+
|
|
361
|
+
### Bounded measurements
|
|
362
|
+
|
|
363
|
+
Command: `bun run --cwd packages/realtime measure` (sets
|
|
364
|
+
`BUN_CONFIG_MAX_HTTP_REQUESTS=1024`). One process sequentially tests 100 and 500 real
|
|
365
|
+
loopback Fetch/SSE connections, admitted in batches of 50, all simultaneously open for
|
|
366
|
+
measurement. One resource, one subscription and distinct subject per connection;
|
|
367
|
+
20 publications per scale, each with 256 ASCII payload-text bytes plus event metadata.
|
|
368
|
+
Clients await every read before the next publish. Timing is publish-start to each client's
|
|
369
|
+
read completion, including serialization, fan-out and loopback HTTP; no TLS/proxy/Redis.
|
|
370
|
+
Memory uses `process.memoryUsage()` after `Bun.gc(true)` before/after admission.
|
|
371
|
+
|
|
372
|
+
| Connections | Deliveries | Setup ms | p50 ms | p95 ms | Max ms | Baseline RSS bytes | Active RSS bytes | Baseline JS heap bytes | Active JS heap bytes |
|
|
373
|
+
| ----------: | ---------: | -------: | -----: | -----: | -----: | -----------------: | ---------------: | ---------------------: | -------------------: |
|
|
374
|
+
| 100 | 2,000 | 11.31 | 0.665 | 1.088 | 1.535 | 16,400,384 | 35,438,592 | 263,179 | 27,424,154 |
|
|
375
|
+
| 500 | 10,000 | 37.48 | 3.381 | 3.889 | 4.541 | 55,066,624 | 69,337,088 | 933,108 | 134,957,872 |
|
|
376
|
+
|
|
377
|
+
The second scale retains runtime allocations from the first; these deltas are not
|
|
378
|
+
per-connection estimates. Bun's JS heap accounting can exceed resident RSS; report the
|
|
379
|
+
two separately rather than treating either as allocated physical memory.
|
|
380
|
+
|
|
381
|
+
Slow-consumer run: 500 unread async iterables, one topic, 32-event cap including ready,
|
|
382
|
+
31 updates of 256 payload-text bytes fill queues. Full-GC JS heap rises from 2,686,292
|
|
383
|
+
to 10,538,758 bytes (queue delta **7,852,466 bytes**); RSS from 135,479,296 to 137,084,928.
|
|
384
|
+
The next publication produces **500 overflow gaps**, then connections/subscriptions/topics
|
|
385
|
+
are all **0**. It exercises the package buffer, not an arbitrary proxy/network send buffer.
|
|
386
|
+
|
|
387
|
+
The first benchmark attempt timed out at 120 s with default HTTP client concurrency;
|
|
388
|
+
another burst-admission attempt encountered ECONNRESET. The final runner sets explicit
|
|
389
|
+
client concurrency and batches admission rather than hiding these failures or retrying
|
|
390
|
+
missed updates. The final numbers above come from one complete successful final run.
|
|
391
|
+
They are a bounded local measurement, not production capacity/latency guarantees.
|
|
392
|
+
|
|
393
|
+
At implementation handoff, not run: root-wide build/typecheck/test/release verification, registry installation,
|
|
394
|
+
production deployment, Redis TLS/ACL/Cluster/Sentinel/proxy failure tests, sustained
|
|
395
|
+
distributed throughput/memory load, Node/Deno execution, or deployed Workers/DO runtime.
|
|
396
|
+
There is no persistent replay recovery test because no replay is promised or implemented.
|
|
397
|
+
Application Auth/ingress wiring remains integration-owner work; the package and examples
|
|
398
|
+
themselves are runnable and locally packed.
|
|
399
|
+
|
|
400
|
+
### Landing integration verification
|
|
401
|
+
|
|
402
|
+
The landing candidate adds the root workspace lock entry and manifest-matching peer
|
|
403
|
+
metadata, preserving every existing external dependency resolution. It also records a
|
|
404
|
+
Realtime changeset and installs Redis alongside PostgreSQL in Checks/release verification.
|
|
405
|
+
`bun install --frozen-lockfile` passes both in the candidate and in an archived clean
|
|
406
|
+
source copy without `node_modules` or `dist`; both preserve the lock hash.
|
|
407
|
+
|
|
408
|
+
`bash scripts/ci-checks.sh` passes locally, including root lint, format, build, typecheck,
|
|
409
|
+
test, release-script tests and the script's independently owned disposable PostgreSQL
|
|
410
|
+
fixtures. Realtime has 50 passing tests with all 12 real Redis integration cases executed.
|
|
411
|
+
No shared database or connection-string credential is used for those fixtures.
|
|
412
|
+
|
|
413
|
+
Local Core and Realtime archives are installed together in a standalone temporary consumer,
|
|
414
|
+
without workspace symlinks. TypeScript checks and runtime checks pass for all public imports,
|
|
415
|
+
Core plugin setup, subscribe/snapshot/publish/unsubscribe/cleanup, and the packaged Notes
|
|
416
|
+
example. This is not a registry installation or package publication.
|
|
417
|
+
|
|
418
|
+
The same bounded measurement command passes again during landing: 100/500 loopback SSE
|
|
419
|
+
connections have p95 1.272/4.018 ms respectively; the 500 unread connections again produce
|
|
420
|
+
500 overflow gaps and release all subscriptions. This second local sample does not replace
|
|
421
|
+
the implementation sample or extend its production/host guarantees.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export type Json = null | boolean | number | string | Json[] | {
|
|
2
|
+
[key: string]: Json;
|
|
3
|
+
};
|
|
4
|
+
/** Scope is host-defined: an application, tenant, workspace or another isolation domain. */
|
|
5
|
+
export interface Resource {
|
|
6
|
+
readonly scope: string;
|
|
7
|
+
readonly type: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
}
|
|
10
|
+
export interface Identity<P = unknown> {
|
|
11
|
+
readonly scope: string;
|
|
12
|
+
readonly subject: string;
|
|
13
|
+
readonly principal: P;
|
|
14
|
+
readonly expiresAt: number;
|
|
15
|
+
}
|
|
16
|
+
export interface Cursor {
|
|
17
|
+
readonly generation: string;
|
|
18
|
+
readonly sequence: number;
|
|
19
|
+
}
|
|
20
|
+
export type ProviderEvent = {
|
|
21
|
+
readonly kind: "update";
|
|
22
|
+
readonly type: string;
|
|
23
|
+
readonly data: Json;
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: "deleted";
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "revoke";
|
|
28
|
+
};
|
|
29
|
+
export interface Delivery {
|
|
30
|
+
readonly topic: string;
|
|
31
|
+
readonly cursor: Cursor;
|
|
32
|
+
readonly event: ProviderEvent;
|
|
33
|
+
}
|
|
34
|
+
/** A provider belongs to one Realtime instance. start subscribes before resolving. */
|
|
35
|
+
export interface RealtimeProvider {
|
|
36
|
+
readonly kind: "memory" | "redis";
|
|
37
|
+
start(deliver: (delivery: Delivery) => void, fail: () => void): Promise<void>;
|
|
38
|
+
current(topic: string): Promise<Cursor>;
|
|
39
|
+
publish(topic: string, event: ProviderEvent): Promise<Cursor>;
|
|
40
|
+
close(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export type ErrorCode = "invalid-input" | "denied" | "expired" | "limit" | "closed" | "provider" | "payload";
|
|
43
|
+
export declare class RealtimeError extends Error {
|
|
44
|
+
readonly code: ErrorCode;
|
|
45
|
+
constructor(code: ErrorCode);
|
|
46
|
+
}
|
|
47
|
+
export type GapReason = "reconnect" | "cursor-expired" | "sequence" | "generation" | "out-of-order" | "snapshot-race" | "overflow" | "provider";
|
|
48
|
+
export interface Envelope {
|
|
49
|
+
readonly version: 1;
|
|
50
|
+
readonly kind: "ready" | "update" | "gap" | "closed" | "heartbeat";
|
|
51
|
+
readonly subscription?: string;
|
|
52
|
+
readonly cursor?: string;
|
|
53
|
+
readonly type?: string;
|
|
54
|
+
readonly data?: Json;
|
|
55
|
+
readonly reason?: GapReason | "revoked" | "deleted" | "expired" | "shutdown" | "unsubscribed";
|
|
56
|
+
}
|
|
57
|
+
export interface RealtimeConfig {
|
|
58
|
+
readonly maxConnections?: number;
|
|
59
|
+
readonly maxConnectionsPerSubject?: number;
|
|
60
|
+
readonly maxSubscriptionsPerConnection?: number;
|
|
61
|
+
readonly maxSubscriptionsPerSubject?: number;
|
|
62
|
+
readonly maxSubscribersPerTopic?: number;
|
|
63
|
+
readonly maxTopics?: number;
|
|
64
|
+
readonly maxPendingOperations?: number;
|
|
65
|
+
readonly maxPayloadBytes?: number;
|
|
66
|
+
readonly maxBufferedEvents?: number;
|
|
67
|
+
readonly maxBufferedBytes?: number;
|
|
68
|
+
readonly authorizationLeaseMs?: number;
|
|
69
|
+
readonly sweepMs?: number;
|
|
70
|
+
readonly heartbeatMs?: number;
|
|
71
|
+
readonly retryMs?: number;
|
|
72
|
+
readonly cursorMaxAgeMs?: number;
|
|
73
|
+
readonly snapshotTimeoutMs?: number;
|
|
74
|
+
}
|
|
75
|
+
export interface RealtimeOptions<P> {
|
|
76
|
+
readonly provider: RealtimeProvider;
|
|
77
|
+
readonly config?: RealtimeConfig;
|
|
78
|
+
/** Called only on subscribe/explicit renewal, never once per event. */
|
|
79
|
+
readonly authorize: (identity: Identity<P>, resource: Resource, signal: AbortSignal) => Promise<{
|
|
80
|
+
validUntil: number;
|
|
81
|
+
} | false>;
|
|
82
|
+
/** Only fixed categories, never identity, topics, credentials, errors or payloads. */
|
|
83
|
+
readonly onDiagnostic?: (event: "overflow" | "provider" | "authorization") => void;
|
|
84
|
+
}
|
|
85
|
+
export interface Subscription {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
readonly resource: Resource;
|
|
88
|
+
unsubscribe(): void;
|
|
89
|
+
renew(): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
export interface RealtimeConnection extends AsyncIterable<Envelope> {
|
|
92
|
+
subscribe(resource: Resource, options?: {
|
|
93
|
+
cursor?: string;
|
|
94
|
+
}): Promise<Subscription>;
|
|
95
|
+
snapshot<T>(subscription: Subscription, read: (signal: AbortSignal) => Promise<T>): Promise<{
|
|
96
|
+
value: T;
|
|
97
|
+
cursor: string;
|
|
98
|
+
stable: boolean;
|
|
99
|
+
}>;
|
|
100
|
+
response(): Response;
|
|
101
|
+
close(): void;
|
|
102
|
+
}
|
|
103
|
+
export interface Realtime<P = unknown> {
|
|
104
|
+
connect(identity: Identity<P>, options?: {
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
}): RealtimeConnection;
|
|
107
|
+
publish(resource: Resource, type: string, data: Json): Promise<string>;
|
|
108
|
+
revokeResource(resource: Resource): Promise<void>;
|
|
109
|
+
deleteResource(resource: Resource): Promise<void>;
|
|
110
|
+
/** Local fast path. Other instances still enforce the bounded lease. */
|
|
111
|
+
revokeSubject(scope: string, subject: string): void;
|
|
112
|
+
stats(): {
|
|
113
|
+
connections: number;
|
|
114
|
+
subscriptions: number;
|
|
115
|
+
topics: number;
|
|
116
|
+
pending: number;
|
|
117
|
+
};
|
|
118
|
+
close(): Promise<void>;
|
|
119
|
+
}
|