@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.
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/docs/architecture.md +146 -0
- package/docs/publishing.md +119 -0
- package/docs/wire-format.md +170 -0
- package/index.ts +61 -0
- package/package.json +89 -0
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/client.ts +23 -0
- package/public/nats.ts +19 -0
- package/public/server.ts +35 -0
- package/rust/Cargo.toml +19 -0
- package/rust/src/ffi.rs +135 -0
- package/rust/src/generated/backend.rs +2817 -0
- package/rust/src/generated/mod.rs +2 -0
- package/rust/src/lib.rs +9 -0
- package/rust/src/transcode/generated.rs +1352 -0
- package/rust/src/transcode/mod.rs +2 -0
- package/src/bridge/nats.ts +269 -0
- package/src/bridge/subjects.ts +30 -0
- package/src/core/auth.ts +56 -0
- package/src/core/backpressure.ts +39 -0
- package/src/core/client-heartbeat.ts +27 -0
- package/src/core/client-reconnect.ts +35 -0
- package/src/core/client-state.ts +76 -0
- package/src/core/client-wire.ts +72 -0
- package/src/core/client.ts +176 -0
- package/src/core/groups.ts +52 -0
- package/src/core/int64-guard.ts +44 -0
- package/src/core/metrics.ts +105 -0
- package/src/core/outbound.ts +76 -0
- package/src/core/replay.ts +31 -0
- package/src/core/ring.ts +85 -0
- package/src/core/rooms.ts +44 -0
- package/src/core/routing.ts +94 -0
- package/src/core/server.ts +294 -0
- package/src/core/state.ts +179 -0
- package/src/generated/direct-ser.ts +495 -0
- package/src/generated/fbs/backend.fbs +139 -0
- package/src/generated/registry.ts +341 -0
- package/src/generated/rust/backend_generated.rs +2817 -0
- package/src/generated/ts/backend.ts +25 -0
- package/src/generated/ts/big-val.ts +106 -0
- package/src/generated/ts/complex.ts +303 -0
- package/src/generated/ts/customer.ts +137 -0
- package/src/generated/ts/hello.ts +123 -0
- package/src/generated/ts/join-group.ts +78 -0
- package/src/generated/ts/leave-group.ts +78 -0
- package/src/generated/ts/order-billing.ts +137 -0
- package/src/generated/ts/order-line.ts +144 -0
- package/src/generated/ts/order.ts +236 -0
- package/src/generated/ts/ping.ts +74 -0
- package/src/generated/ts/pong.ts +74 -0
- package/src/generated/ts/portfolio-position.ts +120 -0
- package/src/generated/ts/portfolio-snapshot.ts +170 -0
- package/src/generated/ts/quote.ts +148 -0
- package/src/generated/ts/side.ts +8 -0
- package/src/generated/ts/snapshot-request.ts +78 -0
- package/src/generated/ts/subscribe.ts +78 -0
- package/src/generated/ts/tags.ts +9 -0
- package/src/generated/ts/trade.ts +135 -0
- package/src/generated/ts/unsubscribe.ts +78 -0
- package/src/generated/ts/welcome.ts +112 -0
- package/src/generated/ts-ser.ts +465 -0
- package/src/generated/wire-registry.json +20 -0
- package/src/native/codec.ts +35 -0
- package/src/native/ffi.ts +214 -0
- package/src/native/loader.ts +55 -0
- package/src/schema/index.ts +217 -0
- package/src/server.ts +87 -0
- package/src/transport/byte-buffer-pool.ts +63 -0
- package/src/transport/scratch.ts +48 -0
- package/src/transport/stats.ts +44 -0
- 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
|
+
}
|