@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.
- package/README.md +136 -33
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +292 -0
- package/docs/architecture.md +101 -28
- package/docs/events.md +252 -0
- package/docs/generic-bindings.md +207 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +74 -20
- package/index.ts +75 -27
- package/package.json +13 -2
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/bindings.ts +24 -0
- package/public/client.ts +5 -1
- package/public/events.ts +71 -0
- package/public/generate.ts +510 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +52 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +377 -17
- package/src/bindings/assemble.ts +73 -0
- package/src/bindings/default.ts +65 -0
- package/src/bindings/types.ts +113 -0
- package/src/bridge/nats/inbound.ts +46 -0
- package/src/bridge/nats/index.ts +131 -0
- package/src/bridge/nats/real-transport.ts +133 -0
- package/src/bridge/nats/types.ts +80 -0
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +28 -0
- package/src/codegen/direct-gen.ts +564 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +246 -0
- package/src/codegen/rust-glue-gen.ts +552 -0
- package/src/codegen/schema-model.ts +363 -0
- package/src/codegen/ts-ser-gen.ts +230 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +67 -5
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +63 -8
- package/src/core/client-wire.ts +148 -15
- package/src/core/client.ts +105 -31
- package/src/core/groups.ts +8 -0
- package/src/core/metrics.ts +42 -21
- package/src/core/outbound.ts +62 -11
- package/src/core/rate-limit.ts +69 -0
- package/src/core/replay.ts +41 -1
- package/src/core/resume.ts +181 -0
- package/src/core/rooms.ts +10 -3
- package/src/core/routing.ts +144 -12
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/server/index.ts +360 -0
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +124 -14
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +174 -0
- package/src/events/cluster/dedupe.ts +43 -0
- package/src/events/cluster/envelope.ts +149 -0
- package/src/events/cluster/index.ts +50 -0
- package/src/events/cluster/keys.ts +33 -0
- package/src/events/cluster/kinds.ts +32 -0
- package/src/events/cluster/presence-table.ts +99 -0
- package/src/events/cluster/presence.ts +53 -0
- package/src/events/cluster/redis-client.ts +50 -0
- package/src/events/cluster/store-memory.ts +67 -0
- package/src/events/cluster/store-redis.ts +44 -0
- package/src/events/cluster/subjects.ts +30 -0
- package/src/events/cluster/sync.ts +476 -0
- package/src/events/cluster/transport-nats.ts +24 -0
- package/src/events/cluster/transport-redis.ts +120 -0
- package/src/events/cluster-rpc.ts +196 -0
- package/src/events/data.ts +38 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +173 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub/context-factory.ts +79 -0
- package/src/events/hub/dispatch.ts +86 -0
- package/src/events/hub/index.ts +536 -0
- package/src/events/hub/internal.ts +31 -0
- package/src/events/hub/metrics-snapshot.ts +84 -0
- package/src/events/hub/resolve-cluster.ts +49 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +123 -0
- package/src/events/registry.ts +214 -0
- package/src/events/schedule.ts +73 -0
- package/src/events/trace.ts +283 -0
- package/src/events/types/client.ts +68 -0
- package/src/events/types/cluster.ts +40 -0
- package/src/events/types/context.ts +50 -0
- package/src/events/types/emit-target.ts +29 -0
- package/src/events/types/groups.ts +35 -0
- package/src/events/types/hub.ts +124 -0
- package/src/events/types/index.ts +30 -0
- package/src/events/types/metrics.ts +52 -0
- package/src/events/types/options.ts +62 -0
- package/src/generated/direct-ser.ts +148 -60
- package/src/generated/fbs/backend.fbs +24 -1
- package/src/generated/registry.ts +94 -33
- package/src/generated/rust/backend_generated.rs +503 -0
- package/src/generated/ts/backend.ts +4 -0
- package/src/generated/ts/resume.ts +74 -0
- package/src/generated/ts/resumed.ts +88 -0
- package/src/generated/ts/rpc-call.ts +112 -0
- package/src/generated/ts/rpc-result.ts +126 -0
- package/src/generated/ts/snapshot-request.ts +19 -5
- package/src/generated/ts-ser.ts +110 -17
- package/src/generated/wire-registry.json +7 -2
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +49 -2
- package/src/server.ts +7 -3
- package/src/transport/transport.ts +200 -79
- package/src/bridge/nats.ts +0 -269
- package/src/core/server.ts +0 -294
- package/src/transport/stats.ts +0 -44
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
scripts/generate.ts
|
|
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 (
|
|
26
|
-
|
|
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
|
|
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), `
|
|
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
|
|
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`,
|
|
74
|
-
registry (id → socket), rooms, groups, inbound routing, control
|
|
75
|
-
auth/origin/token gates, backpressure, replay history, metrics,
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
`src/bridge/subjects.ts`. Inbound NATS events are
|
|
121
|
-
`fanOutAll` (no bridge call → loop prevention). All
|
|
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
|
-
-
|
|
146
|
-
|
|
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.
|
package/docs/publishing.md
CHANGED
|
@@ -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` |
|
|
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" }` |
|
|
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
|
|