@ignex/nova 0.1.1 → 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 (120) hide show
  1. package/README.md +136 -33
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +292 -0
  4. package/docs/architecture.md +101 -28
  5. package/docs/events.md +252 -0
  6. package/docs/generic-bindings.md +207 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +74 -20
  9. package/index.ts +75 -27
  10. package/package.json +13 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +510 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +52 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/generated/backend.rs +503 -0
  21. package/rust/src/transcode/generated.rs +377 -17
  22. package/src/bindings/assemble.ts +73 -0
  23. package/src/bindings/default.ts +65 -0
  24. package/src/bindings/types.ts +113 -0
  25. package/src/bridge/nats/inbound.ts +46 -0
  26. package/src/bridge/nats/index.ts +131 -0
  27. package/src/bridge/nats/real-transport.ts +133 -0
  28. package/src/bridge/nats/types.ts +80 -0
  29. package/src/bridge/subjects.ts +3 -0
  30. package/src/codegen/constants.ts +28 -0
  31. package/src/codegen/direct-gen.ts +564 -0
  32. package/src/codegen/fingerprint.ts +44 -0
  33. package/src/codegen/hash.ts +25 -0
  34. package/src/codegen/registry-gen.ts +246 -0
  35. package/src/codegen/rust-glue-gen.ts +552 -0
  36. package/src/codegen/schema-model.ts +363 -0
  37. package/src/codegen/ts-ser-gen.ts +230 -0
  38. package/src/codegen/typebox-to-fbs.ts +60 -0
  39. package/src/core/auth.ts +67 -5
  40. package/src/core/client-heartbeat.ts +2 -1
  41. package/src/core/client-reconnect.ts +9 -2
  42. package/src/core/client-rpc.ts +75 -0
  43. package/src/core/client-state.ts +63 -8
  44. package/src/core/client-wire.ts +148 -15
  45. package/src/core/client.ts +105 -31
  46. package/src/core/groups.ts +8 -0
  47. package/src/core/metrics.ts +42 -21
  48. package/src/core/outbound.ts +62 -11
  49. package/src/core/rate-limit.ts +69 -0
  50. package/src/core/replay.ts +41 -1
  51. package/src/core/resume.ts +181 -0
  52. package/src/core/rooms.ts +10 -3
  53. package/src/core/routing.ts +144 -12
  54. package/src/core/server/client-info.ts +37 -0
  55. package/src/core/server/http-routes.ts +59 -0
  56. package/src/core/server/index.ts +360 -0
  57. package/src/core/server/metrics-view.ts +53 -0
  58. package/src/core/server/socket-lifecycle.ts +57 -0
  59. package/src/core/state.ts +124 -14
  60. package/src/core/topic-log.ts +86 -0
  61. package/src/events/clients.ts +174 -0
  62. package/src/events/cluster/dedupe.ts +43 -0
  63. package/src/events/cluster/envelope.ts +149 -0
  64. package/src/events/cluster/index.ts +50 -0
  65. package/src/events/cluster/keys.ts +33 -0
  66. package/src/events/cluster/kinds.ts +32 -0
  67. package/src/events/cluster/presence-table.ts +99 -0
  68. package/src/events/cluster/presence.ts +53 -0
  69. package/src/events/cluster/redis-client.ts +50 -0
  70. package/src/events/cluster/store-memory.ts +67 -0
  71. package/src/events/cluster/store-redis.ts +44 -0
  72. package/src/events/cluster/subjects.ts +30 -0
  73. package/src/events/cluster/sync.ts +476 -0
  74. package/src/events/cluster/transport-nats.ts +24 -0
  75. package/src/events/cluster/transport-redis.ts +120 -0
  76. package/src/events/cluster-rpc.ts +196 -0
  77. package/src/events/data.ts +38 -0
  78. package/src/events/delivery.ts +83 -0
  79. package/src/events/emit.ts +173 -0
  80. package/src/events/global.ts +117 -0
  81. package/src/events/groups.ts +118 -0
  82. package/src/events/hub/context-factory.ts +79 -0
  83. package/src/events/hub/dispatch.ts +86 -0
  84. package/src/events/hub/index.ts +536 -0
  85. package/src/events/hub/internal.ts +31 -0
  86. package/src/events/hub/metrics-snapshot.ts +84 -0
  87. package/src/events/hub/resolve-cluster.ts +49 -0
  88. package/src/events/index.ts +61 -0
  89. package/src/events/queue.ts +123 -0
  90. package/src/events/registry.ts +214 -0
  91. package/src/events/schedule.ts +73 -0
  92. package/src/events/trace.ts +283 -0
  93. package/src/events/types/client.ts +68 -0
  94. package/src/events/types/cluster.ts +40 -0
  95. package/src/events/types/context.ts +50 -0
  96. package/src/events/types/emit-target.ts +29 -0
  97. package/src/events/types/groups.ts +35 -0
  98. package/src/events/types/hub.ts +124 -0
  99. package/src/events/types/index.ts +30 -0
  100. package/src/events/types/metrics.ts +52 -0
  101. package/src/events/types/options.ts +62 -0
  102. package/src/generated/direct-ser.ts +148 -60
  103. package/src/generated/fbs/backend.fbs +24 -1
  104. package/src/generated/registry.ts +94 -33
  105. package/src/generated/rust/backend_generated.rs +503 -0
  106. package/src/generated/ts/backend.ts +4 -0
  107. package/src/generated/ts/resume.ts +74 -0
  108. package/src/generated/ts/resumed.ts +88 -0
  109. package/src/generated/ts/rpc-call.ts +112 -0
  110. package/src/generated/ts/rpc-result.ts +126 -0
  111. package/src/generated/ts/snapshot-request.ts +19 -5
  112. package/src/generated/ts-ser.ts +110 -17
  113. package/src/generated/wire-registry.json +7 -2
  114. package/src/native/ffi.ts +85 -28
  115. package/src/schema/index.ts +49 -2
  116. package/src/server.ts +7 -3
  117. package/src/transport/transport.ts +200 -79
  118. package/src/bridge/nats.ts +0 -269
  119. package/src/core/server.ts +0 -294
  120. package/src/transport/stats.ts +0 -44
@@ -5,28 +5,40 @@ a Rust FFI serializer — hidden behind a typed pub/sub API.
5
5
 
6
6
  ## Pipeline
7
7
 
8
+ The wire stack is generated from TypeBox schemas. The built-in registry
9
+ (`src/schema/index.ts`) runs the in-repo orchestrator (`scripts/generate.ts`);
10
+ **any** app can run the same emitters on its own schema via
11
+ `generateBindings` (`@ignex/nova/generate` → `src/codegen/`).
12
+
8
13
  ```
9
14
  src/schema/index.ts (TypeBox — single source of truth: app events + control events)
10
- │ scripts/generate.ts
15
+ │ scripts/generate.ts (or generateBindings() for YOUR schema)
11
16
 
12
17
  backend.fbs ──flatc --ts──▶ src/generated/ts/ (decoders, both sides)
13
18
  └───flatc --rust──▶ rust/src/generated/ (table builders)
14
- scripts/rust-glue-gen.ts ─▶ rust/src/transcode/ (JSON glue + direct-args FFI)
15
- scripts/direct-gen.ts ────▶ src/generated/direct-ser.ts (zero-alloc server encoder)
16
- scripts/ts-ser-gen.ts ─────▶ src/generated/ts-ser.ts (pure-JS browser encoder)
17
- scripts/registry-gen.ts ───▶ src/generated/registry.ts (event routing, both sides)
18
- scripts/generate.ts ────────▶ src/generated/wire-registry.json (name→id map for external consumers)
19
+ src/codegen/rust-glue-gen.ts ─▶ rust/src/transcode/ (JSON glue + direct-args FFI)
20
+ src/codegen/direct-gen.ts ────▶ src/generated/direct-ser.ts (zero-alloc server encoder)
21
+ src/codegen/ts-ser-gen.ts ─────▶ src/generated/ts-ser.ts (pure-JS browser encoder)
22
+ src/codegen/registry-gen.ts ───▶ src/generated/registry.ts (event routing, both sides)
23
+ scripts/generate.ts ───────────▶ src/generated/wire-registry.json (name→id map for external consumers)
19
24
  ```
20
25
 
26
+ Every artifact is also emitted per-user-schema by `generateBindings`
27
+ (`src/codegen/*` emitters, `public/generate.ts`) into the app's
28
+ `ignex/generated/` folder, then assembled into a runtime `Bindings`
29
+ (`src/bindings/`) that `createServer` / `createClient` / `createNatsBridge`
30
+ accept via `options.bindings` (default: the built-in `defaultBindings`). See
31
+ [docs/generic-bindings.md](generic-bindings.md).
32
+
21
33
  ## Functional composition
22
34
 
23
35
  The public API is built by **functional composition over an explicit state
24
36
  object** — no classes, no `this`. `public/server.ts` and `public/client.ts`
25
- are thin re-export shims that keep the npm entrypoints (`ignex-nova/server`,
26
- `ignex-nova/client`) and the `dist` build stable; the implementation lives in
37
+ are thin re-export shims that keep the npm entrypoints (`@ignex/nova/server`,
38
+ `@ignex/nova/client`) and the `dist` build stable; the implementation lives in
27
39
  `src/core/`.
28
40
 
29
- - **Composition roots** (`src/core/server.ts`, `src/core/client.ts`) — the only
41
+ - **Composition roots** (`src/core/server/`, `src/core/client.ts`) — the only
30
42
  places that know how the pieces fit together. `createServer(options)` builds
31
43
  the `ServerState`, wires `Bun.serve`, and returns a plain API object;
32
44
  `createClient(url, opts)` builds the client state and returns a plain API
@@ -38,7 +50,9 @@ are thin re-export shims that keep the npm entrypoints (`ignex-nova/server`,
38
50
  the per-socket queue), `routing` (inbound dispatch), and the client's
39
51
  `client-wire` / `client-reconnect` (pure backoff math) / `client-heartbeat`.
40
52
  - **Factories** for encapsulated mutable state: `createMetrics()`, `createScratch()`
41
- (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).
42
56
  `int64-guard` intentionally stays a module-global (cheap `off`-mode no-op) so
43
57
  threading it through the generated encoders can't cost the ~200ns hot path.
44
58
  - **Dev discipline**: `bun run lint` (oxlint with FP rules: no-var,
@@ -68,29 +82,76 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
68
82
  ## Runtime layout
69
83
 
70
84
  - `public/server.ts`, `public/client.ts`, `public/nats.ts` — thin re-export shims
71
- (npm entrypoints `ignex-nova/server` / `client` / `nats` + the `dist` build).
85
+ (npm entrypoints `@ignex/nova/server` / `client` / `nats` + the `dist` build).
72
86
  Implementation is in `src/core/` + `src/bridge/`.
73
- - `src/core/server.ts` — `createServer` composition root: `Bun.serve`, client
74
- registry (id → socket), rooms, groups, inbound routing, control frames,
75
- auth/origin/token gates, backpressure, replay history, metrics, NATS bridge
76
- 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).
77
94
  - `src/core/client.ts` — `createClient` composition root: typed
78
95
  `on`/`send`/`subscribe`/`joinGroup`, reconnect with backoff, heartbeat, status
79
- events, `clientId`/`groups` (from the `welcome` control frame).
80
- - `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` —
81
99
  server action modules over the explicit `ServerState`. `groups.ts` mirrors
82
100
  `rooms.ts` (targeting sets, no replay); `state.clients` is the id→socket
83
- 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.
84
118
  - `src/core/{client-state,client-wire,client-reconnect,client-heartbeat}.ts` —
85
119
  client action modules over the explicit client state.
86
- - `src/bridge/{nats,subjects}.ts` — optional NATS bridge: `createNatsBridge`
87
- (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`),
88
123
  subject builders (`ignex.broadcast.*` / `ignex.topic.*` / `ignex.group.*` /
89
124
  inbound `ignex.inbound.>`). Outbound frames are copied from the shared
90
125
  scratch; inbound frames are decoded via `readFrameHeader`/`decodePayload` and
91
- forwarded to clients (never re-bridged).
126
+ forwarded to clients (never re-bridged). `subscribeRaw` exposes raw byte
127
+ subscriptions (re-subscribed on reconnect) for the events cluster layer.
92
128
  - `src/core/metrics.ts`, `src/core/int64-guard.ts` — `createMetrics()` factory
93
129
  + the exact-int64 safety net.
130
+ - `src/events/*` — the events layer (opt-in via `createServer({ events })`,
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
153
+ workers that keep all cluster/state work off the WS hot path), `global.ts`
154
+ (the importable `emit`/`emitToGroup`/… singleton).
94
155
  - `src/transport/{transport,scratch,stats}.ts` — object → frame encoding:
95
156
  `encodeToScratch` (direct/JSON), the reusable zero-alloc scratch, and
96
157
  encode-path stats.
@@ -115,11 +176,11 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
115
176
  `publishToGroup(group, …)` (server-side targeting sets — from auth metadata,
116
177
  `joinGroup(id, group)`, or client `joinGroup` control frames).
117
178
  - **Bridge**: `createServer({ nats })` creates a `NatsBridge`. The fan-out path
118
- (`fanOutAll`) encodes once and reuses that frame for WS clients AND NATS
119
- (`frame.slice()` copy — the scratch is reused). Subjects are derived by
120
- `src/bridge/subjects.ts`. Inbound NATS events are decoded and forwarded via
121
- `fanOutAll` (no bridge call → loop prevention). All bridge counters fold into
122
- `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()`.
123
184
 
124
185
  ## Why it's fast
125
186
 
@@ -142,5 +203,17 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
142
203
  `Type.BigInt()` fields for exact values, or enable `int64Guard`.
143
204
  - The direct fast path covers flat types + packed vectors; nested single-object
144
205
  tables fall back to JSON (observable via metrics).
145
- - Full gap-based replay (per-frame sequence numbers) is a future extension;
146
- 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 ADDED
@@ -0,0 +1,252 @@
1
+ # Events layer — typed event-driven system on the FlatBuffer core
2
+
3
+ The events layer (`@ignex/nova/events`, opt-in via `createServer({ events })`) is
4
+ the application-facing, event-driven surface on top of the transport: **an
5
+ events file receives events, and a global emit sends events through
6
+ websockets** — with first-class client records, named groups, and
7
+ cross-instance sync for horizontally scaled deployments.
8
+
9
+ Enable it (everything else is opt-in):
10
+
11
+ ```ts
12
+ import { createServer } from "@ignex/nova/server";
13
+
14
+ const server = createServer({
15
+ port: 3000,
16
+ events: {
17
+ onConnect: (client) => client.data.set("connectedAt", client.connectedAt),
18
+ },
19
+ });
20
+ ```
21
+
22
+ `server.events` is the hub; the module-global singleton
23
+ (`@ignex/nova/events`) is bound to it by default.
24
+
25
+ ## The events file (receiving events)
26
+
27
+ Declare handlers like routes — a dedicated events file where inbound events
28
+ arrive. The handler receives the payload and a context describing **who sent
29
+ it and how to reply**:
30
+
31
+ ```ts
32
+ // app/events.ts
33
+ import { on, emitToUser, emitToGroup } from "@ignex/nova/events";
34
+
35
+ on("chat.message", (payload, ctx) => {
36
+ // ctx.client — the sender's connection record (id, userId, data, groups…)
37
+ // ctx.emit / ctx.emitToUser / … — reply without importing the singleton
38
+ emitToUser(payload.to, "chat.delivered", { id: payload.id });
39
+ });
40
+
41
+ on("order.created", (payload, ctx) => {
42
+ emitToGroup("backoffice", "order.alert", { orderId: payload.orderId });
43
+ });
44
+ ```
45
+
46
+ - `hub.on(name, handler)` / `off` / `once` / `onAny` — multiple handlers per
47
+ event, per-handler error isolation (one throwing handler never blocks the
48
+ others; failures count in `metrics().handlerErrors`).
49
+ - `hub.onServerEvent(name, handler)` — server-side handling of events that
50
+ arrive from OTHER instances or the NATS bridge (`ctx.source` is
51
+ `"remote"`/`"bridge"`, `ctx.client` is undefined). Do NOT re-emit the same
52
+ event from these handlers (loop).
53
+ - `on` auto-allows the event for inbound clients (`server.allowInbound`);
54
+ `onAny` sees every event listed in `events.inbound`.
55
+
56
+ ## The global emit (sending events through websockets)
57
+
58
+ `emit` and friends are importable anywhere — no server reference needed:
59
+
60
+ ```ts
61
+ import { emit, emitToGroup, emitToUser, emitToClient, emitToTopic } from "@ignex/nova/events";
62
+
63
+ emit("quote", { symbol: "AAPL", bid: 180.1, ask: 180.2 }); // broadcast
64
+ emitToGroup("traders", "alert", { text: "halt" }); // group fan-out
65
+ emitToUser("u-42", "order.update", { orderId: "o-1" }); // user's sockets
66
+ emitToClient("c-123", "session.expired", { reason: "idle" }); // one connection
67
+ ```
68
+
69
+ Or the discriminated `EmitTarget` — the API's way of **differentiating**
70
+ between addressing modes:
71
+
72
+ ```ts
73
+ server.events.emit("quote", payload, { type: "group", group: "traders" });
74
+ server.events.emit("quote", payload, { type: "user", userId: "u-42" });
75
+ ```
76
+
77
+ When the cluster is configured, every emit is cluster-aware: the target is
78
+ matched against clients on ALL instances.
79
+
80
+ ## Client records — who is connected, on whose behalf
81
+
82
+ Each active connection is a `client` record:
83
+
84
+ ```ts
85
+ interface EventClient {
86
+ id: string; // connection id (unique per socket)
87
+ userId?: string; // identity this connection acts ON BEHALF OF
88
+ meta?: Record<string, unknown>; // auth metadata
89
+ data: ClientData; // per-connection app store (auto-cleared on close)
90
+ groups: ReadonlySet<string>; // client groups (shared with ws.data)
91
+ topics: ReadonlySet<string>; // joined topics (shared with ws.data)
92
+ connectedAt: number; ip: string; closed: boolean; ws: ServerWebSocket;
93
+ }
94
+ ```
95
+
96
+ - `userId` — "on what behalf": set from `authenticate` (`{ userId }`),
97
+ `hub.setUserId(clientId, userId)`, or later. Several sockets may share one
98
+ `userId` (multi-tab / multi-device); `hub.clientsByUser(userId)` groups
99
+ them and `emitToUser` reaches all of them.
100
+ - `data` — per-connection state (`client.data.set(key, value)` or
101
+ `hub.setClientData(clientId, key, value)`); with a shared state store it
102
+ syncs cluster-wide (`hub.remoteClientData(clientId)`).
103
+ - `hub.client(id)` / `clients()` / `clientCount` — live introspection.
104
+ - Lifecycle hooks: `events.onConnect(client)` (seed data) and
105
+ `events.onDisconnect(client)`.
106
+
107
+ ## Groups — broadcast to groups vs individual users
108
+
109
+ Two group kinds, clearly differentiated:
110
+
111
+ | | `hub.group(name)` | `hub.userGroup(name)` |
112
+ |---|---|---|
113
+ | membership | connection ids | user ids |
114
+ | fan-out | members' sockets | every socket of each member user |
115
+ | shares transport groups | yes (`ws.data.groups`, control frames, `server.joinGroup`) | hub-managed |
116
+ | cluster membership | shared state store (`clusterGroupMembers`) | shared state store (`clusterUserGroupMembers`) |
117
+
118
+ ```ts
119
+ const traders = server.events.group("traders"); // client group
120
+ traders.add(clientId); traders.remove(clientId);
121
+ traders.members(); traders.emit("quote", payload);
122
+
123
+ const ops = server.events.userGroup("ops"); // user group
124
+ ops.add("u-42"); ops.emit("alert", { text: "pager" }); // all of u-42's sockets
125
+ ```
126
+
127
+ ## Horizontal scaling (cluster sync)
128
+
129
+ When multiple instances share a broker, every emit is delivered to the
130
+ target's clients on every instance. Heavy work never runs on the hot path:
131
+ local delivery is synchronous (encode once via the transport scratch + `ws.send`),
132
+ everything else (broker publishes, state-store writes, presence maintenance) is
133
+ deferred to a bounded offload queue (`events.queue`, drop-newest on overflow).
134
+
135
+ - **NATS** (server ⇄ server): reuse the server bridge with
136
+ `cluster: { nats: true }`, or a dedicated/different bridge
137
+ (`nats: NatsBridgeOptions | NatsBridge`).
138
+ - **Redis**: `cluster: { redis: { url: "redis://…" } }` (lazy `ioredis`
139
+ optional peer dependency — `bun add ioredis`).
140
+ - **Custom**: `cluster: { transport: MyClusterTransport }` (tests use an
141
+ in-memory bus).
142
+ - Self-delivery dedupe: frames carry the origin `instanceId`
143
+ (`cluster.instanceId`, random by default); an instance drops its own frames,
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`).
153
+ - **Presence with no shared state**: join/leave + periodic heartbeat messages
154
+ — `hub.clusterClients()` lists connections on other instances;
155
+ `hub.clusterInstances()` lists the other instances themselves.
156
+ - **Shared state store** (`cluster.state`, default per-instance memory;
157
+ production: `createRedisStateStore(...)`): user→clients index
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.
168
+ - **Server-side events**: other instances' events reach `onServerEvent`
169
+ handlers with `ctx.source === "remote"` (delivered to clients AND handlers);
170
+ NATS-inbound events reach them with `source === "bridge"`.
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
+
236
+ ## Metrics & shutdown
237
+
238
+ `server.getMetrics().events` (or `hub.metrics()`) exposes emitted counts per
239
+ target, delivered local frames, cluster received/self-dropped/errors,
240
+ queue and handler errors, presence sizes. `hub.close()` unsubscribes,
241
+ flushes the queue, announces leaves, and closes owned transports; call it via
242
+ `server.drain()` / `server.stop()`.
243
+
244
+ ## Performance notes
245
+
246
+ - Zero-alloc local encode + fan-out (the transport scratch is reused; Bun
247
+ copies on `ws.send`) — the emit call is O(target sockets).
248
+ - The cluster publish copies the frame once (required — the scratch is
249
+ reused) and enqueues; a slow or offline broker never blocks the socket loop
250
+ and never throws into it.
251
+ - Without `events`, there is zero overhead; the global functions throw a
252
+ descriptive error if no hub is bound.
@@ -0,0 +1,207 @@
1
+ # Generic bindings — bring your own schema
2
+
3
+ `@ignex/nova` is schema-driven: **any** TypeBox schema you define in your app can
4
+ be turned into a full wire stack (FlatBuffers schema, TS decoders, pure-JS
5
+ encoder, Rust FFI fast path, NATS wire registry) with one function call —
6
+ `generateBindings(schema)` — and then served / consumed / bridged through the
7
+ same `createServer` / `createClient` / `createNatsBridge` APIs, fully typed
8
+ against **your** events.
9
+
10
+ ```
11
+ your app
12
+ src/schema.ts (TypeBox — source of truth)
13
+ │ scripts/generate-bindings.ts
14
+ │ import { generateBindings } from "@ignex/nova/generate";
15
+
16
+ ignex/generated/ (backend.fbs, ts/decoders, registry.ts, ts-ser.ts,
17
+ direct-ser.ts, wire-registry.json, rust/ crate, index.ts)
18
+ │ makeBindings(yourSchema) → Bindings
19
+
20
+ createServer({ bindings }) · createClient(url, { bindings }) · createNatsBridge({ bindings })
21
+ ```
22
+
23
+ ## 1. Define your schema (TypeBox)
24
+
25
+ ```ts
26
+ // src/schema.ts — your app's single source of truth
27
+ import { Type } from "@sinclair/typebox";
28
+
29
+ export const ChatMsg = Type.Object(
30
+ { room: Type.String(), text: Type.String(), ts: Type.Integer() },
31
+ { additionalProperties: false },
32
+ );
33
+ export const Telemetry = Type.Object(
34
+ { device: Type.String(), readings: Type.Array(Type.Number()), ok: Type.Boolean() },
35
+ { additionalProperties: false },
36
+ );
37
+
38
+ export const schemas = { ChatMsg, Telemetry };
39
+ export const events = { chat: ChatMsg, telemetry: Telemetry };
40
+ export const controlEvents = {}; // optional extra transport-internal events
41
+ ```
42
+
43
+ Rules (same as the built-in registry):
44
+
45
+ - `Type.Object({...}, { additionalProperties: false })` for payloads.
46
+ - `Type.Integer()` → int64; `Type.Integer({ bigint: true })` / `Type.BigInt()`
47
+ → exact bigint int64 (lossless beyond 2^53).
48
+ - String-literal unions (`Type.Union([Type.Literal("a"), ...])`) → enums.
49
+ - Arrays of scalars / strings / enums / flat objects → vectors (packed on the
50
+ direct fast path). Nested objects → tables. Tables-in-tables fall back to the
51
+ JSON path.
52
+ - The transport control events (hello / welcome / subscribe / unsubscribe /
53
+ joinGroup / leaveGroup / snapshotRequest / ping / pong) are ALWAYS included —
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.
65
+
66
+ ## 2. Generate bindings
67
+
68
+ > `scripts/generate-bindings.ts` is an example filename **you** give a script in your own app — it is not a file shipped in the `@ignex/nova` package, so don't go looking for it in `node_modules`.
69
+
70
+ ```ts
71
+ // scripts/generate-bindings.ts — run once per schema change
72
+ import { generateBindings } from "@ignex/nova/generate";
73
+ import { schemas, events, controlEvents } from "../src/schema";
74
+
75
+ const gen = generateBindings(
76
+ { schemas, events, controlEvents },
77
+ { outDir: "./ignex/generated" }, // default
78
+ );
79
+ const written = gen.write(); // e.g. 26 files under ignex/generated/
80
+ console.log("generated:", written.length, "files");
81
+ ```
82
+
83
+ Requirements: `flatc` on PATH (the FlatBuffers compiler — the same prerequisite
84
+ as the built-in registry: `brew install flatbuffers` /
85
+ `apt install flatbuffers-compiler`). Pass `rust: false` to skip the Rust crate
86
+ scaffold (you lose the FFI fast path; the pure-JS encoder is still used).
87
+
88
+ The output folder contains:
89
+
90
+ | File | Role |
91
+ | --- | --- |
92
+ | `backend.fbs` | FlatBuffers schema (wire layout for independent consumers) |
93
+ | `ts/*.ts` | flatc-generated decoders (browser + Bun) |
94
+ | `registry.ts` | event ids, `readFrameHeader` / `decodePayload` / `decodeFrame`, `SCHEMA_FINGERPRINT` |
95
+ | `ts-ser.ts` | pure-JS encoder (works in the browser) |
96
+ | `direct-ser.ts` | direct fast-path serde (Bun server, when FFI is used) |
97
+ | `wire-registry.json` | machine-readable event-id registry for NATS consumers |
98
+ | `rust/` | a complete cargo crate — build it for the FFI fast path |
99
+ | `index.ts` | `makeBindings(schema)` — assembles the runtime `Bindings` |
100
+
101
+ ## 3. Assemble the bindings and use the APIs
102
+
103
+ ```ts
104
+ // bindings.ts
105
+ import { makeBindings } from "./ignex/generated"; // generated
106
+ import * as schema from "../src/schema";
107
+
108
+ export const bindings = makeBindings(schema);
109
+ export type AppEvents = import("@ignex/nova").EventsOf<typeof bindings>;
110
+ ```
111
+
112
+ ```ts
113
+ // server.ts (Bun)
114
+ import { createServer } from "@ignex/nova/server";
115
+ import { bindings } from "./bindings";
116
+
117
+ const server = createServer({
118
+ port: 3000,
119
+ bindings,
120
+ inbound: ["chat"], // events clients may send
121
+ nats: { servers: ["nats://localhost:4222"], inbound: true, bridgeClientEvents: true },
122
+ });
123
+ server.publish("chat", { room: "lobby", text: "hello", ts: Date.now() }); // typed!
124
+ server.on("chat", (msg, ws) => console.log(msg.room, msg.text));
125
+ ```
126
+
127
+ ```ts
128
+ // FE (browser or Bun)
129
+ import { createClient } from "@ignex/nova/client";
130
+ import { bindings } from "./bindings";
131
+
132
+ const client = createClient("ws://localhost:3000/ws", { bindings });
133
+ client.on("telemetry", (t) => console.log(t.device, t.readings)); // typed!
134
+ client.send("chat", { room: "lobby", text: "hi from FE", ts: Date.now() });
135
+ client.connect();
136
+ ```
137
+
138
+ The whole public surface is generic:
139
+
140
+ - `createServer({ bindings })` → `publish` / `publishToTopic` / `publishToGroup`
141
+ / `on` / … typed against your `Events`.
142
+ - `createClient(url, { bindings })` → `on` / `send` / `once` / `onAny` / …
143
+ typed against your `Events`.
144
+ - `createNatsBridge({ bindings })` → decodes inbound frames with your schema.
145
+
146
+ ## Rust FFI fast path (optional)
147
+
148
+ By default generated bindings use `ffiMode: "optional"`: the server tries the
149
+ Rust addon only when you point `IGNEX_FFI_PATH` at one — otherwise it silently
150
+ uses the pure-JS encoder (correct everywhere, just not zero-allocation). To get
151
+ the fast path:
152
+
153
+ ```bash
154
+ cd ignex/generated/rust
155
+ cargo build --release # produces libignex_ffi.so/.dylib/.dll
156
+ IGNEX_FFI_PATH=$(pwd)/target/release/libignex_ffi.so bun run your-server.ts
157
+ ```
158
+
159
+ The cdylib exports a **schema fingerprint** (`fb_schema_fingerprint`) that the
160
+ bind-time self-test checks against `SCHEMA_FINGERPRINT` in your generated
161
+ registry — a stale or schema-mismatched addon fails loudly instead of producing
162
+ undecodable frames. (`ffiMode: "required"` makes a missing/mismatched addon a
163
+ hard error.)
164
+
165
+ ## NATS & horizontal scaling
166
+
167
+ Point every server instance at the same NATS and the same subject prefix, and
168
+ they behave as one hub:
169
+
170
+ - Every `publish` / `publishToTopic` / `publishToGroup` is ALSO published to
171
+ NATS as the identical wire frame (`{prefix}.broadcast.<event>`,
172
+ `{prefix}.topic.<topic>.<event>`, `{prefix}.group.<group>.<event>`) — any
173
+ backend (BE) service can consume them with the wire registry +
174
+ `backend.fbs`.
175
+ - External producers (or BE services) publish on `{prefix}.inbound.>`; every
176
+ server forwards those events to its own clients.
177
+ - With `nats.bridgeClientEvents: true`, events that a client sends to ONE
178
+ server are re-published to `{prefix}.inbound.<event>`, so all other servers'
179
+ clients receive them too — client messages become cluster-wide. Loop
180
+ prevention is built in: frames that arrive via NATS are forwarded to clients
181
+ but never re-bridged.
182
+
183
+ ```
184
+ ┌─────────────┐ publish("chat", …) ┌─────────────┐
185
+ FE client ─▶│ server A │──────▶ NATS ◀─────────│ server B │◀─ FE client
186
+ │ ignex.* │ │ ignex.* │
187
+ └─────────────┘ └─────────────┘
188
+ │ inbound.<event> (client-sent, bridgeClientEvents)
189
+ └──────────────▶ BE consumers (any language)
190
+ ```
191
+
192
+ Because the wire bytes are schema-derived, all instances must run the SAME
193
+ generated bindings (same event names → same FNV-1a ids). Changing the schema
194
+ changes the fingerprint — regenerate all instances together.
195
+
196
+ ## Independent (non-JS) consumers
197
+
198
+ `wire-registry.json` maps event names → stable FNV-1a ids, and `backend.fbs`
199
+ is the FlatBuffer layout — decode bridged NATS frames in any language. See
200
+ `docs/wire-format.md` for the envelope.
201
+
202
+ ## What about the built-in events?
203
+
204
+ The built-in registry (quote/trade/portfolio/…) is just the default
205
+ `Bindings` (`defaultBindings`); every API accepts `bindings` and defaults to
206
+ it, so existing code is untouched. Use the built-in events as a reference for
207
+ schema style — your app's schema works exactly the same way.
@@ -10,9 +10,9 @@ mirrors how the `@ignex/*` packages in the Ignex monorepo are shipped.
10
10
  | Field | Value | Why |
11
11
  | --- | --- | --- |
12
12
  | `main` / `module` / `types` | `./index.ts` | source entrypoint (Bun-native) |
13
- | `exports` | `.` → `index.ts`, `./server` → `public/server.ts`, `./client` → `public/client.ts`, `./nats` → `public/nats.ts`, `./package.json` | typed subpath API |
13
+ | `exports` | `@ignex/nova` → `index.ts`; `@ignex/nova/server` → `public/server.ts`; `@ignex/nova/client` → `public/client.ts`; `@ignex/nova/nats` → `public/nats.ts`; `@ignex/nova/events` → `public/events.ts`; `@ignex/nova/bindings` → `public/bindings.ts`; `@ignex/nova/generate` → `public/generate.ts`; `@ignex/nova/internal` → `public/internal.ts`; `@ignex/nova/package.json` → `package.json` | typed subpath API |
14
14
  | `files` | `index.ts`, `public`, `src`, `rust`, `prebuilds`, `docs`, `README.md`, `LICENSE` | everything consumers need, nothing they don't |
15
- | `publishConfig` | `{ "access": "public" }` | unscoped package must be public |
15
+ | `publishConfig` | `{ "access": "public" }` | scoped packages are restricted by default — `access: public` publishes `@ignex/nova` publicly |
16
16
  | `engines` | `{ "bun": ">=1.4" }` | Bun-only runtime |
17
17
  | `sideEffects` | `false` | safe to tree-shake / mark in bundlers |
18
18