@ignex/nova 0.1.1

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -0
  3. package/docs/architecture.md +146 -0
  4. package/docs/publishing.md +119 -0
  5. package/docs/wire-format.md +170 -0
  6. package/index.ts +61 -0
  7. package/package.json +89 -0
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/client.ts +23 -0
  10. package/public/nats.ts +19 -0
  11. package/public/server.ts +35 -0
  12. package/rust/Cargo.toml +19 -0
  13. package/rust/src/ffi.rs +135 -0
  14. package/rust/src/generated/backend.rs +2817 -0
  15. package/rust/src/generated/mod.rs +2 -0
  16. package/rust/src/lib.rs +9 -0
  17. package/rust/src/transcode/generated.rs +1352 -0
  18. package/rust/src/transcode/mod.rs +2 -0
  19. package/src/bridge/nats.ts +269 -0
  20. package/src/bridge/subjects.ts +30 -0
  21. package/src/core/auth.ts +56 -0
  22. package/src/core/backpressure.ts +39 -0
  23. package/src/core/client-heartbeat.ts +27 -0
  24. package/src/core/client-reconnect.ts +35 -0
  25. package/src/core/client-state.ts +76 -0
  26. package/src/core/client-wire.ts +72 -0
  27. package/src/core/client.ts +176 -0
  28. package/src/core/groups.ts +52 -0
  29. package/src/core/int64-guard.ts +44 -0
  30. package/src/core/metrics.ts +105 -0
  31. package/src/core/outbound.ts +76 -0
  32. package/src/core/replay.ts +31 -0
  33. package/src/core/ring.ts +85 -0
  34. package/src/core/rooms.ts +44 -0
  35. package/src/core/routing.ts +94 -0
  36. package/src/core/server.ts +294 -0
  37. package/src/core/state.ts +179 -0
  38. package/src/generated/direct-ser.ts +495 -0
  39. package/src/generated/fbs/backend.fbs +139 -0
  40. package/src/generated/registry.ts +341 -0
  41. package/src/generated/rust/backend_generated.rs +2817 -0
  42. package/src/generated/ts/backend.ts +25 -0
  43. package/src/generated/ts/big-val.ts +106 -0
  44. package/src/generated/ts/complex.ts +303 -0
  45. package/src/generated/ts/customer.ts +137 -0
  46. package/src/generated/ts/hello.ts +123 -0
  47. package/src/generated/ts/join-group.ts +78 -0
  48. package/src/generated/ts/leave-group.ts +78 -0
  49. package/src/generated/ts/order-billing.ts +137 -0
  50. package/src/generated/ts/order-line.ts +144 -0
  51. package/src/generated/ts/order.ts +236 -0
  52. package/src/generated/ts/ping.ts +74 -0
  53. package/src/generated/ts/pong.ts +74 -0
  54. package/src/generated/ts/portfolio-position.ts +120 -0
  55. package/src/generated/ts/portfolio-snapshot.ts +170 -0
  56. package/src/generated/ts/quote.ts +148 -0
  57. package/src/generated/ts/side.ts +8 -0
  58. package/src/generated/ts/snapshot-request.ts +78 -0
  59. package/src/generated/ts/subscribe.ts +78 -0
  60. package/src/generated/ts/tags.ts +9 -0
  61. package/src/generated/ts/trade.ts +135 -0
  62. package/src/generated/ts/unsubscribe.ts +78 -0
  63. package/src/generated/ts/welcome.ts +112 -0
  64. package/src/generated/ts-ser.ts +465 -0
  65. package/src/generated/wire-registry.json +20 -0
  66. package/src/native/codec.ts +35 -0
  67. package/src/native/ffi.ts +214 -0
  68. package/src/native/loader.ts +55 -0
  69. package/src/schema/index.ts +217 -0
  70. package/src/server.ts +87 -0
  71. package/src/transport/byte-buffer-pool.ts +63 -0
  72. package/src/transport/scratch.ts +48 -0
  73. package/src/transport/stats.ts +44 -0
  74. package/src/transport/transport.ts +106 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `bun:ffi` binding to the Rust cdylib — the ONLY place that talks to native.
3
+ *
4
+ * Bun 1.4 standard practices (per the castrum FFI guide):
5
+ * - `dlopen` once, bind lazily, never `close()` (dlclose-on-GC is unsound)
6
+ * - `buffer`/`buffer_length` ABI pair → the engine reads ptr + byteLength off
7
+ * the SAME view at call time (atomic snapshot) — the "peak"/pointer + length
8
+ * pattern. Call sites pass the view twice.
9
+ * - `cstring` arg → the engine transcodes the JS string to a NUL-terminated
10
+ * UTF-8 buffer (zero JS-side encoding).
11
+ * - `u64_fast` return → byte counts surface as plain `number`, not BigInt.
12
+ * - bind-time self-test (`fb_probe` + a JSON `fb_serialize` frame + every
13
+ * direct symbol via the generated `directSelfTest`); a failing direct
14
+ * symbol is DISABLED and its event falls back to the JSON path (graceful
15
+ * degradation instead of a hard throw).
16
+ * - `probeBufferLength()` at bind: if the Bun build rejects the
17
+ * `buffer`/`buffer_length` pair, fall back to explicit `(ptr, usize)`
18
+ * output pairs. The `abi()` transformer keeps the shipped specs canonical
19
+ * (`(ptr, usize)` outputs) and upgrades them at bind time.
20
+ */
21
+ 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";
25
+ import { getAddonPath } from "./loader";
26
+
27
+ export const FB_PROBE_MAGIC = 0x4947_4e58; // "IGNX"
28
+
29
+ export interface BunFfi {
30
+ /** (eventId, JSON string, out view) → bytes written; 0 = error; >cap = needed */
31
+ fb_serialize(eventId: number, json: string, out: Uint8Array): number;
32
+ fb_probe(): number;
33
+ /** Wire-format version of the cdylib (must equal generated WIRE_VERSION). */
34
+ fb_wire_version(): number;
35
+ }
36
+
37
+ /** Output-buffer ABI mode of the live binding (set once by `bind()`). */
38
+ export type BufferAbiMode = "buffer-pair" | "ptr-len";
39
+
40
+ interface Dl {
41
+ bindings: BunFfi;
42
+ /** every bound symbol, callable with raw args (…fieldArgs, out, out) */
43
+ raw: Record<string, (...args: unknown[]) => number>;
44
+ /** direct symbols disabled by the bind-time self-test (fall back to JSON). */
45
+ disabledDirect: Set<string>;
46
+ bufferAbiMode: BufferAbiMode;
47
+ }
48
+
49
+ let cachedDl: Dl | null | undefined;
50
+ let bufferAbiMode: BufferAbiMode = "ptr-len";
51
+
52
+ const U64_FAST = "u64_fast" as unknown as FFITypeOrString;
53
+
54
+ /**
55
+ * Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
56
+ * `dlopen`. Uses the diagnostic `ffi_probe_echo_view` symbol (bench-only, NOT in
57
+ * the shipped surface) bound with the pair and called with the same view twice —
58
+ * the engine reads ptr + byteLength off that object at call time. An older Bun
59
+ * canary threw "invalid ABI type" for `buffer_length`; on any failure we keep
60
+ * explicit `(ptr, usize)` pairs (castrum rule: never hardcode the pair).
61
+ */
62
+ function probeBufferLength(path: string): boolean {
63
+ // Test hook: force the `(ptr, usize)` fallback to validate the degraded path.
64
+ if (process.env.IGNEX_FFI_FORCE_PTR_LEN === "1") return false;
65
+ try {
66
+ const { symbols } = dlopen(path, {
67
+ ffi_probe_echo_view: {
68
+ args: ["buffer", "buffer_length"] as unknown as readonly FFITypeOrString[],
69
+ // `u64_fast` returns a plain `number` (not a BigInt) for small values.
70
+ returns: U64_FAST,
71
+ },
72
+ });
73
+ const fn = (symbols as unknown as Record<string, (a: unknown, b: unknown) => unknown>).ffi_probe_echo_view;
74
+ const view = new Uint8Array([1, 2, 3]);
75
+ const out = fn?.(view, view);
76
+ return typeof out === "number" && out >= 0;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Positional pair-aware `(ptr, usize)` → `(buffer, buffer_length)` upgrade.
84
+ * Each `ptr` consumes its following `usize` as a length; scalar `usize` args
85
+ * that happen to appear (future opaque handles) pass through unchanged.
86
+ * Applied to canonical specs at bind time; a `buffer`/`usize` INPUT pair is
87
+ * left untouched (explicit JS length — no atomic-snapshot need).
88
+ */
89
+ const abi = (shape: readonly string[]): readonly FFITypeOrString[] => {
90
+ if (bufferAbiMode !== "buffer-pair") return shape as readonly FFITypeOrString[];
91
+ const out: FFITypeOrString[] = [];
92
+ for (let i = 0; i < shape.length; i++) {
93
+ const t = shape[i];
94
+ if (t === "ptr") {
95
+ out.push("buffer" as unknown as FFITypeOrString);
96
+ out.push("buffer_length" as unknown as FFITypeOrString);
97
+ i++; // consume the paired `usize`
98
+ } else {
99
+ out.push(t as FFITypeOrString);
100
+ }
101
+ }
102
+ return out;
103
+ };
104
+
105
+ /**
106
+ * Mode-aware wrapper for symbols whose LAST TWO args are the output view passed
107
+ * twice (`…, out, out`). In `ptr-len` mode the trailing `out` becomes
108
+ * `out.byteLength` so the explicit `(ptr, usize)` spec receives a length number.
109
+ */
110
+ function adaptOut(sym: (...a: unknown[]) => number, mode: BufferAbiMode): (...a: unknown[]) => number {
111
+ if (mode !== "ptr-len") return sym;
112
+ return (...args: unknown[]) => {
113
+ if (args.length === 0) return sym(...args); // fb_probe: no output pair
114
+ const out = args[args.length - 1] as Uint8Array;
115
+ args[args.length - 1] = out.byteLength;
116
+ return sym(...args);
117
+ };
118
+ }
119
+
120
+ function bind(): Dl {
121
+ const path = getAddonPath();
122
+
123
+ // Probe the atomic `buffer`/`buffer_length` pair once; fall back to explicit
124
+ // `(ptr, usize)` output pairs when the Bun build rejects it.
125
+ bufferAbiMode = probeBufferLength(path) ? "buffer-pair" : "ptr-len";
126
+
127
+ // Build the dlopen map from CANONICAL specs, upgrading `ptr` outputs to the
128
+ // `buffer`/`buffer_length` pair when supported.
129
+ const specMap: Record<string, { args: readonly FFITypeOrString[]; returns: FFITypeOrString }> = {
130
+ fb_serialize: { args: abi(["u32", "cstring", "ptr", "usize"]), returns: U64_FAST },
131
+ fb_probe: { args: [], returns: "u32" },
132
+ fb_wire_version: { args: [], returns: "u32" },
133
+ };
134
+ for (const [name, spec] of Object.entries(directSymbols)) {
135
+ specMap[name] = { args: abi(spec.args), returns: spec.returns as FFITypeOrString };
136
+ }
137
+
138
+ const { symbols } = dlopen(path, specMap as unknown as Parameters<typeof dlopen>[1]);
139
+
140
+ // Adapt every bound symbol so call sites always pass `(…, out, out)`.
141
+ const raw: Record<string, (...args: unknown[]) => number> = {};
142
+ for (const [name, sym] of Object.entries(symbols as unknown as Record<string, (...args: unknown[]) => number>)) {
143
+ raw[name] = adaptOut(sym, bufferAbiMode);
144
+ }
145
+
146
+ const bindings: BunFfi = {
147
+ fb_serialize: (eventId, json, out) => raw["fb_serialize"]!(eventId, json, out, out) as number,
148
+ fb_probe: () => raw["fb_probe"]!() as number,
149
+ fb_wire_version: () => raw["fb_wire_version"]!() as number,
150
+ };
151
+
152
+ // ── Bind-time self-tests ─────────────────────────────────────────────
153
+ if (bindings.fb_probe() !== FB_PROBE_MAGIC) {
154
+ throw new Error(`ignex: FFI self-test failed (fb_probe mismatch) — addon at ${path}`);
155
+ }
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) {
159
+ throw new Error(
160
+ `ignex: FFI self-test failed (wire version ${bindings.fb_wire_version()} !== ${WIRE_VERSION}) — addon at ${path}; regenerate + rebuild`,
161
+ );
162
+ }
163
+ // JSON path sanity: `{}` → default frame for the FIRST event; verify the
164
+ // frame invariant `out[0]` = WIRE_VERSION, `out[1..5]` = event id AND
165
+ // `bytes_written === WIRE_HEADER_LEN + 4 + size_prefix`.
166
+ const firstEvent = Object.keys(eventNameToId)[0] as EventName;
167
+ const firstId = eventNameToId[firstEvent];
168
+ const scratch = new Uint8Array(2048);
169
+ const jw = bindings.fb_serialize(firstId, "{}", scratch);
170
+ if (jw === 0 || jw > scratch.byteLength) {
171
+ throw new Error(`ignex: FFI self-test failed (fb_serialize JSON) — addon at ${path}`);
172
+ }
173
+ {
174
+ const dv = new DataView(scratch.buffer, scratch.byteOffset, scratch.byteLength);
175
+ const size = dv.getUint32(scratch.byteOffset + WIRE_HEADER_LEN, true);
176
+ const gotId = dv.getUint32(scratch.byteOffset + 1, true);
177
+ if (scratch[0] !== WIRE_VERSION || gotId !== firstId || jw !== WIRE_HEADER_LEN + 4 + size) {
178
+ throw new Error(`ignex: FFI self-test failed (fb_serialize frame invariant) — addon at ${path}`);
179
+ }
180
+ }
181
+ // Direct fast-path: probe every generated symbol; disable the failures so
182
+ // their events gracefully fall back to the JSON path.
183
+ const disabledDirect = new Set(directSelfTest(raw, new Uint8Array(2048)));
184
+
185
+ return { bindings, raw, disabledDirect, bufferAbiMode };
186
+ }
187
+
188
+ function ensure(): Dl {
189
+ if (cachedDl === undefined) cachedDl = bind();
190
+ return cachedDl as Dl;
191
+ }
192
+
193
+ /** Lazily bind once. Throws if the addon is missing or the self-test fails. */
194
+ export function getFfi(): BunFfi {
195
+ return ensure().bindings;
196
+ }
197
+
198
+ /** Output-buffer ABI mode of the live binding (lazy — triggers bind). */
199
+ export function getBufferAbiMode(): BufferAbiMode {
200
+ return ensure().bufferAbiMode;
201
+ }
202
+
203
+ /**
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).
207
+ */
208
+ export function getDirectSymbol(name: string): ((...args: unknown[]) => number) | undefined {
209
+ const dl = ensure();
210
+ if (dl.disabledDirect.has(name)) return undefined;
211
+ const symbol = dl.raw[name];
212
+ if (!symbol) throw new Error(`ignex: unknown direct symbol "${name}"`);
213
+ return symbol;
214
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Resolve the Rust cdylib path for the CURRENT platform/arch.
6
+ *
7
+ * Previously hardcoded to `rust/target/release/libignex_ffi.so` (Linux-only).
8
+ * Now maps `process.platform` → the correct library name (.so / .dylib / .dll)
9
+ * and searches, in order:
10
+ * 1. `IGNEX_FFI_PATH` env override (absolute path to the addon)
11
+ * 2. the in-repo dev build: `<repo>/rust/target/release/<lib>`
12
+ * 3. the packaged npm layout: `<pkg>/prebuilds/<platform>-<arch>/<lib>`
13
+ *
14
+ * Bun is the only supported runtime; the addon must be built per-OS (see
15
+ * README). A clear error listing every candidate is thrown when missing.
16
+ */
17
+ const LIB_NAMES: Record<string, string> = {
18
+ linux: "libignex_ffi.so",
19
+ darwin: "libignex_ffi.dylib",
20
+ win32: "ignex_ffi.dll",
21
+ };
22
+
23
+ /** The cdylib filename for a platform (used for tests + build tooling). */
24
+ export function addonFilename(platform: string = process.platform): string {
25
+ const name = LIB_NAMES[platform];
26
+ if (!name) throw new Error(`ignex: unsupported platform "${platform}"`);
27
+ return name;
28
+ }
29
+
30
+ const here = import.meta.dir;
31
+
32
+ /** Candidate addon paths in priority order (first existing wins). */
33
+ export function addonCandidates(): string[] {
34
+ const file = addonFilename();
35
+ const tag = `${process.platform}-${process.arch}`;
36
+ return [
37
+ // in-repo dev build (repo root = src/native/../../)
38
+ join(here, "..", "..", "rust", "target", "release", file),
39
+ // packaged npm layout: <pkg-root>/prebuilds/<platform>-<arch>/<lib>
40
+ join(here, "..", "prebuilds", tag, file),
41
+ join(here, "..", "..", "prebuilds", tag, file),
42
+ ];
43
+ }
44
+
45
+ export function getAddonPath(): string {
46
+ const override = process.env.IGNEX_FFI_PATH;
47
+ if (override) return override;
48
+ for (const p of addonCandidates()) {
49
+ if (existsSync(p)) return p;
50
+ }
51
+ throw new Error(
52
+ `ignex: native addon not found. Tried:\n ${addonCandidates().join("\n ")}\n` +
53
+ `Build it: cargo build --release --manifest-path rust/Cargo.toml (or set IGNEX_FFI_PATH)`,
54
+ );
55
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * TypeBox schema — the SINGLE SOURCE OF TRUTH for the wire format.
3
+ *
4
+ * Everything else is generated from here:
5
+ * - `scripts/generate.ts` → backend.fbs → flatc --ts (browser decoders) +
6
+ * Rust glue (`rust/src/transcode/generated.rs`) + `src/generated/registry.ts`.
7
+ *
8
+ * The `events` registry defines the pub/sub event surface. Each event maps to
9
+ * a TypeBox schema; `Events[K]` is the plain-object type devs see on both the
10
+ * server (publish) and the FE (on) — no FlatBuffer API anywhere in sight.
11
+ */
12
+ import { Type, type Static } from "@sinclair/typebox";
13
+
14
+ // ── Enums (union of string literals → FlatBuffer enum) ───────────────
15
+ export const Side = Type.Union([Type.Literal("buy"), Type.Literal("sell")]);
16
+
17
+ // ── Realtime market-data payloads ─────────────────────────────────────
18
+ export const Trade = Type.Object(
19
+ {
20
+ symbol: Type.String(),
21
+ price: Type.Number(), // double
22
+ volume: Type.Integer(), // int64
23
+ side: Side, // enum
24
+ ts: Type.Integer(), // int64
25
+ },
26
+ { additionalProperties: false },
27
+ );
28
+
29
+ export const Quote = Type.Object(
30
+ {
31
+ symbol: Type.String(),
32
+ bid: Type.Number(),
33
+ ask: Type.Number(),
34
+ bidSize: Type.Integer(),
35
+ askSize: Type.Integer(),
36
+ ts: Type.Integer(),
37
+ },
38
+ { additionalProperties: false },
39
+ );
40
+
41
+ export const PortfolioPosition = Type.Object(
42
+ {
43
+ symbol: Type.String(),
44
+ quantity: Type.Integer(),
45
+ avgPrice: Type.Number(),
46
+ pnl: Type.Number(),
47
+ },
48
+ { additionalProperties: false },
49
+ );
50
+
51
+ export const PortfolioSnapshot = Type.Object(
52
+ {
53
+ accountId: Type.String(),
54
+ positions: Type.Array(PortfolioPosition), // vector of tables
55
+ totalValue: Type.Number(),
56
+ cash: Type.Number(),
57
+ ts: Type.Integer(),
58
+ updatedBy: Type.Optional(Type.String()), // optional string
59
+ },
60
+ { additionalProperties: false },
61
+ );
62
+
63
+ // ── Complex / nested payloads (integrity + nesting coverage) ──────────
64
+ export const Tag = Type.Union([Type.Literal("hot"), Type.Literal("new"), Type.Literal("sale")]);
65
+
66
+ /** Every packed-vector kind + scalars on ONE flat event (DIRECT fast path). */
67
+ export const Complex = Type.Object(
68
+ {
69
+ id: Type.String(),
70
+ names: Type.Array(Type.String()), // vector-string
71
+ prices: Type.Array(Type.Number()), // vector-double
72
+ counts: Type.Array(Type.Integer()), // vector-int64
73
+ flags: Type.Array(Type.Boolean()), // vector-bool
74
+ tags: Type.Array(Tag), // vector-enum
75
+ active: Type.Boolean(),
76
+ total: Type.Number(),
77
+ ts: Type.Integer(),
78
+ },
79
+ { additionalProperties: false },
80
+ );
81
+
82
+ /** Nested single-object table (forces the JSON fallback path in `order`). */
83
+ export const Customer = Type.Object(
84
+ {
85
+ id: Type.String(),
86
+ name: Type.String(),
87
+ vip: Type.Boolean(),
88
+ loyaltyPoints: Type.Integer(),
89
+ rating: Type.Number(),
90
+ },
91
+ { additionalProperties: false },
92
+ );
93
+
94
+ /** Table element with its own vector (deep nesting — not "flat"). */
95
+ export const OrderLine = Type.Object(
96
+ {
97
+ sku: Type.String(),
98
+ qty: Type.Integer(),
99
+ unitPrice: Type.Number(),
100
+ tags: Type.Array(Tag), // vector-enum inside a table element
101
+ },
102
+ { additionalProperties: false },
103
+ );
104
+
105
+ /** Deeply nested event: single-object table, optional table, table-of-tables. */
106
+ export const Order = Type.Object(
107
+ {
108
+ orderId: Type.String(),
109
+ customer: Customer, // nested single-object table
110
+ lines: Type.Array(OrderLine), // vector of tables (each with a vector-enum)
111
+ notes: Type.Array(Type.String()), // vector-string
112
+ discounts: Type.Array(Type.Number()), // vector-double
113
+ active: Type.Boolean(),
114
+ createdAt: Type.Integer(),
115
+ billing: Type.Optional(Customer), // optional nested table
116
+ },
117
+ { additionalProperties: false },
118
+ );
119
+
120
+ /**
121
+ * An event with an EXACT int64 field: `Type.Integer({ bigint: true })` makes
122
+ * the field decode/encode as `bigint` on both sides, so values above 2^53
123
+ * round-trip losslessly (plain `number` int64s silently lose precision there).
124
+ */
125
+ export const BigVal = Type.Object(
126
+ {
127
+ id: Type.String(),
128
+ seq: Type.BigInt(), // exact int64 — survives beyond 2^53 (maps to `long` on the wire)
129
+ when: Type.Integer(), // plain number (safe-integer timestamps)
130
+ },
131
+ { additionalProperties: false },
132
+ );
133
+
134
+ // ── Named schemas (referenced tables / enums) ────────────────────────
135
+ export const schemas = {
136
+ Trade,
137
+ Quote,
138
+ PortfolioPosition,
139
+ PortfolioSnapshot,
140
+ Complex,
141
+ Customer,
142
+ OrderLine,
143
+ Order,
144
+ BigVal,
145
+ } as const;
146
+
147
+ // ── Event registry (source of truth for event ids + public API types) ─
148
+ export const events = {
149
+ quote: Quote,
150
+ trade: Trade,
151
+ portfolio: PortfolioSnapshot,
152
+ complex: Complex, // DIRECT path — all packed-vector kinds
153
+ order: Order, // JSON fallback path — nested single-object tables
154
+ bigVal: BigVal, // DIRECT path — exact bigint int64 field
155
+ } as const;
156
+
157
+ // ── Control events (transport-internal — hidden from the public Events surface) ─
158
+ //
159
+ // These are first-class FlatBuffer tables so they ride the SAME codegen,
160
+ // self-tests, and wire envelope as app events. The server encodes them via the
161
+ // Rust FFI (direct path — all are flat/directable); the browser client encodes
162
+ // them via the generated JS encoder (ts-ser). The transport layers route
163
+ // control frames BEFORE user handlers.
164
+ //
165
+ // Rules enforced by the direct fast path (why fields are shaped like this):
166
+ // - no optional scalars / optional vectors (direct path has no Option support
167
+ // for them) — use required fields with a 0/[] sentinel
168
+ export const Hello = Type.Object(
169
+ {
170
+ version: Type.Integer(), // wire version the sender supports
171
+ caps: Type.Array(Type.String()), // capabilities list ([] = none)
172
+ lastSeq: Type.Integer(), // last sequence seen (0 = none)
173
+ },
174
+ { additionalProperties: false },
175
+ );
176
+
177
+ export const Subscribe = Type.Object({ topic: Type.String() }, { additionalProperties: false });
178
+
179
+ export const Unsubscribe = Type.Object({ topic: Type.String() }, { additionalProperties: false });
180
+
181
+ export const JoinGroup = Type.Object({ group: Type.String() }, { additionalProperties: false });
182
+
183
+ export const LeaveGroup = Type.Object({ group: Type.String() }, { additionalProperties: false });
184
+
185
+ export const SnapshotRequest = Type.Object({ topic: Type.String() }, { additionalProperties: false });
186
+
187
+ export const Ping = Type.Object({ ts: Type.Integer() }, { additionalProperties: false });
188
+
189
+ export const Pong = Type.Object({ ts: Type.Integer() }, { additionalProperties: false });
190
+
191
+ /** Server→client identity assignment (sent right after `hello` on open). */
192
+ export const Welcome = Type.Object(
193
+ {
194
+ clientId: Type.String(), // the id assigned to this connection (auth metadata or UUID)
195
+ groups: Type.Array(Type.String()), // server-side groups this client belongs to
196
+ },
197
+ { additionalProperties: false },
198
+ );
199
+
200
+ /** Transport-internal event registry (name → schema). Not part of Events[K]. */
201
+ export const controlEvents = {
202
+ hello: Hello,
203
+ welcome: Welcome,
204
+ subscribe: Subscribe,
205
+ unsubscribe: Unsubscribe,
206
+ joinGroup: JoinGroup,
207
+ leaveGroup: LeaveGroup,
208
+ snapshotRequest: SnapshotRequest,
209
+ ping: Ping,
210
+ pong: Pong,
211
+ } as const;
212
+
213
+ export type EventName = keyof typeof events;
214
+ export type ControlEventName = keyof typeof controlEvents;
215
+ export type AnyEventName = EventName | ControlEventName;
216
+ export type Events = { [K in EventName]: Static<(typeof events)[K]> };
217
+ export type ControlEvents = { [K in ControlEventName]: Static<(typeof controlEvents)[K]> };
package/src/server.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Runnable demo server.
3
+ *
4
+ * bun run serve (after: bun run generate && cargo build --release && bun run build:client)
5
+ *
6
+ * Serves:
7
+ * - /ws → websocket (typed pub/sub events)
8
+ * - /health → health check
9
+ * - / → client/index.html + /dist/* from client-dist/ (browser demo)
10
+ */
11
+ import { join } from "node:path";
12
+ import { createServer } from "../public/server";
13
+
14
+ const here = import.meta.dir;
15
+ const CLIENT_DIR = join(here, "..", "client");
16
+ const DIST_DIR = join(here, "..", "client-dist");
17
+
18
+ const port = Number(process.env.PORT ?? 3000);
19
+ const natsUrl = process.env.NATS_URL;
20
+ const server = createServer({
21
+ port,
22
+ nats: natsUrl ? { servers: [natsUrl], inbound: true } : undefined,
23
+ fetch: (req) => serveStatic(req),
24
+ });
25
+
26
+ console.log(`ignex demo: http://localhost:${port}/ (ws: ws://localhost:${port}/ws)`);
27
+ if (natsUrl) {
28
+ console.log(`ignex demo: NATS bridge → ${natsUrl} (subjects: ignex.broadcast.* / ignex.topic.* / ignex.group.*; inbound: ignex.inbound.>)`);
29
+ }
30
+
31
+ const CONTENT_TYPES: Record<string, string> = {
32
+ ".html": "text/html; charset=utf-8",
33
+ ".js": "text/javascript; charset=utf-8",
34
+ ".mjs": "text/javascript; charset=utf-8",
35
+ ".css": "text/css; charset=utf-8",
36
+ ".json": "application/json",
37
+ ".svg": "image/svg+xml",
38
+ };
39
+
40
+ async function serveStatic(req: Request): Promise<Response> {
41
+ const url = new URL(req.url);
42
+ const rel = url.pathname === "/" ? "/index.html" : url.pathname;
43
+ const file = rel.startsWith("/dist/")
44
+ ? join(DIST_DIR, rel.slice("/dist/".length))
45
+ : join(CLIENT_DIR, rel);
46
+ const f = Bun.file(file);
47
+ if (!(await f.exists())) return new Response("not found", { status: 404 });
48
+ const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
49
+ return new Response(f, { headers: { "content-type": CONTENT_TYPES[ext] ?? "application/octet-stream" } });
50
+ }
51
+
52
+ // Static page + pub/sub websocket share one port: /ws is upgraded by the
53
+ // event server, everything else falls through to the static file handler.
54
+
55
+ // ── push synthetic market data ─────────────────────────────────────────
56
+ let seq = 0;
57
+ setInterval(() => {
58
+ seq++;
59
+ server.publish("quote", {
60
+ symbol: "AAPL",
61
+ bid: 180 + Math.sin(seq / 10) * 0.5,
62
+ ask: 180.1 + Math.cos(seq / 10) * 0.5,
63
+ bidSize: 100 + (seq % 400),
64
+ askSize: 200 + (seq % 300),
65
+ ts: Date.now(),
66
+ });
67
+ server.publish("trade", {
68
+ symbol: seq % 2 === 0 ? "AAPL" : "MSFT",
69
+ price: 180 + Math.random(),
70
+ volume: 1 + (seq % 50),
71
+ side: seq % 2 === 0 ? "buy" : "sell",
72
+ ts: Date.now(),
73
+ });
74
+ if (seq % 5 === 0) {
75
+ server.publish("portfolio", {
76
+ accountId: "demo-1",
77
+ positions: [
78
+ { symbol: "AAPL", quantity: 100, avgPrice: 175, pnl: 500 + seq },
79
+ { symbol: "MSFT", quantity: 50, avgPrice: 400, pnl: -120 + seq },
80
+ ],
81
+ totalValue: 18000 + seq,
82
+ cash: 2500,
83
+ ts: Date.now(),
84
+ updatedBy: "ignex-demo",
85
+ });
86
+ }
87
+ }, 0);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Pooled `flatbuffers.ByteBuffer` — avoids allocating a fresh ByteBuffer (and
3
+ * the new `TextDecoder` its constructor creates) for every decoded frame.
4
+ *
5
+ * Why this is safe:
6
+ * - `flatbuffers.ByteBuffer` stores its backing view in a stable internal
7
+ * field (`bytes_`) and its read offset in `position_` (plain JS properties
8
+ * in v25). The read offset also has a public `setPosition()`.
9
+ * - Decoding is SYNCHRONOUS and the generated `.unpack()`/`*ToPlain` path
10
+ * fully materializes the plain-object payload before `decodePayload`
11
+ * returns — nothing reads the ByteBuffer afterwards, and no lazy reference
12
+ * to the frame bytes escapes (strings are decoded eagerly via
13
+ * `text_decoder_`, vectors are copied into fresh arrays).
14
+ * - Therefore a single reused instance can be re-pointed at each new frame.
15
+ *
16
+ * Robustness: the first use verifies the internal `bytes_` field exists. If a
17
+ * future flatbuffers version renames it, we fall back to constructing a fresh
18
+ * ByteBuffer per call (the previous behavior). Browser-safe (imports only
19
+ * `flatbuffers`), so the generated registry (used by the client bundle) can use
20
+ * it.
21
+ */
22
+ import * as flatbuffers from "flatbuffers";
23
+
24
+ let pooled: flatbuffers.ByteBuffer | null = null;
25
+ /** null = not probed yet; true = pooling works; false = use fresh per call. */
26
+ let usable: boolean | null = null;
27
+
28
+ function probe(): boolean {
29
+ const bb = new flatbuffers.ByteBuffer(new Uint8Array(1));
30
+ return (bb as unknown as { bytes_?: Uint8Array }).bytes_ instanceof Uint8Array;
31
+ }
32
+
33
+ /**
34
+ * Return a ByteBuffer viewing `bytes`, positioned at `pos` (a RELATIVE index
35
+ * into `bytes`, i.e. the wire envelope length — flatbuffers reads are
36
+ * `bytes_[offset]`, relative to the view's start, so `position_` must be
37
+ * relative too, NOT `bytes.byteOffset + pos`). Reuses a single pooled instance
38
+ * when the running flatbuffers version supports it; otherwise constructs a
39
+ * fresh one (also positioned) — the previous behavior.
40
+ */
41
+ export function pooledByteBuffer(bytes: Uint8Array, pos: number): flatbuffers.ByteBuffer {
42
+ if (usable === false) {
43
+ const bb = new flatbuffers.ByteBuffer(bytes);
44
+ bb.setPosition(pos);
45
+ return bb;
46
+ }
47
+ if (usable === null) usable = probe();
48
+ if (!usable) {
49
+ const bb = new flatbuffers.ByteBuffer(bytes);
50
+ bb.setPosition(pos);
51
+ return bb;
52
+ }
53
+ if (!pooled) pooled = new flatbuffers.ByteBuffer(bytes);
54
+ (pooled as unknown as { bytes_: Uint8Array }).bytes_ = bytes;
55
+ pooled.setPosition(pos);
56
+ return pooled;
57
+ }
58
+
59
+ /** @internal test hook — force the fresh-per-call fallback path. */
60
+ export function __forceFreshPoolForTest(force: boolean): void {
61
+ usable = force ? false : null;
62
+ pooled = null;
63
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Reusable output scratch buffer for the zero-alloc encode path. `createScratch`
3
+ * owns a growable `Uint8Array` and the FFI "needed-size" grow/retry convention
4
+ * (`0` = error, `w > cap` = exact size). One instance is created per process in
5
+ * `transport.ts` and reused for every encode — no per-call allocation.
6
+ */
7
+ export interface Scratch {
8
+ /** current backing buffer (may be reallocated by `grow`) */
9
+ readonly view: Uint8Array;
10
+ grow(needed: number): void;
11
+ /**
12
+ * Validate an FFI write count, growing + retrying once if the buffer was too
13
+ * small. Returns the final write size, or throws on error/retry-failure.
14
+ */
15
+ neededSize(name: string, w: number, retry: () => number): number;
16
+ }
17
+
18
+ export const MIN_CAP = 512;
19
+
20
+ export function createScratch(initialCap = MIN_CAP): Scratch {
21
+ let buf = new Uint8Array(initialCap);
22
+
23
+ function grow(needed: number): void {
24
+ if (buf.byteLength >= needed) return;
25
+ buf = new Uint8Array(Math.max(buf.byteLength * 2, needed));
26
+ }
27
+
28
+ function neededSize(name: string, w: number, retry: () => number): number {
29
+ if (w === 0) throw new Error(`ignex: serialize failed for event "${name}"`);
30
+ if (w > buf.byteLength) {
31
+ grow(w);
32
+ const w2 = retry();
33
+ if (w2 === 0 || w2 > buf.byteLength) {
34
+ throw new Error(`ignex: serialize retry failed for event "${name}"`);
35
+ }
36
+ return w2;
37
+ }
38
+ return w;
39
+ }
40
+
41
+ return {
42
+ get view(): Uint8Array {
43
+ return buf;
44
+ },
45
+ grow,
46
+ neededSize,
47
+ };
48
+ }