@ignex/nova 0.1.1 → 0.1.3

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 (67) hide show
  1. package/README.md +132 -32
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +232 -0
  4. package/docs/architecture.md +35 -10
  5. package/docs/events.md +170 -0
  6. package/docs/generic-bindings.md +197 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +9 -2
  9. package/index.ts +75 -27
  10. package/package.json +12 -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 +416 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +42 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/transcode/generated.rs +2 -1
  21. package/src/bindings/assemble.ts +73 -0
  22. package/src/bindings/default.ts +65 -0
  23. package/src/bindings/types.ts +113 -0
  24. package/src/bridge/nats.ts +53 -13
  25. package/src/bridge/subjects.ts +3 -0
  26. package/src/codegen/constants.ts +18 -0
  27. package/src/codegen/direct-gen.ts +550 -0
  28. package/src/codegen/fingerprint.ts +44 -0
  29. package/src/codegen/hash.ts +25 -0
  30. package/src/codegen/registry-gen.ts +242 -0
  31. package/src/codegen/rust-glue-gen.ts +545 -0
  32. package/src/codegen/schema-model.ts +338 -0
  33. package/src/codegen/ts-ser-gen.ts +221 -0
  34. package/src/codegen/typebox-to-fbs.ts +60 -0
  35. package/src/core/auth.ts +2 -1
  36. package/src/core/client-heartbeat.ts +2 -1
  37. package/src/core/client-reconnect.ts +9 -2
  38. package/src/core/client-state.ts +21 -8
  39. package/src/core/client-wire.ts +10 -11
  40. package/src/core/client.ts +34 -29
  41. package/src/core/groups.ts +3 -0
  42. package/src/core/metrics.ts +7 -3
  43. package/src/core/outbound.ts +12 -5
  44. package/src/core/routing.ts +17 -8
  45. package/src/core/server.ts +108 -34
  46. package/src/core/state.ts +51 -13
  47. package/src/events/clients.ts +156 -0
  48. package/src/events/cluster.ts +732 -0
  49. package/src/events/data.ts +38 -0
  50. package/src/events/emit.ts +127 -0
  51. package/src/events/global.ts +117 -0
  52. package/src/events/groups.ts +118 -0
  53. package/src/events/hub.ts +481 -0
  54. package/src/events/index.ts +61 -0
  55. package/src/events/queue.ts +96 -0
  56. package/src/events/registry.ts +178 -0
  57. package/src/events/types.ts +378 -0
  58. package/src/generated/direct-ser.ts +2 -1
  59. package/src/generated/fbs/backend.fbs +1 -1
  60. package/src/generated/registry.ts +3 -1
  61. package/src/generated/ts-ser.ts +1 -1
  62. package/src/generated/wire-registry.json +1 -0
  63. package/src/native/ffi.ts +85 -28
  64. package/src/schema/index.ts +5 -2
  65. package/src/server.ts +7 -3
  66. package/src/transport/stats.ts +8 -4
  67. package/src/transport/transport.ts +149 -68
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,7 +182,10 @@ 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
+ { topic: Type.String() },
187
+ { additionalProperties: false },
188
+ );
186
189
 
187
190
  export const Ping = Type.Object({ ts: Type.Integer() }, { additionalProperties: false });
188
191
 
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
@@ -8,8 +8,8 @@
8
8
  * increment — no Map.get + Map.set churn per encode.
9
9
  */
10
10
  export interface EncodeStats {
11
- bump(name: string, path: "direct" | "json"): void;
12
- get(): { direct: Record<string, number>; json: Record<string, number> };
11
+ bump(name: string, path: "direct" | "json" | "js"): void;
12
+ get(): { direct: Record<string, number>; json: Record<string, number>; js: Record<string, number> };
13
13
  }
14
14
 
15
15
  /** Mutable counter box — allocated once per (event, path), incremented in place. */
@@ -26,10 +26,14 @@ function toObj(m: Map<string, Counter>): Record<string, number> {
26
26
  export function createStats(): EncodeStats {
27
27
  const direct = new Map<string, Counter>();
28
28
  const json = new Map<string, Counter>();
29
+ const js = new Map<string, Counter>();
29
30
 
30
31
  return {
31
32
  bump(name, path) {
32
- const m = path === "direct" ? direct : json;
33
+ let m: Map<string, Counter>;
34
+ if (path === "direct") m = direct;
35
+ else if (path === "json") m = json;
36
+ else m = js;
33
37
  let c = m.get(name);
34
38
  if (!c) {
35
39
  c = { n: 0 };
@@ -38,7 +42,7 @@ export function createStats(): EncodeStats {
38
42
  c.n++;
39
43
  },
40
44
  get() {
41
- return { direct: toObj(direct), json: toObj(json) };
45
+ return { direct: toObj(direct), json: toObj(json), js: toObj(js) };
42
46
  },
43
47
  };
44
48
  }
@@ -1,29 +1,48 @@
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
+ * `createTransport(bindings)` is a per-schema factory: it owns its own scratch +
18
+ * stats + FFI binding, so several servers with different schemas can coexist.
19
+ * The module-level `defaultTransport` (built-in registry, Rust required) keeps
20
+ * the historical singleton behavior — `encodeToScratch` / `encodeEvent` /
21
+ * `getEncodeStats` remain re-exported for backwards compatibility.
11
22
  */
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";
23
+
24
+ import { defaultBindings } from "../bindings/default";
25
+ import type { Bindings, DirectEncoder } from "../bindings/types";
26
+ import { createFfi, type FfiDl } from "../native/ffi";
16
27
  import { createScratch, MIN_CAP } from "./scratch";
17
28
  import { createStats } from "./stats";
18
29
 
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();
30
+ export interface Transport {
31
+ /**
32
+ * Zero-allocation encode into the transport's shared output scratch. The
33
+ * returned view is only valid until the next call — publish/send it
34
+ * immediately (Bun copies). Accepts app events AND control events.
35
+ */
36
+ encodeToScratch(name: string, payload: unknown): Uint8Array;
37
+ /** Owned copy (safe to hold) — used by tests/bench. One allocation. */
38
+ encodeEvent(name: string, payload: unknown): Uint8Array;
39
+ /** encode-path stats (direct vs json vs js). */
40
+ getEncodeStats(): {
41
+ direct: Record<string, number>;
42
+ json: Record<string, number>;
43
+ js: Record<string, number>;
44
+ };
45
+ }
27
46
 
28
47
  /**
29
48
  * Resolved direct-path record for one event — populated lazily on first encode
@@ -32,75 +51,137 @@ const stats = createStats();
32
51
  */
33
52
  interface ResolvedDirect {
34
53
  /** generated zero-alloc encoder (absent for JSON-only events) */
35
- encoder?: (call: (...args: unknown[]) => number, o: unknown, out: Uint8Array) => number;
54
+ encoder?: DirectEncoder;
36
55
  /** resolved FFI symbol (undefined = symbol disabled → JSON fallback) */
37
- call?: (...args: unknown[]) => number;
56
+ call: ((...args: unknown[]) => number) | undefined;
38
57
  /** per-event NUL pre-scan (absent when the event has no string fields) */
39
58
  hasNul?: (o: unknown) => boolean;
40
59
  }
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];
60
+
61
+ export function createTransport(bindings: Bindings): Transport {
62
+ const scratch = createScratch();
63
+ const stats = createStats();
64
+ const resolvedDirect = new Map<string, ResolvedDirect>();
65
+
66
+ // Lazily bound once: undefined = not yet resolved, null = JS-only mode,
67
+ // FfiDl = bound addon. For ffiMode "required" the bind throws (missing /
68
+ // mismatched addon fails on first encode, as before); for "optional" it
69
+ // resolves to null and the JS encoder is used.
70
+ let ffi: FfiDl | null | undefined;
71
+ const getFfiDl = (): FfiDl | null => {
72
+ if (ffi === undefined) ffi = createFfi(bindings);
73
+ return ffi;
74
+ };
75
+
76
+ const guard = bindings.ffiMode === "optional";
77
+
78
+ function resolveDirect(name: string): ResolvedDirect {
79
+ let r = resolvedDirect.get(name);
80
+ if (r === undefined) {
81
+ r = { call: undefined };
82
+ const encoder = bindings.direct?.encoders[name];
83
+ if (encoder) {
84
+ r.encoder = encoder;
85
+ const dl = getFfiDl();
86
+ const symName = bindings.direct?.symbolNames[name];
87
+ r.call = dl ? dl.raw[symName ?? ""] : undefined;
88
+ const hasNul = bindings.direct?.hasNul[name];
89
+ if (hasNul !== undefined) r.hasNul = hasNul;
90
+ }
91
+ resolvedDirect.set(name, r);
52
92
  }
53
- resolvedDirect.set(name, r);
93
+ return r;
54
94
  }
55
- return r;
56
- }
57
95
 
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");
96
+ function encodeToScratch(name: string, payload: unknown): Uint8Array {
97
+ const r = resolveDirect(name);
98
+ const encoder = r.encoder;
99
+ if (encoder && r.call) {
100
+ // `call` is undefined when the bind-time self-test disabled the symbol
101
+ // fall through to the JSON path (graceful degradation). Embedded NULs route
102
+ // to JSON too: the `cstring` direct path truncates them (silent data loss),
103
+ // the JSON path preserves them exactly.
104
+ if (!(r.hasNul?.(payload) ?? false)) {
105
+ if (!guard) {
106
+ // required mode zero-alloc hot path, no try/catch
107
+ scratch.grow(MIN_CAP);
108
+ const w = scratch.neededSize(name, encoder(r.call, payload, scratch.view), () =>
109
+ encoder(r.call!, payload, scratch.view),
110
+ );
111
+ stats.bump(name, "direct");
112
+ return scratch.view.subarray(0, w);
113
+ }
114
+ try {
115
+ scratch.grow(MIN_CAP);
116
+ const w = scratch.neededSize(name, encoder(r.call, payload, scratch.view), () =>
117
+ encoder(r.call!, payload, scratch.view),
118
+ );
119
+ stats.bump(name, "direct");
120
+ return scratch.view.subarray(0, w);
121
+ } catch {
122
+ // optional mode: a direct-call failure (e.g. ABI drift at runtime)
123
+ // permanently demotes this event to the JSON path.
124
+ r.call = undefined;
125
+ }
126
+ }
127
+ }
128
+
129
+ const dl = getFfiDl();
130
+ if (dl) {
131
+ // JSON fallback (vector/nested events, or a disabled direct symbol).
132
+ const id = bindings.anyEventNameToId[name];
133
+ if (id === undefined) throw new Error(`ignex: unknown event "${name}"`);
134
+ const json = JSON.stringify(payload);
135
+ const ffi2 = dl.bindings;
136
+ scratch.grow(Math.max(MIN_CAP, json.length * 2 + 128));
137
+ const w = scratch.neededSize(name, ffi2.fb_serialize(id, json, scratch.view), () =>
138
+ ffi2.fb_serialize(id, json, scratch.view),
139
+ );
140
+ stats.bump(name, "json");
76
141
  return scratch.view.subarray(0, w);
77
142
  }
143
+
144
+ // JS-only mode (user schema, no native addon) — the pure-JS encoder.
145
+ const frame = bindings.encodeFrame(name, payload);
146
+ stats.bump(name, "js");
147
+ return frame;
78
148
  }
79
149
 
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}"`);
150
+ function encodeEvent(name: string, payload: unknown): Uint8Array {
151
+ const frame = encodeToScratch(name, payload);
152
+ const owned = new Uint8Array(frame.byteLength);
153
+ owned.set(frame);
154
+ return owned;
155
+ }
83
156
 
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);
157
+ return {
158
+ encodeToScratch,
159
+ encodeEvent,
160
+ getEncodeStats: () => stats.get(),
161
+ };
90
162
  }
91
163
 
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()`.
164
+ // ── default transport (built-in registry) ──────────────────────────────────
165
+ // Module-level singleton the historical zero-alloc hot path. Re-exported
166
+ // below so existing imports (`outbound.ts` migration aside) keep working.
167
+
168
+ export const defaultTransport: Transport = createTransport(defaultBindings);
95
169
 
96
- export function getEncodeStats(): { direct: Record<string, number>; json: Record<string, number> } {
97
- return stats.get();
170
+ /** Zero-allocation encode into the shared output scratch (built-in registry). */
171
+ export function encodeToScratch(name: string, payload: unknown): Uint8Array {
172
+ return defaultTransport.encodeToScratch(name, payload);
98
173
  }
99
174
 
100
175
  /** 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;
176
+ export function encodeEvent(name: string, payload: unknown): Uint8Array {
177
+ return defaultTransport.encodeEvent(name, payload);
178
+ }
179
+
180
+ /** encode-path stats for the built-in registry (direct vs JSON). */
181
+ export function getEncodeStats(): {
182
+ direct: Record<string, number>;
183
+ json: Record<string, number>;
184
+ js: Record<string, number>;
185
+ } {
186
+ return defaultTransport.getEncodeStats();
106
187
  }