@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
package/src/native/ffi.ts CHANGED
@@ -17,11 +17,16 @@
17
17
  * `buffer`/`buffer_length` pair, fall back to explicit `(ptr, usize)`
18
18
  * output pairs. The `abi()` transformer keeps the shipped specs canonical
19
19
  * (`(ptr, usize)` outputs) and upgrades them at bind time.
20
+ *
21
+ * Generic: `bindFfi(req)` binds for ANY schema (`req` comes from a `Bindings`
22
+ * object — the built-in one or a user-generated one), and the bind-time
23
+ * self-test now ALSO verifies the cdylib's `fb_schema_fingerprint` matches the
24
+ * schema's fingerprint, so a schema-mismatched addon fails loudly instead of
25
+ * producing undecodable frames.
20
26
  */
21
27
  import { dlopen, type FFITypeOrString } from "bun:ffi";
22
- import { directSelfTest, directSymbols } from "../generated/direct-ser";
23
- import { eventNameToId, WIRE_HEADER_LEN, WIRE_VERSION } from "../generated/registry";
24
- import type { EventName } from "../schema";
28
+ import type { Bindings, DirectTables } from "../bindings/types";
29
+ import { defaultBindings } from "../bindings/default";
25
30
  import { getAddonPath } from "./loader";
26
31
 
27
32
  export const FB_PROBE_MAGIC = 0x4947_4e58; // "IGNX"
@@ -30,14 +35,16 @@ export interface BunFfi {
30
35
  /** (eventId, JSON string, out view) → bytes written; 0 = error; >cap = needed */
31
36
  fb_serialize(eventId: number, json: string, out: Uint8Array): number;
32
37
  fb_probe(): number;
33
- /** Wire-format version of the cdylib (must equal generated WIRE_VERSION). */
38
+ /** Wire-format version of the cdylib (must equal the bindings' WIRE_VERSION). */
34
39
  fb_wire_version(): number;
40
+ /** Schema fingerprint of the cdylib (must equal the bindings' SCHEMA_FINGERPRINT). */
41
+ fb_schema_fingerprint(): number;
35
42
  }
36
43
 
37
44
  /** Output-buffer ABI mode of the live binding (set once by `bind()`). */
38
45
  export type BufferAbiMode = "buffer-pair" | "ptr-len";
39
46
 
40
- interface Dl {
47
+ export interface FfiDl {
41
48
  bindings: BunFfi;
42
49
  /** every bound symbol, callable with raw args (…fieldArgs, out, out) */
43
50
  raw: Record<string, (...args: unknown[]) => number>;
@@ -46,7 +53,16 @@ interface Dl {
46
53
  bufferAbiMode: BufferAbiMode;
47
54
  }
48
55
 
49
- let cachedDl: Dl | null | undefined;
56
+ /** What the FFI binder needs from a `Bindings` object (schema-specific parts). */
57
+ export interface FfiRequirements {
58
+ readonly wireVersion: number;
59
+ readonly wireHeaderLen: number;
60
+ readonly schemaFingerprint: number;
61
+ readonly eventNameToId: Readonly<Record<string, number>>;
62
+ readonly direct?: DirectTables;
63
+ }
64
+
65
+ let cachedDl: FfiDl | null | undefined;
50
66
  let bufferAbiMode: BufferAbiMode = "ptr-len";
51
67
 
52
68
  const U64_FAST = "u64_fast" as unknown as FFITypeOrString;
@@ -117,7 +133,14 @@ function adaptOut(sym: (...a: unknown[]) => number, mode: BufferAbiMode): (...a:
117
133
  };
118
134
  }
119
135
 
120
- function bind(): Dl {
136
+ /**
137
+ * Bind the cdylib for a specific schema (`req`). Throws on any self-test
138
+ * failure: missing addon, `fb_probe` magic mismatch, wire-version drift,
139
+ * schema-fingerprint drift (stale / mismatched addon), or a broken JSON path.
140
+ * Direct symbols that fail their per-symbol self-test are DISABLED (their
141
+ * events fall back to the JSON path) rather than throwing.
142
+ */
143
+ export function bindFfi(req: FfiRequirements): FfiDl {
121
144
  const path = getAddonPath();
122
145
 
123
146
  // Probe the atomic `buffer`/`buffer_length` pair once; fall back to explicit
@@ -130,8 +153,9 @@ function bind(): Dl {
130
153
  fb_serialize: { args: abi(["u32", "cstring", "ptr", "usize"]), returns: U64_FAST },
131
154
  fb_probe: { args: [], returns: "u32" },
132
155
  fb_wire_version: { args: [], returns: "u32" },
156
+ fb_schema_fingerprint: { args: [], returns: U64_FAST },
133
157
  };
134
- for (const [name, spec] of Object.entries(directSymbols)) {
158
+ for (const [name, spec] of Object.entries(req.direct?.symbols ?? {})) {
135
159
  specMap[name] = { args: abi(spec.args), returns: spec.returns as FFITypeOrString };
136
160
  }
137
161
 
@@ -147,24 +171,34 @@ function bind(): Dl {
147
171
  fb_serialize: (eventId, json, out) => raw["fb_serialize"]!(eventId, json, out, out) as number,
148
172
  fb_probe: () => raw["fb_probe"]!() as number,
149
173
  fb_wire_version: () => raw["fb_wire_version"]!() as number,
174
+ fb_schema_fingerprint: () => raw["fb_schema_fingerprint"]!() as number,
150
175
  };
151
176
 
152
177
  // ── Bind-time self-tests ─────────────────────────────────────────────
153
178
  if (bindings.fb_probe() !== FB_PROBE_MAGIC) {
154
179
  throw new Error(`ignex: FFI self-test failed (fb_probe mismatch) — addon at ${path}`);
155
180
  }
156
- // Wire-version drift check: a stale cdylib (built from an older schema /
157
- // envelope) must fail loudly at bind instead of producing undecodable frames.
158
- if (bindings.fb_wire_version() !== WIRE_VERSION) {
181
+ // Wire-version drift check: a stale cdylib (built from an older envelope)
182
+ // must fail loudly at bind instead of producing undecodable frames.
183
+ if (bindings.fb_wire_version() !== req.wireVersion) {
184
+ throw new Error(
185
+ `ignex: FFI self-test failed (wire version ${bindings.fb_wire_version()} !== ${req.wireVersion}) — addon at ${path}; regenerate + rebuild`,
186
+ );
187
+ }
188
+ // Schema-fingerprint check: a cdylib built from a DIFFERENT schema (e.g. the
189
+ // built-in addon loaded by a project with its own generated bindings) must
190
+ // fail loudly instead of silently encoding the wrong tables.
191
+ if (bindings.fb_schema_fingerprint() !== req.schemaFingerprint) {
159
192
  throw new Error(
160
- `ignex: FFI self-test failed (wire version ${bindings.fb_wire_version()} !== ${WIRE_VERSION}) — addon at ${path}; regenerate + rebuild`,
193
+ `ignex: FFI self-test failed (schema fingerprint ${bindings.fb_schema_fingerprint()} !== ${req.schemaFingerprint}) — ` +
194
+ `addon at ${path} was built from a different schema; build the addon from your generated rust/ crate (or unset IGNEX_FFI_PATH to use the pure-JS encoder)`,
161
195
  );
162
196
  }
163
197
  // JSON path sanity: `{}` → default frame for the FIRST event; verify the
164
198
  // frame invariant `out[0]` = WIRE_VERSION, `out[1..5]` = event id AND
165
199
  // `bytes_written === WIRE_HEADER_LEN + 4 + size_prefix`.
166
- const firstEvent = Object.keys(eventNameToId)[0] as EventName;
167
- const firstId = eventNameToId[firstEvent];
200
+ const firstEvent = Object.keys(req.eventNameToId)[0] as string;
201
+ const firstId = req.eventNameToId[firstEvent]!;
168
202
  const scratch = new Uint8Array(2048);
169
203
  const jw = bindings.fb_serialize(firstId, "{}", scratch);
170
204
  if (jw === 0 || jw > scratch.byteLength) {
@@ -172,41 +206,64 @@ function bind(): Dl {
172
206
  }
173
207
  {
174
208
  const dv = new DataView(scratch.buffer, scratch.byteOffset, scratch.byteLength);
175
- const size = dv.getUint32(scratch.byteOffset + WIRE_HEADER_LEN, true);
209
+ const size = dv.getUint32(scratch.byteOffset + req.wireHeaderLen, true);
176
210
  const gotId = dv.getUint32(scratch.byteOffset + 1, true);
177
- if (scratch[0] !== WIRE_VERSION || gotId !== firstId || jw !== WIRE_HEADER_LEN + 4 + size) {
211
+ if (scratch[0] !== req.wireVersion || gotId !== firstId || jw !== req.wireHeaderLen + 4 + size) {
178
212
  throw new Error(`ignex: FFI self-test failed (fb_serialize frame invariant) — addon at ${path}`);
179
213
  }
180
214
  }
181
215
  // Direct fast-path: probe every generated symbol; disable the failures so
182
216
  // their events gracefully fall back to the JSON path.
183
- const disabledDirect = new Set(directSelfTest(raw, new Uint8Array(2048)));
217
+ const disabledDirect = new Set(req.direct ? req.direct.selfTest(raw, new Uint8Array(2048)) : []);
184
218
 
185
219
  return { bindings, raw, disabledDirect, bufferAbiMode };
186
220
  }
187
221
 
188
- function ensure(): Dl {
189
- if (cachedDl === undefined) cachedDl = bind();
190
- return cachedDl as Dl;
222
+ /**
223
+ * Bind the cdylib for a `Bindings` object.
224
+ * - `ffiMode: "required"` — bind or THROW (the built-in registry's contract).
225
+ * - `ffiMode: "optional"` — used only when the user explicitly pointed at an
226
+ * addon (`IGNEX_FFI_PATH`); any bind / self-test failure (missing addon,
227
+ * schema mismatch, ...) returns `null`, and the transport falls back to the
228
+ * pure-JS encoder. Warns once per schema fingerprint.
229
+ */
230
+ const warnedOptional = new Set<number>();
231
+ export function createFfi(bindings: Bindings): FfiDl | null {
232
+ if (bindings.ffiMode === "required") return bindFfi(bindings);
233
+ if (!process.env.IGNEX_FFI_PATH) return null; // no addon requested — pure JS
234
+ try {
235
+ return bindFfi(bindings);
236
+ } catch (err) {
237
+ if (!warnedOptional.has(bindings.schemaFingerprint)) {
238
+ warnedOptional.add(bindings.schemaFingerprint);
239
+ console.warn(`ignex: native addon unavailable for this schema — using the pure-JS encoder (${(err as Error).message})`);
240
+ }
241
+ return null;
242
+ }
243
+ }
244
+
245
+ function ensureDefault(): FfiDl {
246
+ if (cachedDl === undefined) cachedDl = bindFfi(defaultBindings);
247
+ return cachedDl as FfiDl;
191
248
  }
192
249
 
193
- /** Lazily bind once. Throws if the addon is missing or the self-test fails. */
250
+ /** Lazily bind once (built-in registry). Throws if the addon is missing. */
194
251
  export function getFfi(): BunFfi {
195
- return ensure().bindings;
252
+ return ensureDefault().bindings;
196
253
  }
197
254
 
198
- /** Output-buffer ABI mode of the live binding (lazy — triggers bind). */
255
+ /** Output-buffer ABI mode of the live default binding (lazy — triggers bind). */
199
256
  export function getBufferAbiMode(): BufferAbiMode {
200
- return ensure().bufferAbiMode;
257
+ return ensureDefault().bufferAbiMode;
201
258
  }
202
259
 
203
260
  /**
204
- * Direct fast-path symbol. Call it with `(…fieldArgs, outView, outView)` →
205
- * bytes written (0 = error, >cap = needed). Returns undefined if the symbol is
206
- * disabled by the bind-time self-test (its event falls back to the JSON path).
261
+ * Direct fast-path symbol (built-in registry). Call it with
262
+ * `(…fieldArgs, outView, outView)` → bytes written (0 = error, >cap = needed).
263
+ * Returns undefined if the symbol is disabled by the bind-time self-test.
207
264
  */
208
265
  export function getDirectSymbol(name: string): ((...args: unknown[]) => number) | undefined {
209
- const dl = ensure();
266
+ const dl = ensureDefault();
210
267
  if (dl.disabledDirect.has(name)) return undefined;
211
268
  const symbol = dl.raw[name];
212
269
  if (!symbol) throw new Error(`ignex: unknown direct symbol "${name}"`);
@@ -9,7 +9,7 @@
9
9
  * a TypeBox schema; `Events[K]` is the plain-object type devs see on both the
10
10
  * server (publish) and the FE (on) — no FlatBuffer API anywhere in sight.
11
11
  */
12
- import { Type, type Static } from "@sinclair/typebox";
12
+ import { type Static, Type } from "@sinclair/typebox";
13
13
 
14
14
  // ── Enums (union of string literals → FlatBuffer enum) ───────────────
15
15
  export const Side = Type.Union([Type.Literal("buy"), Type.Literal("sell")]);
@@ -182,12 +182,55 @@ export const JoinGroup = Type.Object({ group: Type.String() }, { additionalPrope
182
182
 
183
183
  export const LeaveGroup = Type.Object({ group: Type.String() }, { additionalProperties: false });
184
184
 
185
- export const SnapshotRequest = Type.Object({ topic: Type.String() }, { additionalProperties: false });
185
+ export const SnapshotRequest = Type.Object(
186
+ {
187
+ topic: Type.String(),
188
+ /**
189
+ * Resume point: serve recorded history STRICTLY AFTER this topic seq
190
+ * (0 = from the beginning of retained history — the v1 behavior).
191
+ */
192
+ fromSeq: Type.Integer(),
193
+ },
194
+ { additionalProperties: false },
195
+ );
186
196
 
187
197
  export const Ping = Type.Object({ ts: Type.Integer() }, { additionalProperties: false });
188
198
 
189
199
  export const Pong = Type.Object({ ts: Type.Integer() }, { additionalProperties: false });
190
200
 
201
+ /**
202
+ * Client → server: resume missed delivery-seq frames after a gap or
203
+ * reconnect. The server replays everything it still holds strictly after
204
+ * `lastSeq` from the per-connection history ring, prefixed by a `resumed`
205
+ * control frame (`ok:false` when the hole is older than the ring).
206
+ */
207
+ export const Resume = Type.Object(
208
+ { lastSeq: Type.Integer() }, // last CONTIGUOUS delivery seq the client has
209
+ { additionalProperties: false },
210
+ );
211
+
212
+ /** Server → client: ack before replayed frames (`ok:false` → resubscribe topics). */
213
+ export const Resumed = Type.Object(
214
+ { ok: Type.Boolean(), from: Type.Integer() }, // first seq being replayed
215
+ { additionalProperties: false },
216
+ );
217
+
218
+ /**
219
+ * Request/response over the event wire (correlation id + timeout live on the
220
+ * caller side). `name` is the app-event name being requested; `payload` is the
221
+ * base64 of a full wire frame encoded with that same event's schema — the
222
+ * response reuses the SAME event schema both directions.
223
+ */
224
+ export const RpcCall = Type.Object(
225
+ { id: Type.String(), name: Type.String(), payloadB64: Type.String() },
226
+ { additionalProperties: false },
227
+ );
228
+
229
+ export const RpcResult = Type.Object(
230
+ { id: Type.String(), ok: Type.Boolean(), err: Type.String(), payloadB64: Type.String() },
231
+ { additionalProperties: false },
232
+ );
233
+
191
234
  /** Server→client identity assignment (sent right after `hello` on open). */
192
235
  export const Welcome = Type.Object(
193
236
  {
@@ -208,6 +251,10 @@ export const controlEvents = {
208
251
  snapshotRequest: SnapshotRequest,
209
252
  ping: Ping,
210
253
  pong: Pong,
254
+ resume: Resume,
255
+ resumed: Resumed,
256
+ rpcCall: RpcCall,
257
+ rpcResult: RpcResult,
211
258
  } as const;
212
259
 
213
260
  export type EventName = keyof typeof events;
package/src/server.ts CHANGED
@@ -19,13 +19,15 @@ const port = Number(process.env.PORT ?? 3000);
19
19
  const natsUrl = process.env.NATS_URL;
20
20
  const server = createServer({
21
21
  port,
22
- nats: natsUrl ? { servers: [natsUrl], inbound: true } : undefined,
22
+ ...(natsUrl ? { nats: { servers: [natsUrl], inbound: true } } : {}),
23
23
  fetch: (req) => serveStatic(req),
24
24
  });
25
25
 
26
26
  console.log(`ignex demo: http://localhost:${port}/ (ws: ws://localhost:${port}/ws)`);
27
27
  if (natsUrl) {
28
- console.log(`ignex demo: NATS bridge → ${natsUrl} (subjects: ignex.broadcast.* / ignex.topic.* / ignex.group.*; inbound: ignex.inbound.>)`);
28
+ console.log(
29
+ `ignex demo: NATS bridge → ${natsUrl} (subjects: ignex.broadcast.* / ignex.topic.* / ignex.group.*; inbound: ignex.inbound.>)`,
30
+ );
29
31
  }
30
32
 
31
33
  const CONTENT_TYPES: Record<string, string> = {
@@ -46,7 +48,9 @@ async function serveStatic(req: Request): Promise<Response> {
46
48
  const f = Bun.file(file);
47
49
  if (!(await f.exists())) return new Response("not found", { status: 404 });
48
50
  const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
49
- return new Response(f, { headers: { "content-type": CONTENT_TYPES[ext] ?? "application/octet-stream" } });
51
+ return new Response(f, {
52
+ headers: { "content-type": CONTENT_TYPES[ext] ?? "application/octet-stream" },
53
+ });
50
54
  }
51
55
 
52
56
  // Static page + pub/sub websocket share one port: /ws is upgraded by the
@@ -1,106 +1,227 @@
1
1
  /**
2
- * Internal transport: JS object → wire frame via Rust FFI.
3
- * frame = `[1-byte event_id][size-prefixed FlatBuffer]` fully produced by Rust.
2
+ * Internal transport: JS object → wire frame via Rust FFI (or the pure-JS
3
+ * encoder for user schemas without a native addon).
4
+ * frame = `[WIRE_VERSION:1][event_id:u32 LE][size-prefixed FlatBuffer]` — fully
5
+ * produced by Rust (envelope header is WIRE_HEADER_LEN = 5 bytes).
4
6
  *
5
- * Two paths:
7
+ * Three paths:
6
8
  * - DIRECT (flat events, generated): fields pushed straight into Rust as FFI
7
9
  * args from a zero-alloc encoder — no JSON, no intermediate array, and a
8
10
  * single reusable output scratch (`encodeToScratch`).
9
11
  * - JSON fallback (events with vectors / nested tables): object → JSON →
10
12
  * `fb_serialize` → Rust parses and builds (still allocates — documented).
13
+ * - JS fallback (user schemas without a native addon): the generated pure-JS
14
+ * encoder (`bindings.encodeFrame`) — correct everywhere, slower, and the
15
+ * default when `ffiMode: "optional"` and no addon is available.
16
+ *
17
+ * ALL per-event dispatch state is resolved EAGERLY at `createTransport()`
18
+ * (instantiation time), not lazily on first encode: every known event name —
19
+ * app AND control — gets an {@link EncodeRecord} up front, holding its event
20
+ * id, generated encoder, NUL pre-scan and (once the addon binds) its direct
21
+ * FFI symbol + encode-path counters. The hot path is then one Map hit plus a
22
+ * couple of monomorphic field reads/increments — no lazy-init branches, no
23
+ * second stats Map, no per-encode counter allocation.
24
+ *
25
+ * `createTransport(bindings)` is a per-schema factory: it owns its own scratch
26
+ * + records + FFI binding, so several servers with different schemas can
27
+ * coexist. The module-level `defaultTransport` (built-in registry, Rust
28
+ * required) keeps the historical singleton behavior — `encodeToScratch` /
29
+ * `encodeEvent` / `getEncodeStats` remain re-exported for backwards compat.
11
30
  */
12
- import { directEncoders, directSymbolNames, hasNulEncoders } from "../generated/direct-ser";
13
- import { anyEventNameToId } from "../generated/registry";
14
- import type { AnyEventName } from "../schema";
15
- import { getDirectSymbol, getFfi } from "../native/ffi";
16
- import { createScratch, MIN_CAP } from "./scratch";
17
- import { createStats } from "./stats";
18
31
 
19
- // Single reusable output scratch + encode-path stats, created once per process
20
- // and reused for every encode (the zero-alloc hot path). Safe to reuse right
21
- // after `ws.send` Bun copies binary frames synchronously (verified
22
- // empirically). These are intentionally module-level singletons: threading
23
- // them through every encode call would only add parameter churn to the hot
24
- // path for no functional gain.
25
- const scratch = createScratch();
26
- const stats = createStats();
32
+ import { defaultBindings } from "../bindings/default";
33
+ import type { Bindings, DirectEncoder } from "../bindings/types";
34
+ import { createFfi, type FfiDl } from "../native/ffi";
35
+ import { createScratch, MIN_CAP } from "./scratch";
27
36
 
28
- /**
29
- * Resolved direct-path record for one event — populated lazily on first encode
30
- * and immutable afterwards (a direct symbol that was disabled by the bind-time
31
- * self-test never re-enables, so caching the resolved call is safe).
32
- */
33
- interface ResolvedDirect {
37
+ /** Everything the hot path needs for one event — built once at instantiation. */
38
+ interface EncodeRecord {
39
+ /** stable wire id (anyEventNameToId) used by the JSON fallback */
40
+ readonly id: number;
34
41
  /** generated zero-alloc encoder (absent for JSON-only events) */
35
- encoder?: (call: (...args: unknown[]) => number, o: unknown, out: Uint8Array) => number;
36
- /** resolved FFI symbol (undefined = symbol disabled JSON fallback) */
37
- call?: (...args: unknown[]) => number;
42
+ readonly encoder: DirectEncoder | undefined;
43
+ /** FFI symbol name in the addon (absent when there is no direct table) */
44
+ readonly symName: string | undefined;
38
45
  /** per-event NUL pre-scan (absent when the event has no string fields) */
39
- hasNul?: (o: unknown) => boolean;
46
+ readonly hasNul: ((o: unknown) => boolean) | undefined;
47
+ /**
48
+ * Resolved FFI symbol: undefined = not yet bound OR disabled/JSON-only.
49
+ * Flipped to a function once the addon binds; set back to undefined when a
50
+ * runtime failure (`ffiMode: "optional"`) permanently demotes this event to
51
+ * the JSON path.
52
+ */
53
+ call: ((...args: unknown[]) => number) | undefined;
54
+ /** encode-path counters — incremented IN PLACE on the record (no Maps) */
55
+ directCount: number;
56
+ jsonCount: number;
57
+ jsCount: number;
40
58
  }
41
- const resolvedDirect = new Map<string, ResolvedDirect>();
42
-
43
- function resolveDirect(name: AnyEventName): ResolvedDirect {
44
- let r = resolvedDirect.get(name);
45
- if (r === undefined) {
46
- r = {};
47
- const encoder = directEncoders[name];
48
- if (encoder) {
49
- r.encoder = encoder;
50
- r.call = getDirectSymbol(directSymbolNames[name]!);
51
- r.hasNul = hasNulEncoders[name];
52
- }
53
- resolvedDirect.set(name, r);
54
- }
55
- return r;
59
+
60
+ export interface Transport {
61
+ /**
62
+ * Zero-allocation encode into the transport's shared output scratch. The
63
+ * returned view is only valid until the next call — publish/send it
64
+ * immediately (Bun copies). Accepts app events AND control events.
65
+ */
66
+ encodeToScratch(name: string, payload: unknown): Uint8Array;
67
+ /** Owned copy (safe to hold) — used by tests/bench. One allocation. */
68
+ encodeEvent(name: string, payload: unknown): Uint8Array;
69
+ /** encode-path stats (direct vs json vs js). */
70
+ getEncodeStats(): {
71
+ direct: Record<string, number>;
72
+ json: Record<string, number>;
73
+ js: Record<string, number>;
74
+ };
56
75
  }
57
76
 
58
- /**
59
- * Zero-allocation encode into the shared output scratch. The returned view is
60
- * only valid until the next call — publish/send it immediately (Bun copies).
61
- * Accepts app events AND control events (hello/subscribe/ping/...) — the
62
- * server encodes both through the Rust FFI.
63
- */
64
- export function encodeToScratch(name: AnyEventName, payload: unknown): Uint8Array {
65
- const r = resolveDirect(name);
66
- const encoder = r.encoder;
67
- if (encoder && r.call) {
68
- // `call` is undefined when the bind-time self-test disabled the symbol —
69
- // fall through to the JSON path (graceful degradation). Embedded NULs route
70
- // to JSON too: the `cstring` direct path truncates them (silent data loss),
71
- // the JSON path preserves them exactly.
72
- if (!(r.hasNul?.(payload) ?? false)) {
73
- scratch.grow(MIN_CAP);
74
- const w = scratch.neededSize(name, encoder(r.call, payload, scratch.view), () => encoder(r.call!, payload, scratch.view));
75
- stats.bump(name, "direct");
77
+ export function createTransport(bindings: Bindings): Transport {
78
+ const scratch = createScratch();
79
+
80
+ // ── instantiation-time resolution: one record per known event ────────────
81
+ const records = new Map<string, EncodeRecord>();
82
+ for (const name of Object.keys(bindings.anyEventNameToId)) {
83
+ const direct = bindings.direct;
84
+ const encoder = direct?.encoders[name];
85
+ const hasNul = direct?.hasNul[name];
86
+ records.set(name, {
87
+ id: bindings.anyEventNameToId[name] as number,
88
+ encoder,
89
+ ...(encoder !== undefined ? { symName: direct?.symbolNames[name] } : { symName: undefined }),
90
+ hasNul,
91
+ call: undefined,
92
+ directCount: 0,
93
+ jsonCount: 0,
94
+ jsCount: 0,
95
+ });
96
+ }
97
+
98
+ // Lazily bound once: undefined = not yet resolved, null = JS-only mode,
99
+ // FfiDl = bound addon. For ffiMode "required" the bind throws (missing /
100
+ // mismatched addon fails on first encode, as before); for "optional" it
101
+ // resolves to null and the JS encoder is used.
102
+ let ffi: FfiDl | null | undefined;
103
+ const getFfiDl = (): FfiDl | null => {
104
+ if (ffi === undefined) ffi = createFfi(bindings);
105
+ return ffi;
106
+ };
107
+
108
+ /**
109
+ * Bind the addon once and fan the resolved symbols out across ALL records
110
+ * in a single pass — the first encode pays the dlopen + self-test, every
111
+ * later encode sees a plain populated field. Self-test-disabled symbols are
112
+ * left undefined (their events take the JSON path).
113
+ */
114
+ const bindSymbols = (): void => {
115
+ const dl = getFfiDl();
116
+ if (dl === null) return;
117
+ const disabled = dl.disabledDirect;
118
+ for (const r of records.values()) {
119
+ if (r.encoder === undefined || r.symName === undefined) continue;
120
+ if (disabled.has(r.symName)) continue;
121
+ const sym = dl.raw[r.symName];
122
+ if (sym !== undefined) r.call = sym;
123
+ }
124
+ };
125
+
126
+ function encodeToScratch(name: string, payload: unknown): Uint8Array {
127
+ const r = records.get(name);
128
+ if (r === undefined) throw new Error(`ignex: unknown event "${name}"`);
129
+ const encoder = r.encoder;
130
+ if (encoder !== undefined) {
131
+ if (r.call === undefined && ffi === undefined) bindSymbols();
132
+ const call = r.call;
133
+ if (call !== undefined) {
134
+ // Embedded NULs route to JSON: the `cstring` direct path truncates
135
+ // them (silent data loss); the JSON path preserves them exactly.
136
+ if (!(r.hasNul?.(payload) ?? false)) {
137
+ if (bindings.ffiMode !== "optional") {
138
+ // required mode — zero-alloc hot path, no try/catch
139
+ scratch.grow(MIN_CAP);
140
+ const w = scratch.neededSize(name, encoder(call, payload, scratch.view), () =>
141
+ encoder(r.call as (...args: unknown[]) => number, payload, scratch.view),
142
+ );
143
+ r.directCount++;
144
+ return scratch.view.subarray(0, w);
145
+ }
146
+ try {
147
+ scratch.grow(MIN_CAP);
148
+ const w = scratch.neededSize(name, encoder(call, payload, scratch.view), () =>
149
+ encoder(r.call as (...args: unknown[]) => number, payload, scratch.view),
150
+ );
151
+ r.directCount++;
152
+ return scratch.view.subarray(0, w);
153
+ } catch {
154
+ // optional mode: a direct-call failure (e.g. ABI drift at runtime)
155
+ // permanently demotes this event to the JSON path.
156
+ r.call = undefined;
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ const dl = ffi === undefined ? getFfiDl() : ffi;
163
+ if (dl !== null) {
164
+ // JSON fallback (vector/nested events, or a disabled direct symbol).
165
+ const json = JSON.stringify(payload);
166
+ scratch.grow(Math.max(MIN_CAP, json.length * 2 + 128));
167
+ const w = scratch.neededSize(name, dl.bindings.fb_serialize(r.id, json, scratch.view), () =>
168
+ dl.bindings.fb_serialize(r.id, json, scratch.view),
169
+ );
170
+ r.jsonCount++;
76
171
  return scratch.view.subarray(0, w);
77
172
  }
173
+
174
+ // JS-only mode (user schema, no native addon) — the pure-JS encoder.
175
+ const frame = bindings.encodeFrame(name, payload);
176
+ r.jsCount++;
177
+ return frame;
78
178
  }
79
179
 
80
- // JSON fallback (vector/nested events, or a disabled direct symbol).
81
- const id = anyEventNameToId[name];
82
- if (id === undefined) throw new Error(`ignex: unknown event "${name}"`);
180
+ function encodeEvent(name: string, payload: unknown): Uint8Array {
181
+ const frame = encodeToScratch(name, payload);
182
+ const owned = new Uint8Array(frame.byteLength);
183
+ owned.set(frame);
184
+ return owned;
185
+ }
83
186
 
84
- const json = JSON.stringify(payload);
85
- const ffi = getFfi();
86
- scratch.grow(Math.max(MIN_CAP, json.length * 2 + 128));
87
- const w = scratch.neededSize(name, ffi.fb_serialize(id, json, scratch.view), () => ffi.fb_serialize(id, json, scratch.view));
88
- stats.bump(name, "json");
89
- return scratch.view.subarray(0, w);
187
+ return {
188
+ encodeToScratch,
189
+ encodeEvent,
190
+ getEncodeStats() {
191
+ const direct: Record<string, number> = {};
192
+ const json: Record<string, number> = {};
193
+ const js: Record<string, number> = {};
194
+ for (const [name, r] of records) {
195
+ if (r.directCount > 0) direct[name] = r.directCount;
196
+ if (r.jsonCount > 0) json[name] = r.jsonCount;
197
+ if (r.jsCount > 0) js[name] = r.jsCount;
198
+ }
199
+ return { direct, json, js };
200
+ },
201
+ };
90
202
  }
91
203
 
92
- // ── encode-path stats (direct vs JSON) ─────────────────────────────────
93
- // Accumulated per event name across the process (typical deployments run one
94
- // server per process). Surfaced via `getEncodeStats()` / `server.metrics()`.
204
+ // ── default transport (built-in registry) ──────────────────────────────────
205
+ // Module-level singleton the historical zero-alloc hot path. Re-exported
206
+ // below so existing imports (`outbound.ts` migration aside) keep working.
95
207
 
96
- export function getEncodeStats(): { direct: Record<string, number>; json: Record<string, number> } {
97
- return stats.get();
208
+ export const defaultTransport: Transport = createTransport(defaultBindings);
209
+
210
+ /** Zero-allocation encode into the shared output scratch (built-in registry). */
211
+ export function encodeToScratch(name: string, payload: unknown): Uint8Array {
212
+ return defaultTransport.encodeToScratch(name, payload);
98
213
  }
99
214
 
100
215
  /** Owned copy (safe to hold) — used by tests/bench. One allocation. */
101
- export function encodeEvent(name: AnyEventName, payload: unknown): Uint8Array {
102
- const frame = encodeToScratch(name, payload);
103
- const owned = new Uint8Array(frame.byteLength);
104
- owned.set(frame);
105
- return owned;
216
+ export function encodeEvent(name: string, payload: unknown): Uint8Array {
217
+ return defaultTransport.encodeEvent(name, payload);
218
+ }
219
+
220
+ /** encode-path stats for the built-in registry (direct vs JSON). */
221
+ export function getEncodeStats(): {
222
+ direct: Record<string, number>;
223
+ json: Record<string, number>;
224
+ js: Record<string, number>;
225
+ } {
226
+ return defaultTransport.getEncodeStats();
106
227
  }