@ignex/nova 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +136 -33
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +292 -0
  4. package/docs/architecture.md +101 -28
  5. package/docs/events.md +252 -0
  6. package/docs/generic-bindings.md +207 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +74 -20
  9. package/index.ts +75 -27
  10. package/package.json +13 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +510 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +52 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/generated/backend.rs +503 -0
  21. package/rust/src/transcode/generated.rs +377 -17
  22. package/src/bindings/assemble.ts +73 -0
  23. package/src/bindings/default.ts +65 -0
  24. package/src/bindings/types.ts +113 -0
  25. package/src/bridge/nats/inbound.ts +46 -0
  26. package/src/bridge/nats/index.ts +131 -0
  27. package/src/bridge/nats/real-transport.ts +133 -0
  28. package/src/bridge/nats/types.ts +80 -0
  29. package/src/bridge/subjects.ts +3 -0
  30. package/src/codegen/constants.ts +28 -0
  31. package/src/codegen/direct-gen.ts +564 -0
  32. package/src/codegen/fingerprint.ts +44 -0
  33. package/src/codegen/hash.ts +25 -0
  34. package/src/codegen/registry-gen.ts +246 -0
  35. package/src/codegen/rust-glue-gen.ts +552 -0
  36. package/src/codegen/schema-model.ts +363 -0
  37. package/src/codegen/ts-ser-gen.ts +230 -0
  38. package/src/codegen/typebox-to-fbs.ts +60 -0
  39. package/src/core/auth.ts +67 -5
  40. package/src/core/client-heartbeat.ts +2 -1
  41. package/src/core/client-reconnect.ts +9 -2
  42. package/src/core/client-rpc.ts +75 -0
  43. package/src/core/client-state.ts +63 -8
  44. package/src/core/client-wire.ts +148 -15
  45. package/src/core/client.ts +105 -31
  46. package/src/core/groups.ts +8 -0
  47. package/src/core/metrics.ts +42 -21
  48. package/src/core/outbound.ts +62 -11
  49. package/src/core/rate-limit.ts +69 -0
  50. package/src/core/replay.ts +41 -1
  51. package/src/core/resume.ts +181 -0
  52. package/src/core/rooms.ts +10 -3
  53. package/src/core/routing.ts +144 -12
  54. package/src/core/server/client-info.ts +37 -0
  55. package/src/core/server/http-routes.ts +59 -0
  56. package/src/core/server/index.ts +360 -0
  57. package/src/core/server/metrics-view.ts +53 -0
  58. package/src/core/server/socket-lifecycle.ts +57 -0
  59. package/src/core/state.ts +124 -14
  60. package/src/core/topic-log.ts +86 -0
  61. package/src/events/clients.ts +174 -0
  62. package/src/events/cluster/dedupe.ts +43 -0
  63. package/src/events/cluster/envelope.ts +149 -0
  64. package/src/events/cluster/index.ts +50 -0
  65. package/src/events/cluster/keys.ts +33 -0
  66. package/src/events/cluster/kinds.ts +32 -0
  67. package/src/events/cluster/presence-table.ts +99 -0
  68. package/src/events/cluster/presence.ts +53 -0
  69. package/src/events/cluster/redis-client.ts +50 -0
  70. package/src/events/cluster/store-memory.ts +67 -0
  71. package/src/events/cluster/store-redis.ts +44 -0
  72. package/src/events/cluster/subjects.ts +30 -0
  73. package/src/events/cluster/sync.ts +476 -0
  74. package/src/events/cluster/transport-nats.ts +24 -0
  75. package/src/events/cluster/transport-redis.ts +120 -0
  76. package/src/events/cluster-rpc.ts +196 -0
  77. package/src/events/data.ts +38 -0
  78. package/src/events/delivery.ts +83 -0
  79. package/src/events/emit.ts +173 -0
  80. package/src/events/global.ts +117 -0
  81. package/src/events/groups.ts +118 -0
  82. package/src/events/hub/context-factory.ts +79 -0
  83. package/src/events/hub/dispatch.ts +86 -0
  84. package/src/events/hub/index.ts +536 -0
  85. package/src/events/hub/internal.ts +31 -0
  86. package/src/events/hub/metrics-snapshot.ts +84 -0
  87. package/src/events/hub/resolve-cluster.ts +49 -0
  88. package/src/events/index.ts +61 -0
  89. package/src/events/queue.ts +123 -0
  90. package/src/events/registry.ts +214 -0
  91. package/src/events/schedule.ts +73 -0
  92. package/src/events/trace.ts +283 -0
  93. package/src/events/types/client.ts +68 -0
  94. package/src/events/types/cluster.ts +40 -0
  95. package/src/events/types/context.ts +50 -0
  96. package/src/events/types/emit-target.ts +29 -0
  97. package/src/events/types/groups.ts +35 -0
  98. package/src/events/types/hub.ts +124 -0
  99. package/src/events/types/index.ts +30 -0
  100. package/src/events/types/metrics.ts +52 -0
  101. package/src/events/types/options.ts +62 -0
  102. package/src/generated/direct-ser.ts +148 -60
  103. package/src/generated/fbs/backend.fbs +24 -1
  104. package/src/generated/registry.ts +94 -33
  105. package/src/generated/rust/backend_generated.rs +503 -0
  106. package/src/generated/ts/backend.ts +4 -0
  107. package/src/generated/ts/resume.ts +74 -0
  108. package/src/generated/ts/resumed.ts +88 -0
  109. package/src/generated/ts/rpc-call.ts +112 -0
  110. package/src/generated/ts/rpc-result.ts +126 -0
  111. package/src/generated/ts/snapshot-request.ts +19 -5
  112. package/src/generated/ts-ser.ts +110 -17
  113. package/src/generated/wire-registry.json +7 -2
  114. package/src/native/ffi.ts +85 -28
  115. package/src/schema/index.ts +49 -2
  116. package/src/server.ts +7 -3
  117. package/src/transport/transport.ts +200 -79
  118. package/src/bridge/nats.ts +0 -269
  119. package/src/core/server.ts +0 -294
  120. package/src/transport/stats.ts +0 -44
@@ -0,0 +1,510 @@
1
+ /**
2
+ * `generateBindings(schema, options)` — the generic codegen: build the full
3
+ * wire stack (FlatBuffers schema + flatc TS decoders + registry + pure-JS
4
+ * encoder + direct fast-path serde + Rust glue/scaffold + wire registry) for
5
+ * ANY TypeBox schema you define in your app.
6
+ *
7
+ * Usage in your project:
8
+ *
9
+ * // scripts/generate-bindings.ts
10
+ * import { generateBindings } from "@ignex/nova/generate";
11
+ * import { schemas, events, controlEvents } from "../src/schema"; // YOUR TypeBox
12
+ * generateBindings({ schemas, events, controlEvents }, { outDir: "./ignex/generated" }).write();
13
+ *
14
+ * // app/bindings.ts
15
+ * import { makeBindings } from "./ignex/generated"; // generated
16
+ * import * as schema from "../src/schema"; // your TypeBox registry
17
+ * export const bindings = makeBindings(schema);
18
+ *
19
+ * // app/server.ts
20
+ * import { createServer } from "@ignex/nova/server";
21
+ * import { bindings } from "./bindings";
22
+ * const server = createServer({ port: 3000, bindings, nats: { servers: [...], inbound: true } });
23
+ * server.publish("yourEvent", {...}); // typed against YOUR Events
24
+ *
25
+ * Requirements: `flatc` on PATH (the FlatBuffers compiler — same prerequisite
26
+ * as the built-in registry). The Rust side (ffiMode "optional" + `rust: true`)
27
+ * additionally needs a Rust toolchain to BUILD the emitted crate; without it,
28
+ * the server falls back to the pure-JS encoder (correct, slower).
29
+ */
30
+
31
+ import { spawnSync } from "node:child_process";
32
+ import {
33
+ existsSync,
34
+ mkdirSync,
35
+ mkdtempSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ rmSync,
39
+ writeFileSync,
40
+ } from "node:fs";
41
+ import { tmpdir } from "node:os";
42
+ import { join } from "node:path";
43
+ import type { TSchema } from "@sinclair/typebox";
44
+ import { WIRE_VERSION } from "../src/codegen/constants";
45
+ import { emitDirectSer } from "../src/codegen/direct-gen";
46
+ import { schemaFingerprint } from "../src/codegen/fingerprint";
47
+ import { eventId } from "../src/codegen/hash";
48
+ import { emitRegistry } from "../src/codegen/registry-gen";
49
+ import { emitRustGlue } from "../src/codegen/rust-glue-gen";
50
+ import type { Model } from "../src/codegen/schema-model";
51
+ import { buildModel } from "../src/codegen/schema-model";
52
+ import { emitTsSer } from "../src/codegen/ts-ser-gen";
53
+ import { emitFbs } from "../src/codegen/typebox-to-fbs";
54
+ import { controlEvents as standardControlEvents } from "../src/schema";
55
+
56
+ /** Your TypeBox schema registry — the single source of truth for the wire format. */
57
+ export interface SchemaRegistry {
58
+ /** named tables / enums referenced by events (optional — inferred from events). */
59
+ schemas?: Record<string, TSchema>;
60
+ /** app events: name → TypeBox schema (required). */
61
+ events: Record<string, TSchema>;
62
+ /**
63
+ * Extra transport-internal control events (advanced). The STANDARD control
64
+ * events (hello/welcome/subscribe/unsubscribe/joinGroup/leaveGroup/
65
+ * snapshotRequest/ping/pong) are always included — you cannot override or
66
+ * remove them.
67
+ */
68
+ controlEvents?: Record<string, TSchema>;
69
+ }
70
+
71
+ export interface GenerateOptions {
72
+ /** output directory, default "./ignex/generated". */
73
+ outDir?: string;
74
+ /** flatc binary, default "flatc". */
75
+ flatc?: string;
76
+ /** emit the Rust crate scaffold + glue (buildable with cargo), default true. */
77
+ rust?: boolean;
78
+ /**
79
+ * Import specifier for the ignex library in generated code (internal helpers
80
+ * + `assembleBindings`), default "@ignex/nova". The package root exports all
81
+ * of them.
82
+ */
83
+ libraryImport?: string;
84
+ /** NATS subject prefix baked into the generated bindings, default "ignex". */
85
+ subjectPrefix?: string;
86
+ /**
87
+ * Server FFI mode for the generated bindings, default "optional":
88
+ * - "optional" — the Rust addon is used when `IGNEX_FFI_PATH` points at an
89
+ * addon built from the emitted crate (and it passes self-tests);
90
+ * otherwise the pure-JS encoder is used.
91
+ * - "required" — the server throws if the addon is missing / mismatched.
92
+ */
93
+ ffiMode?: "optional" | "required";
94
+ /** wire envelope version (must match the library), default 1. */
95
+ wireVersion?: number;
96
+ /** overwrite `outDir` if it exists, default true. */
97
+ force?: boolean;
98
+ /**
99
+ * Emit a typed FRONTEND client (`<outDir>/client.gen.ts`): the assembled
100
+ * app bindings + a `createRealtimeClient(url, options?)` factory typed
101
+ * against YOUR events (the realtime analog of the generated OpenAPI SDK).
102
+ *
103
+ * ```ts
104
+ * // generated client.gen.ts
105
+ * import { createRealtimeClient } from "./generated/client.gen";
106
+ * const client = createRealtimeClient("ws://host/ws", { reconnect: true });
107
+ * client.on("chat.message", (m) => /* m: ChatMessagePayload *\/);
108
+ * client.send("chat.send", { orderId, body, clientTs });
109
+ * ```
110
+ *
111
+ * `schemaImport` is required: the emitted client assembles bindings from
112
+ * YOUR TypeBox registry (`makeBindings(schema)`), so it must point at the
113
+ * same `{ schemas, events, controlEvents }` object passed here (relative
114
+ * to the emitted file's location in `outDir`).
115
+ */
116
+ client?: {
117
+ /** specifier for the generated wire stack (`makeBindings`), default "./index". */
118
+ wireImport?: string;
119
+ /** specifier for YOUR TypeBox registry, relative to the emitted file. Required. */
120
+ schemaImport: string;
121
+ /** emitted filename inside `outDir`, default "client.gen.ts". */
122
+ outFile?: string;
123
+ };
124
+ }
125
+
126
+ export interface GeneratedBindings {
127
+ /** the normalized wire model (tables / enums / events). */
128
+ model: Model;
129
+ /** stable schema fingerprint (see `scripts/fingerprint.ts`). */
130
+ fingerprint: number;
131
+ wireVersion: number;
132
+ /** machine-readable event-id registry for NATS consumers / independent clients. */
133
+ wireRegistry: { version: number; fingerprint: number; events: Record<string, number> };
134
+ /** all generated artifacts keyed by relative path (e.g. "ts/backend.ts"). */
135
+ files: Record<string, string>;
136
+ /** write `files` to `outDir`; returns the written relative paths. */
137
+ write(): string[];
138
+ }
139
+
140
+ function run(cmd: string, args: string[], cwd: string, flatc: string): void {
141
+ const res = spawnSync(cmd, args, { cwd, encoding: "utf8" });
142
+ if (res.status !== 0) {
143
+ if (res.stdout) console.error(res.stdout);
144
+ if (res.stderr) console.error(res.stderr);
145
+ throw new Error(
146
+ `generateBindings: flatc failed (${res.status}) — is "${flatc}" installed? ` +
147
+ `(brew install flatbuffers / apt install flatbuffers-compiler / download from https://flatbuffers.dev)`,
148
+ );
149
+ }
150
+ }
151
+
152
+ // Rust crate scaffold — the same files the repo's own cdylib is built from,
153
+ // so `cargo build --release` in the generated `rust/` dir yields a
154
+ // schema-matched addon you can point `IGNEX_FFI_PATH` at.
155
+ const RUST_LIB_RS = `//! ignex-nova FFI cdylib (generated for your schema).
156
+ pub mod ffi;
157
+ pub mod generated;
158
+ pub mod transcode;
159
+ `;
160
+
161
+ function rustScaffold(): { lib: string; ffi: string; cargo: string } {
162
+ const here = import.meta.dir;
163
+ const ffi = readFileSync(join(here, "..", "rust", "src", "ffi.rs"), "utf8");
164
+ const lib = RUST_LIB_RS;
165
+ const cargo = `[package]
166
+ name = "app-ignex-ffi"
167
+ version = "0.1.0"
168
+ edition = "2021"
169
+
170
+ [lib]
171
+ name = "ignex_ffi"
172
+ crate-type = ["cdylib", "rlib"]
173
+ path = "src/lib.rs"
174
+
175
+ [dependencies]
176
+ flatbuffers = "25"
177
+ serde = { version = "1", features = ["derive"] }
178
+ serde_json = "1"
179
+
180
+ [profile.release]
181
+ opt-level = 3
182
+ lto = true
183
+ codegen-units = 1
184
+ `;
185
+ return { lib, ffi, cargo };
186
+ }
187
+
188
+ /**
189
+ * Emit the typed FRONTEND client module (see `GenerateOptions.client`).
190
+ * The client assembles the app bindings from YOUR schema and exposes a
191
+ * typed `createRealtimeClient` factory — `on`/`send` are statically checked
192
+ * against the app's event names, so no `as never` casts are needed on the FE.
193
+ */
194
+ function emitClientModel(
195
+ model: Model,
196
+ wireImport: string,
197
+ schemaImport: string,
198
+ libraryImport: string,
199
+ ): string {
200
+ const eventNames = model.events
201
+ .filter((e) => !e.control)
202
+ .map((e) => `"${e.name}"`)
203
+ .join(" | ");
204
+ const lines: string[] = [];
205
+ lines.push("// @generated by ignex-nova generate — DO NOT EDIT");
206
+ lines.push("// Typed frontend realtime client (the realtime analog of the generated OpenAPI SDK).");
207
+ lines.push(`import { createClient } from ${JSON.stringify(libraryImport.replace(/\/bindings$/, "/client"))};`);
208
+ lines.push(`import type { IgnClient, IgnClientOptions } from ${JSON.stringify(libraryImport.replace(/\/bindings$/, "/client"))};`);
209
+ lines.push(`import { makeBindings } from ${JSON.stringify(wireImport)};`);
210
+ lines.push(`import * as schema from ${JSON.stringify(schemaImport)};`);
211
+ lines.push("");
212
+ lines.push("/** The app's assembled runtime bindings (typed against your events). */");
213
+ lines.push("export const bindings = makeBindings(schema);");
214
+ lines.push("export type RealtimeBindings = typeof bindings;");
215
+ lines.push("export type RealtimeClient = IgnClient<RealtimeBindings>;");
216
+ lines.push("");
217
+ lines.push("/** Event names your client can send/observe (derived from the schema). */");
218
+ lines.push(`export type RealtimeEventName = ${eventNames || "never"};`);
219
+ lines.push("");
220
+ lines.push("/**");
221
+ lines.push(" * Create a typed realtime client. `on`/`send` are checked against");
222
+ lines.push(" * RealtimeEventName — payload types flow from the generated registry.");
223
+ lines.push(" */");
224
+ lines.push("export const createRealtimeClient = (");
225
+ lines.push(" url: string,");
226
+ lines.push(' options?: Omit<IgnClientOptions<RealtimeBindings>, "bindings">,');
227
+ lines.push("): RealtimeClient => createClient(url, { ...options, bindings });");
228
+ lines.push("");
229
+ return lines.join("\n");
230
+ }
231
+
232
+ export function generateBindings(
233
+ schema: SchemaRegistry,
234
+ options: GenerateOptions = {},
235
+ ): GeneratedBindings {
236
+ const wireVersion = options.wireVersion ?? WIRE_VERSION;
237
+ // Default: the FFI-free entry points. The package barrel ("@ignex/nova")
238
+ // pulls in bun:ffi at import time, which breaks vitest/node/browser module
239
+ // runners consuming generated bindings. Custom libraryImport values (e.g. a
240
+ // relative path to the repo in tests) keep the legacy single-import behavior.
241
+ const isDefaultLib = options.libraryImport === undefined || options.libraryImport === "@ignex/nova";
242
+ const libraryImport = options.libraryImport ?? "@ignex/nova/bindings";
243
+ const helperImport = isDefaultLib ? "@ignex/nova/internal" : libraryImport;
244
+
245
+ // The standard transport control protocol is always present; users may add
246
+ // custom control events but never override the standard ones.
247
+ const controlSchemas: Record<string, TSchema> = { ...standardControlEvents };
248
+ for (const [name, s] of Object.entries(schema.controlEvents ?? {})) {
249
+ if (name in controlSchemas) {
250
+ throw new Error(
251
+ `generateBindings: control event "${name}" is reserved by the transport protocol — pick a different name`,
252
+ );
253
+ }
254
+ controlSchemas[name] = s;
255
+ }
256
+
257
+ const model = buildModel(schema.schemas ?? {}, schema.events, controlSchemas);
258
+
259
+ // Stable-hash collision check: every event must have a unique FNV-1a id.
260
+ const seen = new Map<number, string>();
261
+ for (const ev of model.events) {
262
+ const id = eventId(ev.name);
263
+ const existing = seen.get(id);
264
+ if (existing !== undefined) {
265
+ throw new Error(
266
+ `generateBindings: event id collision: "${ev.name}" and "${existing}" both hash to ${id} — rename one of them`,
267
+ );
268
+ }
269
+ seen.set(id, ev.name);
270
+ }
271
+
272
+ const fingerprint = schemaFingerprint(model, wireVersion);
273
+ const files: Record<string, string> = {};
274
+ files["backend.fbs"] = emitFbs(model);
275
+ files["wire-registry.json"] =
276
+ JSON.stringify(
277
+ {
278
+ version: wireVersion,
279
+ fingerprint,
280
+ events: Object.fromEntries(model.events.map((ev) => [ev.name, eventId(ev.name)])),
281
+ },
282
+ null,
283
+ 2,
284
+ ) + `\n`;
285
+
286
+ // flatc → TS decoders (+ Rust generated code when rust !== false)
287
+ const flatcBin = options.flatc ?? "flatc";
288
+ const tmp = mkdtempSync(join(tmpdir(), "ignex-gen-"));
289
+ const fbsPath = join(tmp, "backend.fbs");
290
+ writeFileSync(fbsPath, files["backend.fbs"]);
291
+ try {
292
+ const tsOut = join(tmp, "ts");
293
+ mkdirSync(tsOut, { recursive: true });
294
+ run(flatcBin, ["--ts", "--gen-object-api", "-o", tsOut, fbsPath], tmp, flatcBin);
295
+ for (const f of readdirSync(tsOut)) {
296
+ files[`ts/${f}`] = readFileSync(join(tsOut, f), "utf8");
297
+ }
298
+
299
+ if (options.rust !== false) {
300
+ const rustOut = join(tmp, "rust");
301
+ mkdirSync(rustOut, { recursive: true });
302
+ run(flatcBin, ["--rust", "-o", rustOut, fbsPath], tmp, flatcBin);
303
+ const rs = readdirSync(rustOut).filter((f) => f.endsWith("_generated.rs"));
304
+ if (rs.length !== 1) {
305
+ throw new Error(
306
+ `generateBindings: expected exactly one .rs from flatc --rust, got: [${rs.join(", ")}]`,
307
+ );
308
+ }
309
+ const scaffold = rustScaffold();
310
+ files["rust/src/generated/backend.rs"] = readFileSync(join(rustOut, rs[0]!), "utf8");
311
+ files["rust/src/generated/mod.rs"] =
312
+ "// @generated by ignex-nova generate — DO NOT EDIT\npub mod backend;\n";
313
+ files["rust/src/transcode/generated.rs"] = emitRustGlue(model, fingerprint);
314
+ files["rust/src/transcode/mod.rs"] =
315
+ "// @generated by ignex-nova generate — DO NOT EDIT\npub mod generated;\n";
316
+ files["rust/src/lib.rs"] = scaffold.lib;
317
+ files["rust/src/ffi.rs"] = scaffold.ffi;
318
+ files["rust/Cargo.toml"] = scaffold.cargo;
319
+ }
320
+ } finally {
321
+ rmSync(tmp, { recursive: true, force: true });
322
+ }
323
+
324
+ // Generated TS stack (user mode: self-contained local types + library imports)
325
+ // registry + direct-ser need the runtime HELPERS (@ignex/nova/internal in the
326
+ // default case); index.ts needs assembleBindings (@ignex/nova/bindings).
327
+ files["registry.ts"] = emitRegistry(model, fingerprint, {
328
+ schemaImport: null,
329
+ libraryImport: helperImport,
330
+ });
331
+ files["direct-ser.ts"] = emitDirectSer(model, {
332
+ schemaImport: null,
333
+ libraryImport: helperImport,
334
+ });
335
+ files["ts-ser.ts"] = emitTsSer(model, { schemaImport: null });
336
+
337
+ const subjectPrefix = options.subjectPrefix ?? "ignex";
338
+ const ffiMode = options.ffiMode ?? "optional";
339
+ files["index.ts"] = emitIndex(
340
+ model,
341
+ libraryImport,
342
+ subjectPrefix,
343
+ ffiMode,
344
+ wireVersion,
345
+ fingerprint,
346
+ );
347
+
348
+ if (options.client) {
349
+ const clientFile = options.client.outFile ?? "client.gen.ts";
350
+ files[clientFile] = emitClientModel(
351
+ model,
352
+ options.client.wireImport ?? "./index",
353
+ options.client.schemaImport,
354
+ libraryImport,
355
+ );
356
+ }
357
+
358
+ files["README.md"] = emitReadme();
359
+
360
+ const write = (): string[] => {
361
+ const outDir = options.outDir ?? "./ignex/generated";
362
+ if (existsSync(outDir)) {
363
+ if (options.force === false)
364
+ throw new Error(
365
+ `generateBindings: "${outDir}" already exists (pass force: true to overwrite)`,
366
+ );
367
+ rmSync(outDir, { recursive: true, force: true });
368
+ }
369
+ mkdirSync(outDir, { recursive: true });
370
+ const written: string[] = [];
371
+ for (const [rel, content] of Object.entries(files)) {
372
+ const p = join(outDir, rel);
373
+ mkdirSync(join(p, ".."), { recursive: true });
374
+ writeFileSync(p, content);
375
+ written.push(rel);
376
+ }
377
+ return written;
378
+ };
379
+
380
+ return {
381
+ model,
382
+ fingerprint,
383
+ wireVersion,
384
+ wireRegistry: JSON.parse(files["wire-registry.json"]!) as {
385
+ version: number;
386
+ fingerprint: number;
387
+ events: Record<string, number>;
388
+ },
389
+ files,
390
+ write,
391
+ };
392
+ }
393
+
394
+ function emitIndex(
395
+ model: Model,
396
+ libraryImport: string,
397
+ subjectPrefix: string,
398
+ ffiMode: "optional" | "required",
399
+ wireVersion: number,
400
+ fingerprint: number,
401
+ ): string {
402
+ const appEvents = model.events
403
+ .filter((e) => !e.control)
404
+ .map((e) => `"${e.name}"`)
405
+ .join(" | ");
406
+ const ctlEvents = model.events
407
+ .filter((e) => e.control)
408
+ .map((e) => `"${e.name}"`)
409
+ .join(" | ");
410
+ const lines: string[] = [];
411
+ lines.push("// @generated by ignex-nova generate — DO NOT EDIT");
412
+ lines.push(`import { assembleBindings } from ${JSON.stringify(libraryImport)};`);
413
+ lines.push('import type { TSchema } from "@sinclair/typebox";');
414
+ lines.push('import * as reg from "./registry";');
415
+ lines.push('import { encodeEventFrame } from "./ts-ser";');
416
+ lines.push(
417
+ 'import { directSymbols, directSymbolNames, directEncoders, hasNulEncoders, directSelfTest } from "./direct-ser";',
418
+ );
419
+ lines.push("");
420
+ lines.push('export * from "./registry";');
421
+ lines.push("");
422
+ lines.push("/** machine-readable wire registry for NATS consumers / independent clients. */");
423
+ lines.push(
424
+ `export const wireRegistry = ${JSON.stringify(
425
+ {
426
+ version: wireVersion,
427
+ fingerprint,
428
+ events: Object.fromEntries(model.events.map((ev) => [ev.name, eventId(ev.name)])),
429
+ },
430
+ null,
431
+ 2,
432
+ )};`,
433
+ );
434
+ lines.push("");
435
+ lines.push("/**");
436
+ lines.push(" * Assemble a runtime `Bindings` from this generated stack.");
437
+ lines.push(" * `schema` is YOUR TypeBox registry: `{ events, controlEvents? }` —");
438
+ lines.push(" * the same objects you passed to `generateBindings`. The returned");
439
+ lines.push(" * bindings type derives from it, so `createServer` / `createClient`");
440
+ lines.push(" * are fully typed against your events.");
441
+ lines.push(" */");
442
+ lines.push("export function makeBindings<");
443
+ lines.push(" E extends Record<string, TSchema>,");
444
+ lines.push(" C extends Record<string, TSchema>,");
445
+ lines.push(">(schema: { events: E; controlEvents?: C }) {");
446
+ lines.push(" return assembleBindings(");
447
+ lines.push(" {");
448
+ lines.push(" wireVersion: reg.WIRE_VERSION,");
449
+ lines.push(" wireHeaderLen: reg.WIRE_HEADER_LEN,");
450
+ lines.push(" schemaFingerprint: reg.SCHEMA_FINGERPRINT,");
451
+ lines.push(" eventNameToId: reg.eventNameToId,");
452
+ lines.push(" idToEventName: reg.idToEventName,");
453
+ lines.push(" anyEventNameToId: reg.anyEventNameToId,");
454
+ lines.push(" idToAnyEventName: reg.idToAnyEventName,");
455
+ lines.push(" controlEventNameToId: reg.controlEventNameToId,");
456
+ lines.push(" readFrameHeader: reg.readFrameHeader,");
457
+ lines.push(" isControlId: reg.isControlId,");
458
+ lines.push(" decodePayload: reg.decodePayload,");
459
+ lines.push(" decodeFrame: reg.decodeFrame,");
460
+ lines.push(" encodeFrame: encodeEventFrame,");
461
+ lines.push(" direct: {");
462
+ lines.push(" symbols: directSymbols,");
463
+ lines.push(" symbolNames: directSymbolNames,");
464
+ lines.push(" encoders: directEncoders,");
465
+ lines.push(" hasNul: hasNulEncoders,");
466
+ lines.push(" selfTest: directSelfTest,");
467
+ lines.push(" },");
468
+ lines.push(" },");
469
+ lines.push(" schema,");
470
+ lines.push(
471
+ ` { ffiMode: ${JSON.stringify(ffiMode)}, subjectPrefix: ${JSON.stringify(subjectPrefix)} },`,
472
+ );
473
+ lines.push(" );");
474
+ lines.push("}");
475
+ lines.push("");
476
+ lines.push(`export type EventName = ${appEvents};`);
477
+ lines.push(`export type ControlEventName = ${ctlEvents};`);
478
+ lines.push("");
479
+ return lines.join("\n");
480
+ }
481
+
482
+ function emitReadme(): string {
483
+ return [
484
+ "# Generated bindings",
485
+ "",
486
+ "Generated by `ignex-nova generate` (public/generate.ts) — DO NOT EDIT by hand.",
487
+ "",
488
+ "## Use",
489
+ "",
490
+ "```ts",
491
+ "// bindings.ts",
492
+ 'import { makeBindings } from "./ignex/generated";',
493
+ 'import * as schema from "../src/schema"; // your TypeBox registry',
494
+ "export const bindings = makeBindings(schema);",
495
+ "```",
496
+ "",
497
+ "```ts",
498
+ 'import { createServer } from "@ignex/nova/server";',
499
+ 'import { createClient } from "@ignex/nova/client";',
500
+ 'import { createNatsBridge } from "@ignex/nova/nats";',
501
+ 'import { bindings } from "./bindings";',
502
+ "",
503
+ 'const server = createServer({ port: 3000, bindings, nats: { servers: ["nats://localhost:4222"], inbound: true } });',
504
+ 'const client = createClient("ws://localhost:3000/ws", { bindings });',
505
+ "",
506
+ "See docs/generic-bindings.md in the @ignex/nova package for the full guide",
507
+ "(including the Rust FFI fast path and NATS horizontal scaling).",
508
+ "",
509
+ ].join("\n");
510
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Public internal-helper entrypoint — `@ignex/nova/internal`.
3
+ *
4
+ * Generated code (from `@ignex/nova/generate`) imports these runtime helpers so
5
+ * user projects don't reach into the package's private modules:
6
+ *
7
+ * - `encodeUtf8Into` / `ensureCapacity` / `utf8Len` — zero-alloc UTF-8
8
+ * helpers used by the generated direct fast-path encoders.
9
+ * - `checkInt64` — lossless-int64 guard for plain `number` int64 fields.
10
+ * - `pooledByteBuffer` — pooled flatbuffers.ByteBuffer used by the generated
11
+ * registry's decoders.
12
+ */
13
+
14
+ export { checkInt64, setInt64GuardMode } from "../src/core/int64-guard";
15
+ export { encodeUtf8Into, ensureCapacity, utf8Len } from "../src/native/codec";
16
+ export { pooledByteBuffer } from "../src/transport/byte-buffer-pool";
package/public/nats.ts CHANGED
@@ -1,19 +1,23 @@
1
1
  /**
2
- * Public NATS bridge API — standalone entrypoint (`ignex-nova/nats`).
2
+ * Public NATS bridge API — standalone entrypoint (`@ignex/nova/nats`).
3
3
  *
4
- * import { createNatsBridge } from "ignex-nova/nats";
4
+ * import { createNatsBridge } from "@ignex/nova/nats";
5
5
  * const bridge = createNatsBridge({ servers: ["nats://localhost:4222"] });
6
6
  * bridge.publish("ignex.broadcast.quote", frame); // frame = wire bytes
7
7
  *
8
8
  * Most apps don't need this directly — pass `nats` to `createServer` and the
9
9
  * server bridges broadcast / topic / group publishes automatically.
10
+ *
11
+ * Generic: pass your own generated bindings so inbound frames decode YOUR
12
+ * events: `createNatsBridge({ servers, bindings })`.
10
13
  */
11
- export { createNatsBridge } from "../src/bridge/nats";
14
+
12
15
  export type {
13
- NatsBridgeOptions,
14
16
  NatsBridge,
17
+ NatsBridgeOptions,
15
18
  NatsBridgeStats,
16
19
  NatsBridgeStatus,
17
20
  NatsTransport,
18
21
  } from "../src/bridge/nats";
19
- export { createSubjectBuilder, type SubjectBuilder } from "../src/bridge/subjects";
22
+ export { createNatsBridge } from "../src/bridge/nats";
23
+ export { createSubjectBuilder, type SubjectBuilder } from "../src/bridge/subjects";
package/public/server.ts CHANGED
@@ -3,33 +3,69 @@
3
3
  * build stable). The implementation lives in the functional modules under
4
4
  * `src/core/`; this file just exposes the public surface.
5
5
  *
6
- * import { createServer } from "ignex-nova/server";
6
+ * import { createServer } from "@ignex/nova/server";
7
7
  *
8
8
  * const server = createServer({ port: 3000 });
9
9
  * server.publish("quote", { symbol: "AAPL", bid: 180.1, ask: 180.2, ... });
10
10
  * server.publishTo(ws, "trade", { ... });
11
11
  * server.join("equities", ws); server.publishToTopic("equities", "quote", {...});
12
12
  *
13
+ * // your own schema (see @ignex/nova/generate):
14
+ * const server = createServer({ port: 3000, bindings });
15
+ * server.publish("yourEvent", {...}); // typed against YOUR Events
16
+ *
13
17
  * Bun-only (bun:ffi + Bun.serve).
14
18
  */
15
- export { createServer, type IgnServer, type ClientInfo } from "../src/core/server";
19
+
16
20
  export type {
17
- IgnServerOptions,
18
- IgnBackpressureOptions,
19
- BackpressurePolicy,
20
- WsData,
21
- ClientMeta,
22
- AuthResult,
23
- } from "../src/core/state";
24
- export type { MetricsSnapshot } from "../src/core/metrics";
25
- export type { Int64GuardMode } from "../src/core/int64-guard";
26
- // NATS bridge — re-exported so `nats` options on `createServer` are typed
27
- // without a separate import (a standalone entrypoint is `ignex-nova/nats`).
28
- export { createNatsBridge } from "../src/bridge/nats";
29
- export type {
30
- NatsBridgeOptions,
31
21
  NatsBridge,
22
+ NatsBridgeOptions,
32
23
  NatsBridgeStats,
33
24
  NatsBridgeStatus,
34
25
  NatsTransport,
35
26
  } from "../src/bridge/nats";
27
+ // NATS bridge — re-exported so `nats` options on `createServer` are typed
28
+ // without a separate import (a standalone entrypoint is `@ignex/nova/nats`).
29
+ export { createNatsBridge } from "../src/bridge/nats";
30
+ export type { Int64GuardMode } from "../src/core/int64-guard";
31
+ export type { MetricsSnapshot } from "../src/core/metrics";
32
+ export { type ClientInfo, createServer, type IgnServer } from "../src/core/server";
33
+ // Durable topic log (replay seam) — `createServer({ topicLog })` + adapters.
34
+ export { createMemoryTopicLog, type LoggedFrame, type TopicLog } from "../src/core/topic-log";
35
+ export type {
36
+ AuthResult,
37
+ BackpressurePolicy,
38
+ ClientMeta,
39
+ IgnBackpressureOptions,
40
+ IgnServerOptions,
41
+ WsData,
42
+ } from "../src/core/state";
43
+ // Events layer — re-exported so `createServer({ events })` is fully typed
44
+ // without a separate import (the runtime API is `@ignex/nova/events`).
45
+ export type { DeliveryPolicy } from "../src/events/delivery";
46
+ export type {
47
+ ClientData,
48
+ ClientGroup,
49
+ ClusterStateStore,
50
+ ClusterTransport,
51
+ EmitTarget,
52
+ EmitTargetKind,
53
+ EventClient,
54
+ EventContext,
55
+ EventHandler,
56
+ EventsClusterOptions,
57
+ EventsHub,
58
+ EventsMetricsSnapshot,
59
+ EventsOptions,
60
+ RedisConnectionOptions,
61
+ RemoteClient,
62
+ ServerEventHandler,
63
+ UserGroup,
64
+ } from "../src/events/types";
65
+ // Event trace (debugger visibility) — `server.getEventTrace()` surface types.
66
+ export type {
67
+ EventTraceOptions,
68
+ EventTraceRow,
69
+ EventTraceStats,
70
+ TraceDirection,
71
+ } from "../src/events/trace";
package/rust/src/ffi.rs CHANGED
@@ -36,6 +36,16 @@ pub extern "C" fn fb_wire_version() -> u32 {
36
36
  WIRE_VERSION as u32
37
37
  }
38
38
 
39
+ /// Schema fingerprint (FNV-1a 32 over the canonical model). The Bun side
40
+ /// checks it against the generated `SCHEMA_FINGERPRINT` at bind time, so a
41
+ /// cdylib built from a DIFFERENT schema (e.g. pointing `IGNEX_FFI_PATH` at the
42
+ /// built-in addon from a project with its own generated bindings) fails loudly
43
+ /// instead of producing frames the client can't decode.
44
+ #[no_mangle]
45
+ pub extern "C" fn fb_schema_fingerprint() -> u64 {
46
+ generated::SCHEMA_FINGERPRINT
47
+ }
48
+
39
49
  // ── Diagnostic C-ABI probes (bench-only, NOT in the ffi.ts dlopen map) ─────
40
50
  //
41
51
  // Used ONLY by bench/ffi-margin.ts to isolate the fixed per-call FFI cost into