@ignex/nova 0.1.3 → 0.1.5

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.
Files changed (98) hide show
  1. package/README.md +4 -1
  2. package/docs/ai/TREE.md +69 -9
  3. package/docs/architecture.md +75 -27
  4. package/docs/events.md +83 -1
  5. package/docs/generic-bindings.md +10 -0
  6. package/docs/wire-format.md +65 -18
  7. package/package.json +2 -1
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/generate.ts +97 -3
  10. package/public/server.ts +10 -0
  11. package/rust/src/generated/backend.rs +503 -0
  12. package/rust/src/transcode/generated.rs +376 -17
  13. package/src/bridge/nats/inbound.ts +46 -0
  14. package/src/bridge/nats/index.ts +131 -0
  15. package/src/bridge/nats/real-transport.ts +133 -0
  16. package/src/bridge/nats/types.ts +80 -0
  17. package/src/codegen/constants.ts +14 -4
  18. package/src/codegen/direct-gen.ts +20 -6
  19. package/src/codegen/registry-gen.ts +10 -6
  20. package/src/codegen/rust-glue-gen.ts +10 -3
  21. package/src/codegen/schema-model.ts +28 -3
  22. package/src/codegen/ts-ser-gen.ts +12 -3
  23. package/src/core/auth.ts +65 -4
  24. package/src/core/client-rpc.ts +75 -0
  25. package/src/core/client-state.ts +42 -0
  26. package/src/core/client-wire.ts +142 -8
  27. package/src/core/client.ts +72 -3
  28. package/src/core/groups.ts +5 -0
  29. package/src/core/metrics.ts +38 -21
  30. package/src/core/outbound.ts +50 -6
  31. package/src/core/rate-limit.ts +69 -0
  32. package/src/core/replay.ts +41 -1
  33. package/src/core/resume.ts +181 -0
  34. package/src/core/rooms.ts +10 -3
  35. package/src/core/routing.ts +128 -5
  36. package/src/core/server/client-info.ts +37 -0
  37. package/src/core/server/http-routes.ts +59 -0
  38. package/src/core/{server.ts → server/index.ts} +112 -120
  39. package/src/core/server/metrics-view.ts +53 -0
  40. package/src/core/server/socket-lifecycle.ts +57 -0
  41. package/src/core/state.ts +73 -1
  42. package/src/core/topic-log.ts +86 -0
  43. package/src/events/clients.ts +18 -0
  44. package/src/events/cluster/dedupe.ts +43 -0
  45. package/src/events/cluster/envelope.ts +149 -0
  46. package/src/events/cluster/index.ts +50 -0
  47. package/src/events/cluster/keys.ts +33 -0
  48. package/src/events/cluster/kinds.ts +32 -0
  49. package/src/events/cluster/presence-table.ts +99 -0
  50. package/src/events/cluster/presence.ts +53 -0
  51. package/src/events/cluster/redis-client.ts +50 -0
  52. package/src/events/cluster/store-memory.ts +67 -0
  53. package/src/events/cluster/store-redis.ts +44 -0
  54. package/src/events/cluster/subjects.ts +30 -0
  55. package/src/events/cluster/sync.ts +476 -0
  56. package/src/events/cluster/transport-nats.ts +24 -0
  57. package/src/events/cluster/transport-redis.ts +120 -0
  58. package/src/events/cluster-rpc.ts +196 -0
  59. package/src/events/delivery.ts +83 -0
  60. package/src/events/emit.ts +57 -11
  61. package/src/events/hub/context-factory.ts +79 -0
  62. package/src/events/hub/dispatch.ts +86 -0
  63. package/src/events/hub/index.ts +536 -0
  64. package/src/events/hub/internal.ts +31 -0
  65. package/src/events/hub/metrics-snapshot.ts +84 -0
  66. package/src/events/hub/resolve-cluster.ts +49 -0
  67. package/src/events/queue.ts +36 -9
  68. package/src/events/registry.ts +90 -54
  69. package/src/events/schedule.ts +73 -0
  70. package/src/events/trace.ts +283 -0
  71. package/src/events/types/client.ts +68 -0
  72. package/src/events/types/cluster.ts +40 -0
  73. package/src/events/types/context.ts +50 -0
  74. package/src/events/types/emit-target.ts +29 -0
  75. package/src/events/types/groups.ts +35 -0
  76. package/src/events/types/hub.ts +124 -0
  77. package/src/events/types/index.ts +30 -0
  78. package/src/events/types/metrics.ts +52 -0
  79. package/src/events/types/options.ts +62 -0
  80. package/src/generated/direct-ser.ts +146 -59
  81. package/src/generated/fbs/backend.fbs +23 -0
  82. package/src/generated/registry.ts +92 -33
  83. package/src/generated/rust/backend_generated.rs +503 -0
  84. package/src/generated/ts/backend.ts +4 -0
  85. package/src/generated/ts/resume.ts +74 -0
  86. package/src/generated/ts/resumed.ts +88 -0
  87. package/src/generated/ts/rpc-call.ts +112 -0
  88. package/src/generated/ts/rpc-result.ts +126 -0
  89. package/src/generated/ts/snapshot-request.ts +19 -5
  90. package/src/generated/ts-ser.ts +109 -16
  91. package/src/generated/wire-registry.json +7 -3
  92. package/src/schema/index.ts +45 -1
  93. package/src/transport/transport.ts +117 -77
  94. package/src/bridge/nats.ts +0 -309
  95. package/src/events/cluster.ts +0 -732
  96. package/src/events/hub.ts +0 -481
  97. package/src/events/types.ts +0 -378
  98. package/src/transport/stats.ts +0 -48
package/README.md CHANGED
@@ -292,9 +292,12 @@ createServer({
292
292
  replay: { historySize: 64 }, // per-topic last-value replay on subscribe
293
293
  authenticate: async (req) => checkToken(req.headers.get("authorization")),
294
294
  allowedOrigins: ["http://localhost:3000"],
295
- token: "shared-secret", // or (tok) => boolean
295
+ token: "shared-secret", // or (tok) => boolean — constant-time compared
296
296
  maxConnections: 10_000,
297
297
  maxMessageSize: 64 * 1024,
298
+ rateLimit: { messagesPerSecond: 100, burst: 200, policy: "drop" }, // per-conn inbound bucket ("close" → 1008)
299
+ authorizeTopic: (topic, ws) => topic !== "admin", // gate room joins (all paths)
300
+ authorizeGroup: (group, ws) => ws.data.userId != null, // gate group joins
298
301
  int64Guard: "warn", // "off" | "throw" | "warn"
299
302
  nats: { servers: ["nats://localhost:4222"], inbound: true }, // optional NATS bridge
300
303
  tls: { keyFile, certFile }, // enables wss://
package/docs/ai/TREE.md CHANGED
@@ -5,10 +5,10 @@
5
5
  > curated maps live in `docs/architecture.md` + `docs/wire-format.md`
6
6
  > (and `AGENTS.md` for agents).
7
7
 
8
- - package: `@ignex/nova` v0.1.1
8
+ - package: `@ignex/nova` v0.1.3
9
9
  - engines: {"bun":">=1.4"}
10
10
  - rust crate: `ignex-nova-ffi` v0.1.0
11
- - scripts (19): `generate`, `build:rust`, `build:client`, `build:dist`, `build`, `prebuild`, `test`, `lint`, `typecheck`, `verify`, `pack:check`, `gen:ai-map`, …
11
+ - scripts (20): `generate`, `build:rust`, `build:client`, `build:dist`, `build`, `prebuild`, `test`, `lint`, `typecheck`, `verify`, `pack:check`, `gen:ai-map`, …
12
12
 
13
13
  ## src/
14
14
 
@@ -19,7 +19,11 @@ src/
19
19
  │ ├─ default.ts
20
20
  │ └─ types.ts
21
21
  ├─ bridge/
22
- │ ├─ nats.ts
22
+ │ ├─ nats/
23
+ │ │ ├─ inbound.ts
24
+ │ │ ├─ index.ts
25
+ │ │ ├─ real-transport.ts
26
+ │ │ └─ types.ts
23
27
  │ └─ subjects.ts
24
28
  ├─ codegen/
25
29
  │ ├─ constants.ts
@@ -32,10 +36,17 @@ src/
32
36
  │ ├─ ts-ser-gen.ts
33
37
  │ └─ typebox-to-fbs.ts
34
38
  ├─ core/
39
+ │ ├─ server/
40
+ │ │ ├─ client-info.ts
41
+ │ │ ├─ http-routes.ts
42
+ │ │ ├─ index.ts
43
+ │ │ ├─ metrics-view.ts
44
+ │ │ └─ socket-lifecycle.ts
35
45
  │ ├─ auth.ts
36
46
  │ ├─ backpressure.ts
37
47
  │ ├─ client-heartbeat.ts
38
48
  │ ├─ client-reconnect.ts
49
+ │ ├─ client-rpc.ts
39
50
  │ ├─ client-state.ts
40
51
  │ ├─ client-wire.ts
41
52
  │ ├─ client.ts
@@ -43,24 +54,59 @@ src/
43
54
  │ ├─ int64-guard.ts
44
55
  │ ├─ metrics.ts
45
56
  │ ├─ outbound.ts
57
+ │ ├─ rate-limit.ts
46
58
  │ ├─ replay.ts
59
+ │ ├─ resume.ts
47
60
  │ ├─ ring.ts
48
61
  │ ├─ rooms.ts
49
62
  │ ├─ routing.ts
50
- │ ├─ server.ts
51
- │ └─ state.ts
63
+ │ ├─ state.ts
64
+ │ └─ topic-log.ts
52
65
  ├─ events/
66
+ │ ├─ cluster/
67
+ │ │ ├─ dedupe.ts
68
+ │ │ ├─ envelope.ts
69
+ │ │ ├─ index.ts
70
+ │ │ ├─ keys.ts
71
+ │ │ ├─ kinds.ts
72
+ │ │ ├─ presence-table.ts
73
+ │ │ ├─ presence.ts
74
+ │ │ ├─ redis-client.ts
75
+ │ │ ├─ store-memory.ts
76
+ │ │ ├─ store-redis.ts
77
+ │ │ ├─ subjects.ts
78
+ │ │ ├─ sync.ts
79
+ │ │ ├─ transport-nats.ts
80
+ │ │ └─ transport-redis.ts
81
+ │ ├─ hub/
82
+ │ │ ├─ context-factory.ts
83
+ │ │ ├─ dispatch.ts
84
+ │ │ ├─ index.ts
85
+ │ │ ├─ internal.ts
86
+ │ │ ├─ metrics-snapshot.ts
87
+ │ │ └─ resolve-cluster.ts
88
+ │ ├─ types/
89
+ │ │ ├─ client.ts
90
+ │ │ ├─ cluster.ts
91
+ │ │ ├─ context.ts
92
+ │ │ ├─ emit-target.ts
93
+ │ │ ├─ groups.ts
94
+ │ │ ├─ hub.ts
95
+ │ │ ├─ index.ts
96
+ │ │ ├─ metrics.ts
97
+ │ │ └─ options.ts
53
98
  │ ├─ clients.ts
54
- │ ├─ cluster.ts
99
+ │ ├─ cluster-rpc.ts
55
100
  │ ├─ data.ts
101
+ │ ├─ delivery.ts
56
102
  │ ├─ emit.ts
57
103
  │ ├─ global.ts
58
104
  │ ├─ groups.ts
59
- │ ├─ hub.ts
60
105
  │ ├─ index.ts
61
106
  │ ├─ queue.ts
62
107
  │ ├─ registry.ts
63
- └─ types.ts
108
+ ├─ schedule.ts
109
+ │ └─ trace.ts
64
110
  ├─ generated/
65
111
  │ ├─ fbs/
66
112
  │ │ └─ backend.fbs
@@ -82,6 +128,10 @@ src/
82
128
  │ │ ├─ portfolio-position.ts
83
129
  │ │ ├─ portfolio-snapshot.ts
84
130
  │ │ ├─ quote.ts
131
+ │ │ ├─ resume.ts
132
+ │ │ ├─ resumed.ts
133
+ │ │ ├─ rpc-call.ts
134
+ │ │ ├─ rpc-result.ts
85
135
  │ │ ├─ side.ts
86
136
  │ │ ├─ snapshot-request.ts
87
137
  │ │ ├─ subscribe.ts
@@ -102,7 +152,6 @@ src/
102
152
  ├─ transport/
103
153
  │ ├─ byte-buffer-pool.ts
104
154
  │ ├─ scratch.ts
105
- │ ├─ stats.ts
106
155
  │ └─ transport.ts
107
156
  └─ server.ts
108
157
  ```
@@ -158,8 +207,11 @@ test/
158
207
  ├─ bidirectional.test.ts
159
208
  ├─ bindings-gen.test.ts
160
209
  ├─ byte-buffer-pool.test.ts
210
+ ├─ cluster-v2.test.ts
211
+ ├─ data-integrity-edge.test.ts
161
212
  ├─ direct.test.ts
162
213
  ├─ e2e.test.ts
214
+ ├─ efficiency.test.ts
163
215
  ├─ events-cluster.test.ts
164
216
  ├─ events.test.ts
165
217
  ├─ ffi.test.ts
@@ -171,12 +223,19 @@ test/
171
223
  ├─ metrics.test.ts
172
224
  ├─ nats-bridge.test.ts
173
225
  ├─ nats-integration.test.ts
226
+ ├─ performance.test.ts
174
227
  ├─ reconnect.test.ts
228
+ ├─ resume.test.ts
175
229
  ├─ ring.test.ts
176
230
  ├─ rooms.test.ts
177
231
  ├─ roundtrip.test.ts
232
+ ├─ rpc.test.ts
233
+ ├─ security-hardening.test.ts
234
+ ├─ security-live-fuzz.test.ts
178
235
  ├─ security.test.ts
236
+ ├─ stability-resilience.test.ts
179
237
  ├─ targeting.test.ts
238
+ ├─ trace.test.ts
180
239
  └─ wire.test.ts
181
240
  ```
182
241
 
@@ -185,6 +244,7 @@ test/
185
244
  ```
186
245
  bench/
187
246
  ├─ BASELINE.md
247
+ ├─ dispatch.ts
188
248
  ├─ ffi-margin.ts
189
249
  ├─ measure.ts
190
250
  ├─ serialize.ts
@@ -38,7 +38,7 @@ are thin re-export shims that keep the npm entrypoints (`@ignex/nova/server`,
38
38
  `@ignex/nova/client`) and the `dist` build stable; the implementation lives in
39
39
  `src/core/`.
40
40
 
41
- - **Composition roots** (`src/core/server.ts`, `src/core/client.ts`) — the only
41
+ - **Composition roots** (`src/core/server/`, `src/core/client.ts`) — the only
42
42
  places that know how the pieces fit together. `createServer(options)` builds
43
43
  the `ServerState`, wires `Bun.serve`, and returns a plain API object;
44
44
  `createClient(url, opts)` builds the client state and returns a plain API
@@ -50,7 +50,9 @@ are thin re-export shims that keep the npm entrypoints (`@ignex/nova/server`,
50
50
  the per-socket queue), `routing` (inbound dispatch), and the client's
51
51
  `client-wire` / `client-reconnect` (pure backoff math) / `client-heartbeat`.
52
52
  - **Factories** for encapsulated mutable state: `createMetrics()`, `createScratch()`
53
- (reusable zero-alloc output buffer), `createStats()` (encode-path counters).
53
+ (reusable zero-alloc output buffer), per-event `EncodeRecord`s (encode-path
54
+ counters resolved eagerly at `createTransport()` — instantiation time, not
55
+ first-encode).
54
56
  `int64-guard` intentionally stays a module-global (cheap `off`-mode no-op) so
55
57
  threading it through the generated encoders can't cost the ~200ns hot path.
56
58
  - **Dev discipline**: `bun run lint` (oxlint with FP rules: no-var,
@@ -82,21 +84,42 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
82
84
  - `public/server.ts`, `public/client.ts`, `public/nats.ts` — thin re-export shims
83
85
  (npm entrypoints `@ignex/nova/server` / `client` / `nats` + the `dist` build).
84
86
  Implementation is in `src/core/` + `src/bridge/`.
85
- - `src/core/server.ts` — `createServer` composition root: `Bun.serve`, client
86
- registry (id → socket), rooms, groups, inbound routing, control frames,
87
- auth/origin/token gates, backpressure, replay history, metrics, NATS bridge
88
- hook, graceful drain, `/health` + `/clients`.
87
+ - `src/core/server/index.ts` — `createServer` composition root: `Bun.serve`,
88
+ client registry (id → socket), rooms, groups, inbound routing, control
89
+ frames, auth/origin/token gates, backpressure, replay history, metrics,
90
+ NATS bridge hook, graceful drain, `/health` + `/clients`. Decomposed into
91
+ sibling modules: `client-info.ts` (pure introspection mapper),
92
+ `http-routes.ts` (fetch handler), `socket-lifecycle.ts` (open/close as
93
+ `(state, ws)` actions), `metrics-view.ts` (pure snapshot assembly).
89
94
  - `src/core/client.ts` — `createClient` composition root: typed
90
95
  `on`/`send`/`subscribe`/`joinGroup`, reconnect with backoff, heartbeat, status
91
- events, `clientId`/`groups` (from the `welcome` control frame).
92
- - `src/core/{state,auth,rooms,groups,replay,backpressure,outbound,routing}.ts`
96
+ events, `clientId`/`groups` (from the `welcome` control frame); the rpc
97
+ plumbing (`client.request`) lives in `client-rpc.ts`.
98
+ - `src/core/{state,auth,rooms,groups,replay,backpressure,outbound,routing,resume,rate-limit}.ts` —
93
99
  server action modules over the explicit `ServerState`. `groups.ts` mirrors
94
100
  `rooms.ts` (targeting sets, no replay); `state.clients` is the id→socket
95
- registry, `state.groups` the group→members index.
101
+ registry, `state.groups` the group→members index. `rate-limit.ts` is the
102
+ per-connection token bucket (`options.rateLimit`, default off); `auth.ts`
103
+ compares literal bearer tokens in constant time and gates the HTTP admin
104
+ surface; topic/group joins are authorized via `authorizeTopic` /
105
+ `authorizeGroup`. `resume.ts` is gap-free delivery (below). `replay.ts`
106
+ serves per-topic snapshots (`snapshotRequest { topic, fromSeq }`) and feeds
107
+ the optional durable topic log; `topic-log.ts` is the pluggable durability
108
+ seam (`createMemoryTopicLog()` ships in-repo).
109
+ - **Gap-free delivery** (`src/core/resume.ts`, envelope v2): with
110
+ `createServer({ resume })` every APP frame is stamped (in place, pre-`ws.send`)
111
+ with a per-connection delivery seq and recorded in a bounded per-connection
112
+ history ring. A client that detects a hole sends `resume { lastSeq }`; the
113
+ server replays from the ring with ORIGINAL seqs (in-order, duplicate-free).
114
+ On disconnect the ring parks in a bounded/TTL'd graveyard keyed by client id;
115
+ a reconnecting session adopts it via `hello { lastSeq }`. Control frames are
116
+ never stamped (no ordering obligations), and external copies (NATS bridge /
117
+ cluster envelope) are always taken BEFORE stamping mutates the scratch.
96
118
  - `src/core/{client-state,client-wire,client-reconnect,client-heartbeat}.ts` —
97
119
  client action modules over the explicit client state.
98
- - `src/bridge/{nats,subjects}.ts` — optional NATS bridge: `createNatsBridge`
99
- (injectable `NatsTransport` for tests; eager non-blocking connect with retry),
120
+ - `src/bridge/` — optional NATS bridge (`createNatsBridge`;
121
+ injectable `NatsTransport` for tests; eager non-blocking connect with retry;
122
+ `nats/{index,types,real-transport,inbound}.ts` + `subjects.ts`),
100
123
  subject builders (`ignex.broadcast.*` / `ignex.topic.*` / `ignex.group.*` /
101
124
  inbound `ignex.inbound.>`). Outbound frames are copied from the shared
102
125
  scratch; inbound frames are decoded via `readFrameHeader`/`decodePayload` and
@@ -105,15 +128,28 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
105
128
  - `src/core/metrics.ts`, `src/core/int64-guard.ts` — `createMetrics()` factory
106
129
  + the exact-int64 safety net.
107
130
  - `src/events/*` — the events layer (opt-in via `createServer({ events })`,
108
- public entry `@ignex/nova/events` → `public/events.ts`): `hub.ts` composition
109
- root (`server.events`, binds the module-global `emit`/`on` singleton),
110
- `types.ts` (`EventClient` records with `userId` + per-connection `data`,
111
- `EmitTarget` discriminated union, hub/options interfaces), `registry.ts`
112
- (multi-handler dispatch with isolation), `clients.ts`/`data.ts` (client
113
- store: byId + byUser index), `groups.ts` (client groups reusing the
114
- transport registry + user groups), `emit.ts` (encode-once local fan-out +
115
- bridge + cluster), `cluster.ts` (origin-tagged envelope, NATS/Redis/custom
116
- transports, presence, shared-state indexes), `queue.ts` (bounded offload
131
+ public entry `@ignex/nova/events` → `public/events.ts`): `hub/` composition
132
+ root (`server.events`, binds the module-global `emit`/`on` singleton;
133
+ decomposed into `context-factory.ts` (cached handler contexts),
134
+ `dispatch.ts` (reliability-aware dispatch), `metrics-snapshot.ts` (pure
135
+ snapshot assembly), `resolve-cluster.ts` (transport resolution)),
136
+ `types/` (`EventClient` records with `userId` + per-connection `data`,
137
+ `EmitTarget` discriminated union, hub/options interfaces one module per
138
+ concern behind a barrel), `registry.ts`
139
+ (multi-handler dispatch with isolation copy-on-write lists,
140
+ allocation-free dispatch, plus a settling variant for retries), `trace.ts`
141
+ (the zero-GC event trace ring behind `server.getEventTrace()`),
142
+ `clients.ts`/`data.ts` (client store: byId + byUser index), `groups.ts`
143
+ (client groups reusing the transport registry + user groups), `emit.ts`
144
+ (encode-once; bridge + cluster copies FIRST — pristine frames — then the
145
+ stamped local fan-out), `cluster/` (v2 envelope codec in `envelope.ts`,
146
+ presence codec + table, dedupe window, shared-state keys, NATS/Redis
147
+ transports and memory/Redis state stores; ROUTED targeted delivery via
148
+ per-instance subjects; broker-redelivery dedupe window), `delivery.ts`
149
+ (opt-in handler retry/backoff + dead-letter sink via `events.handlers`),
150
+ `schedule.ts` (`hub.schedule(name, payload, target, delayMs)` + cancel),
151
+ `cluster-rpc.ts` (cross-instance request/response: `hub.call` /
152
+ `hub.onMethod` over the cluster transport), `queue.ts` (bounded offload
117
153
  workers that keep all cluster/state work off the WS hot path), `global.ts`
118
154
  (the importable `emit`/`emitToGroup`/… singleton).
119
155
  - `src/transport/{transport,scratch,stats}.ts` — object → frame encoding:
@@ -140,11 +176,11 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
140
176
  `publishToGroup(group, …)` (server-side targeting sets — from auth metadata,
141
177
  `joinGroup(id, group)`, or client `joinGroup` control frames).
142
178
  - **Bridge**: `createServer({ nats })` creates a `NatsBridge`. The fan-out path
143
- (`fanOutAll`) encodes once and reuses that frame for WS clients AND NATS
144
- (`frame.slice()` copy — the scratch is reused). Subjects are derived by
145
- `src/bridge/subjects.ts`. Inbound NATS events are decoded and forwarded via
146
- `fanOutAll` (no bridge call → loop prevention). All bridge counters fold into
147
- `server.getMetrics()`.
179
+ encodes once; the NATS copy is taken BEFORE per-socket delivery-seq stamping
180
+ mutates the scratch header, so external consumers see pristine frames.
181
+ Subjects are derived by `src/bridge/subjects.ts`. Inbound NATS events are
182
+ decoded and forwarded via `fanOutAll` (no bridge call → loop prevention). All
183
+ bridge counters fold into `server.getMetrics()`.
148
184
 
149
185
  ## Why it's fast
150
186
 
@@ -167,5 +203,17 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
167
203
  `Type.BigInt()` fields for exact values, or enable `int64Guard`.
168
204
  - The direct fast path covers flat types + packed vectors; nested single-object
169
205
  tables fall back to JSON (observable via metrics).
170
- - Full gap-based replay (per-frame sequence numbers) is a future extension;
171
- today the server replays bounded per-topic history on subscribe.
206
+ - Resume history is bounded (`resume.historySize`, default 256 frames) and the
207
+ cross-session graveyard is TTL'd (`resume.ttlMs`, default 60s): a hole older
208
+ than what is retained is reported `resumed { ok: false }` — clients should
209
+ resubscribe topics for a fresh snapshot. Frames published while NO session
210
+ for a client id exists are not buffered per-client (that's an offline-inbox
211
+ feature, not resume).
212
+ - The durable topic log seam ships with a process-local implementation
213
+ (`createMemoryTopicLog`); production adapters (NATS JetStream / Redis
214
+ Streams / filesystem) implement the same three-method interface.
215
+ - Cluster envelope v2 is not understood by v1 peers (rolling upgrades count
216
+ decode errors on the old instances until they are replaced).
217
+ - Cross-instance rpc `.any` calls are delivered to every instance; the first
218
+ response wins and later responders still execute their handlers (keep
219
+ `.any` methods idempotent, or address a specific instance).
package/docs/events.md CHANGED
@@ -142,15 +142,97 @@ deferred to a bounded offload queue (`events.queue`, drop-newest on overflow).
142
142
  - Self-delivery dedupe: frames carry the origin `instanceId`
143
143
  (`cluster.instanceId`, random by default); an instance drops its own frames,
144
144
  so a broadcast is delivered exactly once per socket.
145
+ - **Broker-redelivery dedupe**: every message carries a unique id; a durable
146
+ broker that redelivers after reconnect gets its duplicates dropped inside the
147
+ receiver's window (`metrics.events.clusterDroppedDupe`).
148
+ - **Routed targeted delivery**: `emitToClient` / `emitToUser` consult cluster
149
+ presence and are published ONLY to the per-instance subject of the instance(s)
150
+ that hold the destination socket(s) — not to the whole mesh. Unknown targets
151
+ fall back to the full-mesh wildcard (visible in
152
+ `metrics.events.clusterRouted` vs `clusterPublished`).
145
153
  - **Presence with no shared state**: join/leave + periodic heartbeat messages
146
- — `hub.clusterClients()` lists connections on other instances.
154
+ — `hub.clusterClients()` lists connections on other instances;
155
+ `hub.clusterInstances()` lists the other instances themselves.
147
156
  - **Shared state store** (`cluster.state`, default per-instance memory;
148
157
  production: `createRedisStateStore(...)`): user→clients index
149
158
  (`clusterUserClients`), cluster group membership, cluster-wide client data.
159
+ - **Cross-instance rpc** (`hub.call` / `hub.onMethod`): request/response
160
+ between instances over the same transport — targeted
161
+ (`call(method, args, { instanceId })`) or any-instance
162
+ (`call(method)` — first response wins; keep `.any` handlers idempotent).
163
+ Timeouts bound every call; counters fold into `metrics.events.rpcSent` /
164
+ `rpcReceived`.
165
+ - **Trace propagation**: emits carry a unique trace id through the cluster
166
+ envelope; remote `onServerEvent` contexts expose it as `ctx.traceId` for
167
+ end-to-end correlation.
150
168
  - **Server-side events**: other instances' events reach `onServerEvent`
151
169
  handlers with `ctx.source === "remote"` (delivered to clients AND handlers);
152
170
  NATS-inbound events reach them with `source === "bridge"`.
153
171
 
172
+ ## Handler reliability — retries + dead letters
173
+
174
+ Opt in via `createServer({ events: { handlers: {...} } })`; without it,
175
+ dispatch is fire-and-forget with per-handler error isolation only.
176
+
177
+ ```ts
178
+ createServer({
179
+ port: 3000,
180
+ events: {
181
+ handlers: {
182
+ retries: 2, // extra attempts after the first try
183
+ backoffMs: 100, // doubling: 100ms, 200ms, …
184
+ dlq(info) { /* { name, payload, err, attempts } */ },
185
+ },
186
+ },
187
+ });
188
+ ```
189
+
190
+ A handler that keeps failing is retried on the same event, then handed to
191
+ `dlq`. Counters live in `server.getMetrics().events.handlerRetries` /
192
+ `.dlqCount`.
193
+
194
+ ## Scheduled emits (time-based events)
195
+
196
+ ```ts
197
+ const id = hub.schedule("reminder.push", payload, { type: "user", userId }, delayMs);
198
+ hub.cancelScheduled(id); // true if it had not fired yet
199
+ hub.scheduledCount; // pending count
200
+ ```
201
+
202
+ Scheduled emits run through the normal emit path at fire time — identical
203
+ targeting, bridging and cluster routing semantics. Timers are cleared on
204
+ `hub.close()`.
205
+
206
+ ## Request/response
207
+
208
+ Two complementary layers:
209
+
210
+ - **Client ⇄ this server**: `client.request(name, payload)` sends an `rpcCall`
211
+ control frame (correlation id + timeout) and awaits the responder registered
212
+ via `server.handle(name, fn)` or `hub.onRequest(name, fn)`. The response
213
+ reuses the SAME event schema both directions.
214
+ - **Instance ⇄ instance**: `hub.call` / `hub.onMethod` (above).
215
+
216
+ ## Event trace (what fired — debugger visibility)
217
+
218
+ Every server owns an **event trace ring** (`src/events/trace.ts`) that records
219
+ each fired event — emitted (`out.emit`), published through the server API
220
+ (`out.publish`), received from a client (`in.client`), from another instance
221
+ (`in.remote`), or from the NATS bridge (`in.bridge`) — with its wire name,
222
+ target kind + key (topic/group/userId/clientId), frame size and timestamp.
223
+
224
+ - **Zero-GC by construction**: scalars live in pre-allocated TypedArrays,
225
+ strings are held by reference in reused slots; row objects materialize only
226
+ when the ring is read. Recording is a handful of typed-array stores (~ns).
227
+ - Read it with `server.getEventTrace({ limit, direction, name })` →
228
+ `{ enabled, capacity, stats, recent }` (newest first) and reset it with
229
+ `server.clearEventTrace()`. `stats.byName` / `stats.last` power at-a-glance
230
+ panels (the ignex debugbar's Nova panel and MCP tool use exactly this).
231
+ - Configure with `createServer({ trace: { capacity, enabled, capturePayloadChars } })`.
232
+ Default: on, capacity 1024, no payload capture. `IGNEX_NOVA_TRACE=0`
233
+ disables recording globally; set `capturePayloadChars` (e.g. 512) to store
234
+ truncated JSON previews of each payload (opt-in — costs a stringify/event).
235
+
154
236
  ## Metrics & shutdown
155
237
 
156
238
  `server.getMetrics().events` (or `hub.metrics()`) exposes emitted counts per
@@ -52,6 +52,16 @@ Rules (same as the built-in registry):
52
52
  - The transport control events (hello / welcome / subscribe / unsubscribe /
53
53
  joinGroup / leaveGroup / snapshotRequest / ping / pong) are ALWAYS included —
54
54
  you can add your own but cannot override the standard ones.
55
+ - **Unsupported field types fail loudly at generate time.** `Type.Any()`,
56
+ `Type.Unknown()`, `Type.Date()`, `Type.Null()`, `Type.Record(...)`, and mixed
57
+ unions (e.g. `number | string`) have no FlatBuffers representation and are
58
+ rejected with a clear error — they used to be *silently coerced to `string`
59
+ fields*, which forced `JSON.stringify(...)` at the call site and put raw JSON
60
+ text inside the (still-binary) frame. If you genuinely need an opaque JSON
61
+ payload, model it explicitly as `Type.String()` and pass the JSON string —
62
+ the frame stays valid, but the JSON bytes appear verbatim in it. Prefer typed
63
+ fields (nested tables, vectors, scalars) so the wire carries structure
64
+ instead of JSON text.
55
65
 
56
66
  ## 2. Generate bindings
57
67
 
@@ -8,18 +8,25 @@ client from the generated FlatBuffers schema — no Bun, no Rust, no FFI needed.
8
8
  Every WebSocket **binary** frame is:
9
9
 
10
10
  ```
11
- ┌──────────┬───────────────────┬──────────────────────────────────┐
12
- │ version │ event_id │ size-prefixed FlatBuffer │
13
- │ 1 byte │ u32 (LE) │ (flatc: 4-byte size prefix + buf)│
14
- └──────────┴───────────────────┴──────────────────────────────────┘
15
- offset 0 1..5 5..
11
+ ┌──────────┬───────────────────┬─────────┬────────────┬──────────────────────────────────┐
12
+ │ version │ event_id │ flags │ seq │ size-prefixed FlatBuffer │
13
+ │ 1 byte │ u32 (LE) │ 1 byte │ u64 (LE) │ (flatc: 4-byte size prefix + buf)│
14
+ └──────────┴───────────────────┴─────────┴────────────┴──────────────────────────────────┘
15
+ offset 0 1..5 5 6..14 14..
16
16
  ```
17
17
 
18
- - `version` = `WIRE_VERSION` (currently `1`). A peer MUST drop frames whose
19
- version it doesn't recognize (the decoders return `null`).
18
+ - `version` = `WIRE_VERSION` (currently `2` v2 added the delivery header).
19
+ A peer MUST drop frames whose version it doesn't recognize (the decoders
20
+ return `null`).
20
21
  - `event_id` = **FNV-1a 32-bit hash of the event name** (e.g.
21
22
  `fnv1a32("quote")`). Stable across schema reordering; collisions are rejected
22
23
  at generate time. The id→name table is emitted in `src/generated/registry.ts`.
24
+ - `flags` — bit0 = "seq-valid" (see below); all other bits reserved, send 0.
25
+ - `seq` — per-CONNECTION delivery sequence, stamped by the SERVER on every
26
+ **app** frame just before `ws.send` (Bun copies synchronously). Clients use
27
+ it for gap detection + resume. Control frames are never stamped; frames that
28
+ were not per-destination stamped carry `flags=0, seq=0`. Offsets are derived
29
+ from `WIRE_HEADER_LEN`: flags at `len-9`, seq at `len-8..len`.
23
30
  - The payload is a **size-prefixed FlatBuffer** (built by flatc, both Rust
24
31
  builders and the TS object API) with the root table for that event id.
25
32
 
@@ -27,6 +34,18 @@ There is **no per-frame checksum and no authentication** — the envelope id byt
27
34
  is trusted. Integrity and AuthN are the application's job (see
28
35
  [Security](#security)).
29
36
 
37
+ ## String fields carry raw UTF-8
38
+
39
+ A FlatBuffer `string` field stores its content as raw UTF-8 bytes in the frame,
40
+ so any JSON document placed in a string field appears **verbatim** inside the
41
+ (binary) frame — expected behavior, not corruption. If a captured frame shows
42
+ `{"items":[...]}` text in the payload, the event schema models that data as a
43
+ `string` field (a JSON-in-a-string envelope, or a field written as
44
+ `Type.Any()`/`Type.Unknown()`, which the codegen now rejects at generate time —
45
+ see docs/generic-bindings.md). Prefer typed fields — `Type.Array(Table)` for
46
+ lists, `Type.Integer()` for counts — so the frame carries structure instead of
47
+ JSON text.
48
+
30
49
  ## Event ids
31
50
 
32
51
  Event ids are stable hashes, so adding events, reordering the registry, or
@@ -46,13 +65,17 @@ SAME codegen, but are routed internally (never delivered to app handlers):
46
65
 
47
66
  | name | direction | payload |
48
67
  | --- | --- | --- |
49
- | `hello` | both | `{ version, caps: string[], lastSeq }` — sent on connect; version mismatch → close(1002) |
68
+ | `hello` | both | `{ version, caps: string[], lastSeq }` — sent on connect; version mismatch → close(1002). `lastSeq > 0` asks the server to resume this client id's delivery stream after that seq (cross-session resume) |
50
69
  | `welcome` | server→client | `{ clientId, groups: string[] }` — identity assigned to this connection (auth metadata or UUID) + its server-side groups |
51
70
  | `subscribe` | client→server | `{ topic }` — join a room (server replies with replay history) |
52
71
  | `unsubscribe` | client→server | `{ topic }` |
53
72
  | `joinGroup` | client→server | `{ group }` — join a server-side group |
54
73
  | `leaveGroup` | client→server | `{ group }` |
55
- | `snapshotRequest` | client→server | `{ topic }` — reserved for future replay control |
74
+ | `snapshotRequest` | client→server | `{ topic, fromSeq }` — replay recorded topic history STRICTLY after `fromSeq` (0 = from the beginning of retained history); hydrates from the durable topic log when the in-memory ring has moved on |
75
+ | `resume` | client→server | `{ lastSeq }` — same-connection gap recovery: re-send everything after the last CONTIGUOUS delivery seq (original seqs preserved) |
76
+ | `resumed` | server→client | `{ ok: boolean, from: number }` — ack before the replayed frames; `ok:false` = the hole is older than the retained history (partial recovery — resubscribe topics for a fresh snapshot) |
77
+ | `rpcCall` | client→server | `{ id, name, payloadB64 }` — request/response: `payloadB64` is base64 of a full wire frame encoded with event `name`'s schema; the response reuses the SAME schema |
78
+ | `rpcResult` | server→client | `{ id, ok, err, payloadB64 }` — correlated by `id`; `ok:false` carries a plain-text `err` |
56
79
  | `ping` | client→server | `{ ts }` — heartbeat |
57
80
  | `pong` | server→client | `{ ts }` |
58
81
 
@@ -118,7 +141,7 @@ consumer in any language:
118
141
 
119
142
  1. reads `version` (byte 0) and `event_id` (bytes 1..5) from the frame,
120
143
  2. maps `event_id` → name via `wire-registry.json`,
121
- 3. decodes the size-prefixed FlatBuffer from `frame[5..]` with a flatc build of
144
+ 3. decodes the size-prefixed FlatBuffer from `frame[14..]` with a flatc build of
122
145
  `src/generated/fbs/backend.fbs`.
123
146
 
124
147
  See `examples/nats-consumer.ts` for a working reference.
@@ -137,12 +160,26 @@ Clients send `ping` every `heartbeatMs` (default 15000). The server replies
137
160
  long-idle sockets alive; a client that misses `heartbeatMisses` pongs force-
138
161
  closes and reconnects.
139
162
 
140
- ## Sequence / replay
141
-
142
- The current model replays **recent history on subscribe** (bounded ring per
143
- topic, last-value snapshot). Full gap-based replay (per-frame sequence numbers
144
- in the envelope + `hello.lastSeq` negotiation) is a documented future extension;
145
- the envelope reserves no space for a per-frame seq today.
163
+ ## Sequence / resume / request-response
164
+
165
+ - **Delivery seqs (v2)**: when the server starts with `resume`, every app frame
166
+ it writes to a socket carries an increasing per-connection `seq` (envelope
167
+ flags bit0). Clients track the stream; a GAP (lost frame backpressure drop,
168
+ transport hiccup) is recovered by sending `resume { lastSeq }`; the server
169
+ replays from the connection's bounded sent-history ring with ORIGINAL seqs so
170
+ redelivery is in-order and duplicate-free. On disconnect the ring parks in a
171
+ per-client-id graveyard (bounded, TTL'd); a reconnecting session with the
172
+ same auth-pinned id continues the stream via `hello { lastSeq }`.
173
+ - **Topic snapshots**: `snapshotRequest { topic, fromSeq }` replays recorded
174
+ topic history strictly after a topic seq; a durable `topicLog`
175
+ (`createServer({ topicLog })`, e.g. `createMemoryTopicLog()`) hydrates ranges
176
+ the replay ring has already forgotten. Topic-history seqs are a SEPARATE
177
+ counter (global replay order) from per-connection delivery seqs.
178
+ - **Request/response**: `client.request(name, payload)` wraps a full wire frame
179
+ (base64) in an `rpcCall` control frame with a correlation id; the server's
180
+ registered responder (via `server.handle(name, fn)` or `hub.onRequest`)
181
+ returns the response payload encoded with the SAME event schema. Timeouts and
182
+ errors are correlated client-side.
146
183
 
147
184
  ## Security
148
185
 
@@ -151,7 +188,17 @@ the envelope reserves no space for a per-frame seq today.
151
188
  happily decode the payload as the claimed type. Add integrity at the
152
189
  application layer if the wire crosses an untrusted boundary.
153
190
  - Server-side guards (all optional): `authenticate(req)` async hook, origin
154
- allowlist, bearer `token`, `maxConnections`, `maxMessageSize`.
191
+ allowlist, bearer `token` (literal tokens compared in constant time),
192
+ `maxConnections`, `maxMessageSize`, per-connection inbound rate limiting
193
+ (`rateLimit`: token bucket over app AND control frames — drop or close 1008,
194
+ counted in `metrics.rateLimited`).
195
+ - Join authorization: `authorizeTopic(topic, ws)` / `authorizeGroup(group, ws)`
196
+ gate EVERY room/group join path (control frames, programmatic joins, and
197
+ auth-seeded membership); rejections are counted in `metrics.rejectedJoins`.
198
+ Leaving is always allowed.
199
+ - Introspection: `GET /clients` is gated by the same token/`authenticate`
200
+ surface when one is configured (public only on servers with no auth at all);
201
+ `/health` stays public (counters only).
155
202
  - Slow consumers: `backpressure` policy (`drop-oldest`/`drop-newest`/
156
203
  `disconnect`) bounds per-socket buffering; without it a hot publish loop can
157
204
  balloon memory.
@@ -168,7 +215,7 @@ the envelope reserves no space for a per-frame seq today.
168
215
  3. Read `version` (byte 0) and `event_id` (bytes 1..5) from each binary frame.
169
216
  Compute ids with FNV-1a 32 over the name (see above) or read the emitted
170
217
  `src/generated/registry.ts` table.
171
- 4. `flatbuffers.ByteBuffer(frame[5..])` + `getSizePrefixedRootAs<T>` (or the
218
+ 4. `flatbuffers.ByteBuffer(frame[14..])` + `getSizePrefixedRootAs<T>` (or the
172
219
  language equivalent) gives you the payload.
173
220
  5. Implement the control frames (at minimum `hello` + `ping`/`pong`) to be a
174
221
  good citizen; `subscribe` for rooms.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignex/nova",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "TypeBox-driven FlatBuffer transport: Rust FFI serializer + Bun WebSocket + typed pub/sub API for server and FE.",
6
6
  "license": "MIT",
@@ -72,6 +72,7 @@
72
72
  "gen:ai-map": "bun scripts/gen-ai-map.ts",
73
73
  "serve": "bun run src/server.ts",
74
74
  "bench:serialize": "bun run bench/serialize.ts",
75
+ "bench:dispatch": "bun run bench/dispatch.ts",
75
76
  "bench:throughput": "bun run bench/throughput.ts",
76
77
  "release": "bun scripts/release.ts",
77
78
  "release:dry": "bun scripts/release.ts --dry-run",
Binary file