@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.
- package/README.md +132 -32
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +232 -0
- package/docs/architecture.md +35 -10
- package/docs/events.md +170 -0
- package/docs/generic-bindings.md +197 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +9 -2
- package/index.ts +75 -27
- package/package.json +12 -2
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/bindings.ts +24 -0
- package/public/client.ts +5 -1
- package/public/events.ts +71 -0
- package/public/generate.ts +416 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +42 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/transcode/generated.rs +2 -1
- package/src/bindings/assemble.ts +73 -0
- package/src/bindings/default.ts +65 -0
- package/src/bindings/types.ts +113 -0
- package/src/bridge/nats.ts +53 -13
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +18 -0
- package/src/codegen/direct-gen.ts +550 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +242 -0
- package/src/codegen/rust-glue-gen.ts +545 -0
- package/src/codegen/schema-model.ts +338 -0
- package/src/codegen/ts-ser-gen.ts +221 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +2 -1
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-state.ts +21 -8
- package/src/core/client-wire.ts +10 -11
- package/src/core/client.ts +34 -29
- package/src/core/groups.ts +3 -0
- package/src/core/metrics.ts +7 -3
- package/src/core/outbound.ts +12 -5
- package/src/core/routing.ts +17 -8
- package/src/core/server.ts +108 -34
- package/src/core/state.ts +51 -13
- package/src/events/clients.ts +156 -0
- package/src/events/cluster.ts +732 -0
- package/src/events/data.ts +38 -0
- package/src/events/emit.ts +127 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub.ts +481 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +96 -0
- package/src/events/registry.ts +178 -0
- package/src/events/types.ts +378 -0
- package/src/generated/direct-ser.ts +2 -1
- package/src/generated/fbs/backend.fbs +1 -1
- package/src/generated/registry.ts +3 -1
- package/src/generated/ts-ser.ts +1 -1
- package/src/generated/wire-registry.json +1 -0
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +5 -2
- package/src/server.ts +7 -3
- package/src/transport/stats.ts +8 -4
- 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 {
|
|
23
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
157
|
-
//
|
|
158
|
-
if (bindings.fb_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 (
|
|
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
|
|
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 +
|
|
209
|
+
const size = dv.getUint32(scratch.byteOffset + req.wireHeaderLen, true);
|
|
176
210
|
const gotId = dv.getUint32(scratch.byteOffset + 1, true);
|
|
177
|
-
if (scratch[0] !==
|
|
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(
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
250
|
+
/** Lazily bind once (built-in registry). Throws if the addon is missing. */
|
|
194
251
|
export function getFfi(): BunFfi {
|
|
195
|
-
return
|
|
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
|
|
257
|
+
return ensureDefault().bufferAbiMode;
|
|
201
258
|
}
|
|
202
259
|
|
|
203
260
|
/**
|
|
204
|
-
* Direct fast-path symbol. Call it with
|
|
205
|
-
* bytes written (0 = error, >cap = needed).
|
|
206
|
-
* disabled by the bind-time self-test
|
|
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 =
|
|
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}"`);
|
package/src/schema/index.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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, {
|
|
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
|
package/src/transport/stats.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
13
|
-
import {
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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?:
|
|
54
|
+
encoder?: DirectEncoder;
|
|
36
55
|
/** resolved FFI symbol (undefined = symbol disabled → JSON fallback) */
|
|
37
|
-
call
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
93
|
+
return r;
|
|
54
94
|
}
|
|
55
|
-
return r;
|
|
56
|
-
}
|
|
57
95
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return scratch.view.subarray(0, w);
|
|
157
|
+
return {
|
|
158
|
+
encodeToScratch,
|
|
159
|
+
encodeEvent,
|
|
160
|
+
getEncodeStats: () => stats.get(),
|
|
161
|
+
};
|
|
90
162
|
}
|
|
91
163
|
|
|
92
|
-
// ──
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
-
|
|
97
|
-
|
|
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:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|