@ignex/nova 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +132 -32
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +232 -0
  4. package/docs/architecture.md +35 -10
  5. package/docs/events.md +170 -0
  6. package/docs/generic-bindings.md +197 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +9 -2
  9. package/index.ts +75 -27
  10. package/package.json +12 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +416 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +42 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/transcode/generated.rs +2 -1
  21. package/src/bindings/assemble.ts +73 -0
  22. package/src/bindings/default.ts +65 -0
  23. package/src/bindings/types.ts +113 -0
  24. package/src/bridge/nats.ts +53 -13
  25. package/src/bridge/subjects.ts +3 -0
  26. package/src/codegen/constants.ts +18 -0
  27. package/src/codegen/direct-gen.ts +550 -0
  28. package/src/codegen/fingerprint.ts +44 -0
  29. package/src/codegen/hash.ts +25 -0
  30. package/src/codegen/registry-gen.ts +242 -0
  31. package/src/codegen/rust-glue-gen.ts +545 -0
  32. package/src/codegen/schema-model.ts +338 -0
  33. package/src/codegen/ts-ser-gen.ts +221 -0
  34. package/src/codegen/typebox-to-fbs.ts +60 -0
  35. package/src/core/auth.ts +2 -1
  36. package/src/core/client-heartbeat.ts +2 -1
  37. package/src/core/client-reconnect.ts +9 -2
  38. package/src/core/client-state.ts +21 -8
  39. package/src/core/client-wire.ts +10 -11
  40. package/src/core/client.ts +34 -29
  41. package/src/core/groups.ts +3 -0
  42. package/src/core/metrics.ts +7 -3
  43. package/src/core/outbound.ts +12 -5
  44. package/src/core/routing.ts +17 -8
  45. package/src/core/server.ts +108 -34
  46. package/src/core/state.ts +51 -13
  47. package/src/events/clients.ts +156 -0
  48. package/src/events/cluster.ts +732 -0
  49. package/src/events/data.ts +38 -0
  50. package/src/events/emit.ts +127 -0
  51. package/src/events/global.ts +117 -0
  52. package/src/events/groups.ts +118 -0
  53. package/src/events/hub.ts +481 -0
  54. package/src/events/index.ts +61 -0
  55. package/src/events/queue.ts +96 -0
  56. package/src/events/registry.ts +178 -0
  57. package/src/events/types.ts +378 -0
  58. package/src/generated/direct-ser.ts +2 -1
  59. package/src/generated/fbs/backend.fbs +1 -1
  60. package/src/generated/registry.ts +3 -1
  61. package/src/generated/ts-ser.ts +1 -1
  62. package/src/generated/wire-registry.json +1 -0
  63. package/src/native/ffi.ts +85 -28
  64. package/src/schema/index.ts +5 -2
  65. package/src/server.ts +7 -3
  66. package/src/transport/stats.ts +8 -4
  67. package/src/transport/transport.ts +149 -68
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Normalizes TypeBox schemas into a simple wire model shared by every emitter
3
+ * (.fbs, Rust glue, TS registry). All emitters derive field order / naming from
4
+ * this single model, so the Rust builder and the flatc-generated TS decoders
5
+ * are wire-compatible by construction.
6
+ */
7
+ import type { TSchema } from "@sinclair/typebox";
8
+
9
+ export type FieldKind =
10
+ | "string"
11
+ | "double"
12
+ | "int64"
13
+ | "bool"
14
+ | "enum"
15
+ | "table"
16
+ | "vector-string"
17
+ | "vector-double"
18
+ | "vector-int64"
19
+ | "vector-bool"
20
+ | "vector-enum"
21
+ | "vector-table";
22
+
23
+ export interface FieldDef {
24
+ /** camelCase, matches the TypeBox/Events key */
25
+ jsonName: string;
26
+ /** snake_case, the wire (.fbs / flatc) field name */
27
+ fbName: string;
28
+ /** whether the field is required in the TypeBox schema */
29
+ required: boolean;
30
+ kind: FieldKind;
31
+ enumName?: string;
32
+ tableName?: string;
33
+ /** int64 field decoded/encoded as `bigint` (Type.Integer({ bigint: true })) — exact beyond 2^53 */
34
+ bigint?: boolean;
35
+ }
36
+
37
+ export interface TableDef {
38
+ name: string;
39
+ fields: FieldDef[];
40
+ }
41
+
42
+ export interface EnumDef {
43
+ name: string;
44
+ values: string[];
45
+ }
46
+
47
+ export interface EventDef {
48
+ name: string;
49
+ tableName: string;
50
+ /** transport-internal event (hello/subscribe/ping/...); hidden from the public Events surface */
51
+ control?: boolean;
52
+ }
53
+
54
+ export interface Model {
55
+ enums: EnumDef[];
56
+ tables: TableDef[];
57
+ events: EventDef[];
58
+ }
59
+
60
+ /**
61
+ * Codegen context shared by every emitter. In-repo generation (scripts/
62
+ * generate.ts) leaves it empty and the generated files import the repo's own
63
+ * modules with relative paths. User-mode generation (public/generate.ts) sets
64
+ * `schemaImport: null` (the generated code emits self-contained local payload
65
+ * types instead of importing the user's schema module) and `libraryImport` to
66
+ * the package the internal helpers should come from.
67
+ */
68
+ export interface EmitContext {
69
+ /** import specifier for schema TYPE imports ("../schema" in-repo). null → local types. */
70
+ schemaImport?: string | null;
71
+ /** user mode: where internal runtime helpers (codec / int64-guard / pooledByteBuffer / assembleBindings) come from. */
72
+ libraryImport?: string;
73
+ }
74
+
75
+ /** A table is "flat" when every field maps to a direct FFI arg (no tables/vectors). */
76
+ export function isFlatTable(t: TableDef): boolean {
77
+ return t.fields.every(
78
+ (f) =>
79
+ f.kind === "string" ||
80
+ f.kind === "double" ||
81
+ f.kind === "int64" ||
82
+ f.kind === "bool" ||
83
+ f.kind === "enum",
84
+ );
85
+ }
86
+
87
+ /**
88
+ * A table is "directable" when every field is either a flat scalar/string/enum
89
+ * OR a packed vector (scalars/strings/enums, or tables whose elements are flat).
90
+ * Directable events serialize with ZERO allocations and NO JSON — vectors travel
91
+ * as a packed binary blob (`read_packed_*` on the Rust side).
92
+ */
93
+ export function isDirectableTable(m: Model, t: TableDef): boolean {
94
+ return t.fields.every((f) => {
95
+ switch (f.kind) {
96
+ case "string":
97
+ case "double":
98
+ case "int64":
99
+ case "bool":
100
+ case "enum":
101
+ return true;
102
+ case "vector-table": {
103
+ const elem = m.tables.find((e) => e.name === f.tableName);
104
+ return elem ? isFlatTable(elem) : false;
105
+ }
106
+ case "vector-string":
107
+ case "vector-double":
108
+ case "vector-int64":
109
+ case "vector-bool":
110
+ case "vector-enum":
111
+ return true;
112
+ default:
113
+ return false;
114
+ }
115
+ });
116
+ }
117
+
118
+ export function isDirectableEvent(m: Model, ev: EventDef): boolean {
119
+ const t = m.tables.find((t) => t.name === ev.tableName);
120
+ return !!t && t.fields.length > 0 && isDirectableTable(m, t);
121
+ }
122
+
123
+ export function toSnake(s: string): string {
124
+ return s
125
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
126
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
127
+ .toLowerCase();
128
+ }
129
+
130
+ export function toPascal(s: string): string {
131
+ const cleaned = s.replace(/[^a-zA-Z0-9]+(.)?/g, (_m, c: string) => (c ? c.toUpperCase() : ""));
132
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
133
+ }
134
+
135
+ type AnySchema = Record<string, any>;
136
+
137
+ interface Ctx {
138
+ named: Map<object, string>;
139
+ used: Set<string>;
140
+ enums: EnumDef[];
141
+ tables: TableDef[];
142
+ events: EventDef[];
143
+ enumById: Map<object, string>;
144
+ tableById: Map<object, string>;
145
+ }
146
+
147
+ function uniqueName(ctx: Ctx, base: string): string {
148
+ let name = base;
149
+ let i = 2;
150
+ while (ctx.used.has(name)) {
151
+ name = `${base}${i}`;
152
+ i++;
153
+ }
154
+ ctx.used.add(name);
155
+ return name;
156
+ }
157
+
158
+ function unionMembers(s: AnySchema): AnySchema[] {
159
+ return (s.anyOf ?? s.oneOf ?? []) as AnySchema[];
160
+ }
161
+
162
+ const isStringLiteral = (m: AnySchema): boolean => typeof m?.const === "string";
163
+ const isNullMember = (m: AnySchema): boolean => m?.type === "null";
164
+ const isObjectLike = (m: AnySchema): boolean => m?.type === "object" && typeof m?.properties === "object";
165
+
166
+ function registerEnum(ctx: Ctx, schema: AnySchema, values: string[], jsonName: string): string {
167
+ const existing = ctx.enumById.get(schema);
168
+ if (existing) return existing;
169
+ const name = uniqueName(ctx, toPascal(jsonName));
170
+ ctx.enums.push({ name, values });
171
+ ctx.enumById.set(schema, name);
172
+ return name;
173
+ }
174
+
175
+ function ensureTable(ctx: Ctx, schema: AnySchema, fallbackName: string): string {
176
+ const existing = ctx.tableById.get(schema);
177
+ if (existing) return existing;
178
+
179
+ const name = ctx.named.get(schema) ?? uniqueName(ctx, fallbackName);
180
+ if (!ctx.used.has(name)) ctx.used.add(name);
181
+ ctx.tableById.set(schema, name);
182
+ // placeholder first so recursive/self references terminate
183
+ ctx.tables.push({ name, fields: [] });
184
+
185
+ const props = (schema.properties ?? {}) as Record<string, AnySchema>;
186
+ const required: string[] = schema.required ?? [];
187
+ const fields: FieldDef[] = [];
188
+ for (const [jsonName, propSchema] of Object.entries(props)) {
189
+ const resolved = resolveFieldType(ctx, propSchema, jsonName, name);
190
+ fields.push({
191
+ jsonName,
192
+ fbName: toSnake(jsonName),
193
+ required: required.includes(jsonName),
194
+ ...resolved,
195
+ });
196
+ }
197
+ const table = ctx.tables.find((t) => t.name === name)!;
198
+ table.fields = fields;
199
+ return name;
200
+ }
201
+
202
+ function resolveFieldType(
203
+ ctx: Ctx,
204
+ schema: AnySchema,
205
+ jsonName: string,
206
+ parentName: string,
207
+ ): { kind: FieldKind; enumName?: string; tableName?: string; bigint?: boolean } {
208
+ const members = unionMembers(schema);
209
+ if (members.length > 0) {
210
+ if (members.every(isStringLiteral)) {
211
+ const enumName = registerEnum(ctx, schema, members.map((m) => m.const as string), jsonName);
212
+ return { kind: "enum", enumName };
213
+ }
214
+ const tableMember = members.find(isObjectLike);
215
+ if (tableMember && members.filter((m) => !isNullMember(m)).length === 1) {
216
+ const tableName = ensureTable(ctx, tableMember, `${parentName}${toPascal(jsonName)}`);
217
+ return { kind: "table", tableName };
218
+ }
219
+ return { kind: "string" };
220
+ }
221
+
222
+ switch (schema.type) {
223
+ case "string": {
224
+ if (typeof schema.const === "string") {
225
+ const enumName = registerEnum(ctx, schema, [schema.const], jsonName);
226
+ return { kind: "enum", enumName };
227
+ }
228
+ return { kind: "string" };
229
+ }
230
+ case "number":
231
+ return { kind: "double" };
232
+ case "integer":
233
+ return { kind: "int64" };
234
+ case "bigint":
235
+ // Type.BigInt() → EXACT int64 (decoded/encoded as bigint, not number)
236
+ return { kind: "int64", bigint: true };
237
+ case "boolean":
238
+ return { kind: "bool" };
239
+ case "array": {
240
+ const items = schema.items as AnySchema | undefined;
241
+ if (!items) return { kind: "vector-double" };
242
+ if (isObjectLike(items)) {
243
+ const tableName = ensureTable(ctx, items, `${parentName}${toPascal(jsonName)}Item`);
244
+ return { kind: "vector-table", tableName };
245
+ }
246
+ const itemMembers = unionMembers(items);
247
+ if (itemMembers.length > 0 && itemMembers.every(isStringLiteral)) {
248
+ const enumName = registerEnum(ctx, items, itemMembers.map((m) => m.const as string), jsonName);
249
+ return { kind: "vector-enum", enumName };
250
+ }
251
+ switch (items.type) {
252
+ case "string":
253
+ return { kind: "vector-string" };
254
+ case "number":
255
+ return { kind: "vector-double" };
256
+ case "integer":
257
+ return { kind: "vector-int64" };
258
+ case "boolean":
259
+ return { kind: "vector-bool" };
260
+ default:
261
+ return { kind: "vector-double" };
262
+ }
263
+ }
264
+ case "object": {
265
+ if (!isObjectLike(schema)) return { kind: "string" };
266
+ const tableName = ensureTable(ctx, schema, `${parentName}${toPascal(jsonName)}`);
267
+ return { kind: "table", tableName };
268
+ }
269
+ default:
270
+ return { kind: "string" };
271
+ }
272
+ }
273
+
274
+ /** TS type expression for a plain-object field (used by user-mode codegen). */
275
+ export function plainTsType(m: Model, f: FieldDef, suffix = "Payload"): string {
276
+ switch (f.kind) {
277
+ case "string":
278
+ return f.required ? "string" : "string | undefined";
279
+ case "double":
280
+ return "number";
281
+ case "int64":
282
+ return f.bigint ? "bigint" : "number";
283
+ case "bool":
284
+ return "boolean";
285
+ case "enum":
286
+ return enumUnion(m, f);
287
+ case "table":
288
+ return f.required ? `${f.tableName}${suffix}` : `${f.tableName}${suffix} | undefined`;
289
+ case "vector-string":
290
+ return `string[]${f.required ? "" : " | undefined"}`;
291
+ case "vector-double":
292
+ return `number[]${f.required ? "" : " | undefined"}`;
293
+ case "vector-int64":
294
+ return `number[]${f.required ? "" : " | undefined"}`;
295
+ case "vector-bool":
296
+ return `boolean[]${f.required ? "" : " | undefined"}`;
297
+ case "vector-enum":
298
+ return `(${enumUnion(m, f)})[]${f.required ? "" : " | undefined"}`;
299
+ case "vector-table":
300
+ return `${f.tableName}${suffix}[]${f.required ? "" : " | undefined"}`;
301
+ default:
302
+ return "unknown";
303
+ }
304
+ }
305
+
306
+ /** string literal union of the enum values, e.g. `"buy" | "sell"`. */
307
+ export function enumUnion(m: Model, f: FieldDef): string {
308
+ const values = m.enums.find((e) => e.name === f.enumName)?.values ?? [];
309
+ return values.map((v) => JSON.stringify(v)).join(" | ");
310
+ }
311
+
312
+ export function buildModel(
313
+ namedSchemas: Record<string, TSchema>,
314
+ eventSchemas: Record<string, TSchema>,
315
+ controlEventSchemas: Record<string, TSchema> = {},
316
+ ): Model {
317
+ const ctx: Ctx = {
318
+ named: new Map(),
319
+ used: new Set(),
320
+ enums: [],
321
+ tables: [],
322
+ events: [],
323
+ enumById: new Map(),
324
+ tableById: new Map(),
325
+ };
326
+ for (const [name, schema] of Object.entries(namedSchemas)) {
327
+ ctx.named.set(schema as object, name);
328
+ }
329
+ for (const [name, schema] of Object.entries(eventSchemas)) {
330
+ const tableName = ensureTable(ctx, schema as AnySchema, toPascal(name));
331
+ ctx.events.push({ name, tableName });
332
+ }
333
+ for (const [name, schema] of Object.entries(controlEventSchemas)) {
334
+ const tableName = ensureTable(ctx, schema as AnySchema, toPascal(name));
335
+ ctx.events.push({ name, tableName, control: true });
336
+ }
337
+ return { enums: ctx.enums, tables: ctx.tables, events: ctx.events };
338
+ }
@@ -0,0 +1,221 @@
1
+ import type { EmitContext, FieldDef, Model } from "./schema-model";
2
+ import { plainTsType, toSnake } from "./schema-model";
3
+
4
+ /**
5
+ * Emits `src/generated/ts-ser.ts` — the PURE-JS (browser-safe) object → FlatBuffer
6
+ * encoder, built on flatc's generated object API (`.T` classes + `pack()`).
7
+ *
8
+ * This is the OTHER half of bidirectionality: the Rust FFI serializer only
9
+ * exists on the Bun server, so browser clients cannot use it. `ts-ser` gives
10
+ * any JS runtime (browser or Bun) a way to encode EVERY event — including the
11
+ * transport control frames (hello/subscribe/ping/...) — into the same wire
12
+ * format `[WIRE_VERSION][event_id:u32][size-prefixed FlatBuffer]`.
13
+ *
14
+ * Key points:
15
+ * - Reuses a single pooled `flatbuffers.Builder` (`getBuilder()`); each
16
+ * encode clears it, so `encodeEventFrame` (which copies into a fresh
17
+ * frame) is the safe public API.
18
+ * - int64 fields convert `number → BigInt` (the flatc object API uses bigint);
19
+ * - string enums map to flatc numeric enum values via generated lookups.
20
+ * - Emits `build<Table>T` helpers for nested tables (table / vector-table).
21
+ *
22
+ * `ctx.schemaImport === null` (user-mode codegen) emits self-contained local
23
+ * `Events` / `ControlEvents` payload maps (over the already-local `XxxPlain`
24
+ * types) instead of importing the app schema.
25
+ */
26
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: string-emitting codegen is inherently branchy
27
+ export function emitTsSer(m: Model, ctx: EmitContext = {}): string {
28
+ const needed = new Set<string>();
29
+ const visit = (name: string) => {
30
+ if (needed.has(name)) return;
31
+ needed.add(name);
32
+ const t = m.tables.find((t) => t.name === name)!;
33
+ for (const f of t.fields) {
34
+ if (f.kind === "table" || f.kind === "vector-table") visit(f.tableName!);
35
+ }
36
+ };
37
+ for (const ev of m.events) visit(ev.tableName);
38
+ const neededTables = m.tables.filter((t) => needed.has(t.name));
39
+
40
+ // enums actually used by a needed table
41
+ const usedEnums = new Map<string, string[]>();
42
+ for (const t of neededTables) {
43
+ for (const f of t.fields) {
44
+ if (f.kind === "enum" || f.kind === "vector-enum") {
45
+ const e = m.enums.find((e) => e.name === f.enumName!);
46
+ if (e) usedEnums.set(e.name, e.values);
47
+ }
48
+ }
49
+ }
50
+
51
+ const userMode = ctx.schemaImport === null;
52
+ const schemaImport = userMode ? "" : (ctx.schemaImport ?? "../schema");
53
+
54
+ const lines: string[] = [];
55
+ lines.push("// @generated by src/codegen/ts-ser-gen.ts — DO NOT EDIT");
56
+ lines.push('import * as flatbuffers from "flatbuffers";');
57
+ lines.push("import {");
58
+ for (const t of neededTables) lines.push(` ${t.name}T,`);
59
+ lines.push('} from "./ts/backend";');
60
+ lines.push('import { anyEventNameToId, WIRE_HEADER_LEN, WIRE_VERSION } from "./registry";');
61
+ if (!userMode) {
62
+ lines.push(
63
+ `import type { AnyEventName, ControlEvents, Events } from ${JSON.stringify(schemaImport)};`,
64
+ );
65
+ }
66
+ lines.push("");
67
+ if (userMode) lines.push(...emitLocalTypeMap(m, appEventsOf(m), controlEventsOf(m)));
68
+ lines.push("/** string → flatc numeric enum value lookups */");
69
+ for (const [name, values] of usedEnums) {
70
+ const entries = values.map((v) => `${v}: ${values.indexOf(v)}`).join(", ");
71
+ lines.push(`const ${toSnake(name).toUpperCase()} = { ${entries} } as const;`);
72
+ }
73
+ if (usedEnums.size > 0) lines.push("");
74
+ lines.push(
75
+ "/** pooled builder — each encode clears it (safe because encodeEventFrame copies). */",
76
+ );
77
+ lines.push("let shared: flatbuffers.Builder | null = null;");
78
+ lines.push("function getBuilder(): flatbuffers.Builder {");
79
+ lines.push(" if (!shared) shared = new flatbuffers.Builder(1024);");
80
+ lines.push(" return shared;");
81
+ lines.push("}");
82
+ lines.push("");
83
+
84
+ // per-table plain input types
85
+ for (const t of neededTables) {
86
+ lines.push(`type ${t.name}Plain = {`);
87
+ for (const f of t.fields)
88
+ lines.push(` ${f.jsonName}${f.required ? "" : "?"}: ${plainTsType(m, f, "Plain")};`);
89
+ lines.push("};");
90
+ lines.push("");
91
+ }
92
+
93
+ // per-table builders (plain object → flatc .T)
94
+ for (const t of neededTables) {
95
+ lines.push(`function build${t.name}T(o: ${t.name}Plain): ${t.name}T {`);
96
+ lines.push(" return new " + t.name + "T(");
97
+ for (const f of t.fields) lines.push(` ${ctorArg(f, "o")},`);
98
+ lines.push(" );");
99
+ lines.push("}");
100
+ lines.push("");
101
+ }
102
+
103
+ // per-event payload encoders (size-prefixed flatbuffer only)
104
+ for (const ev of m.events) {
105
+ const annot = ev.control
106
+ ? `ControlEvents[${JSON.stringify(ev.name)}]`
107
+ : `Events[${JSON.stringify(ev.name)}]`;
108
+ lines.push(
109
+ `/** ${ev.control ? "control " : ""}payload encoder: plain object → size-prefixed FlatBuffer. */`,
110
+ );
111
+ lines.push(
112
+ `export function encode${pascal(ev.name)}Payload(o: ${annot}, b: flatbuffers.Builder = getBuilder()): Uint8Array {`,
113
+ );
114
+ lines.push(" b.clear();");
115
+ lines.push(` b.finishSizePrefixed(build${ev.tableName}T(o).pack(b));`);
116
+ lines.push(" return b.asUint8Array();");
117
+ lines.push("}");
118
+ lines.push("");
119
+ }
120
+
121
+ lines.push("export type JsEncoder = (o: unknown, b?: flatbuffers.Builder) => Uint8Array;");
122
+ lines.push("");
123
+ lines.push("export const jsEncoders: Record<string, JsEncoder> = {");
124
+ for (const ev of m.events)
125
+ lines.push(` ${ev.name}: encode${pascal(ev.name)}Payload as JsEncoder,`);
126
+ lines.push("};");
127
+ lines.push("");
128
+ lines.push("/** Encode a payload (app or control event) → size-prefixed FlatBuffer. */");
129
+ lines.push(
130
+ "export function encodeJsPayload(name: AnyEventName, o: unknown, b?: flatbuffers.Builder): Uint8Array {",
131
+ );
132
+ lines.push(" const enc = jsEncoders[name];");
133
+ lines.push(' if (!enc) throw new Error(`ignex: no JS encoder for "${name}" — regenerate`);');
134
+ lines.push(" return enc(o, b);");
135
+ lines.push("}");
136
+ lines.push("");
137
+ lines.push(
138
+ "/** Encode a payload into a full wire frame `[version][event_id:u32][size-prefixed FB]`. */",
139
+ );
140
+ lines.push(
141
+ "export function encodeEventFrame(name: AnyEventName, o: unknown, b?: flatbuffers.Builder): Uint8Array {",
142
+ );
143
+ lines.push(" const payload = encodeJsPayload(name, o, b);");
144
+ lines.push(" const id = anyEventNameToId[name]!;");
145
+ lines.push(" const frame = new Uint8Array(WIRE_HEADER_LEN + payload.byteLength);");
146
+ lines.push(" frame[0] = WIRE_VERSION;");
147
+ lines.push(" new DataView(frame.buffer).setUint32(1, id, true);");
148
+ lines.push(" frame.set(payload, WIRE_HEADER_LEN);");
149
+ lines.push(" return frame;");
150
+ lines.push("}");
151
+ lines.push("");
152
+
153
+ return lines.join("\n");
154
+ }
155
+
156
+ function appEventsOf(m: Model) {
157
+ return m.events.filter((e) => !e.control);
158
+ }
159
+ function controlEventsOf(m: Model) {
160
+ return m.events.filter((e) => e.control);
161
+ }
162
+
163
+ /** Local `EventName` / `Events` / `ControlEvents` type map over the XxxPlain types. */
164
+ function emitLocalTypeMap(
165
+ _m: Model,
166
+ appEvents: Model["events"],
167
+ controlEvents: Model["events"],
168
+ ): string[] {
169
+ const lines: string[] = [];
170
+ lines.push(`export type EventName = ${appEvents.map((e) => `"${e.name}"`).join(" | ")};`);
171
+ lines.push(
172
+ `export type ControlEventName = ${controlEvents.map((e) => `"${e.name}"`).join(" | ")};`,
173
+ );
174
+ lines.push("export type AnyEventName = EventName | ControlEventName;");
175
+ lines.push("");
176
+ lines.push("export type Events = {");
177
+ for (const ev of appEvents) lines.push(` ${JSON.stringify(ev.name)}: ${ev.tableName}Plain;`);
178
+ lines.push("};");
179
+ lines.push("export type ControlEvents = {");
180
+ for (const ev of controlEvents) lines.push(` ${JSON.stringify(ev.name)}: ${ev.tableName}Plain;`);
181
+ lines.push("};");
182
+ lines.push("");
183
+ return lines;
184
+ }
185
+
186
+ function ctorArg(f: FieldDef, o: string): string {
187
+ const j = `${o}.${f.jsonName}`;
188
+ switch (f.kind) {
189
+ case "string":
190
+ // flatc .T strings are nullable; absent optional → null
191
+ return f.required ? j : `${j} ?? null`;
192
+ case "double":
193
+ return j;
194
+ case "int64":
195
+ // bigint-annotated fields are already bigint (pass through); plain int64
196
+ // are `number` in the Events type → BigInt for the flatc object API
197
+ return f.bigint ? j : `${j} === undefined ? 0n : BigInt(${j})`;
198
+ case "bool":
199
+ return j;
200
+ case "enum":
201
+ return `${toSnake(f.enumName!).toUpperCase()}[${j}]`;
202
+ case "table":
203
+ return f.required ? `build${f.tableName}T(${j})` : `${j} ? build${f.tableName}T(${j}) : null`;
204
+ case "vector-string":
205
+ case "vector-double":
206
+ case "vector-bool":
207
+ return `(${j} ?? [])`;
208
+ case "vector-int64":
209
+ return `(${j} ?? []).map((v) => BigInt(v))`;
210
+ case "vector-enum":
211
+ return `(${j} ?? []).map((v) => ${toSnake(f.enumName!).toUpperCase()}[v])`;
212
+ case "vector-table":
213
+ return `(${j} ?? []).map((v) => build${f.tableName}T(v))`;
214
+ default:
215
+ return j;
216
+ }
217
+ }
218
+
219
+ function pascal(s: string): string {
220
+ return s.charAt(0).toUpperCase() + s.slice(1);
221
+ }
@@ -0,0 +1,60 @@
1
+ import type { FieldDef, Model } from "./schema-model";
2
+
3
+ function fbTypeOf(f: FieldDef): string {
4
+ switch (f.kind) {
5
+ case "string":
6
+ return "string";
7
+ case "double":
8
+ return "double";
9
+ case "int64":
10
+ return "int64";
11
+ case "bool":
12
+ return "bool";
13
+ case "enum":
14
+ return f.enumName!;
15
+ case "table":
16
+ return f.tableName!;
17
+ case "vector-string":
18
+ return "[string]";
19
+ case "vector-double":
20
+ return "[double]";
21
+ case "vector-int64":
22
+ return "[int64]";
23
+ case "vector-bool":
24
+ return "[bool]";
25
+ case "vector-enum":
26
+ return `[${f.enumName}]`;
27
+ case "vector-table":
28
+ return `[${f.tableName}]`;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * TypeBox model → FlatBuffers schema (.fbs). No namespace is used so both
34
+ * `flatc --ts` and `flatc --rust` emit flat, predictable modules. Each event
35
+ * table is its own root (finished size-prefixed on the Rust side, read with
36
+ * `getSizePrefixedRootAsXxx` on the TS side).
37
+ */
38
+ export function emitFbs(m: Model): string {
39
+ const lines: string[] = [];
40
+ lines.push("// @generated by src/codegen/typebox-to-fbs.ts — DO NOT EDIT");
41
+ lines.push("// FlatBuffers schema derived from the TypeBox events registry (schema/index.ts).");
42
+ lines.push("");
43
+ lines.push('file_identifier "IGNX";');
44
+ lines.push("");
45
+ for (const e of m.enums) {
46
+ lines.push(`enum ${e.name} : int32 {`);
47
+ e.values.forEach((v, i) => lines.push(` ${v.toUpperCase()} = ${i},`));
48
+ lines.push("}");
49
+ lines.push("");
50
+ }
51
+ for (const t of m.tables) {
52
+ lines.push(`table ${t.name} {`);
53
+ for (const f of t.fields) {
54
+ lines.push(` ${f.fbName}:${fbTypeOf(f)};`);
55
+ }
56
+ lines.push("}");
57
+ lines.push("");
58
+ }
59
+ return lines.join("\n");
60
+ }
package/src/core/auth.ts CHANGED
@@ -47,7 +47,8 @@ export async function checkUpgrade(
47
47
  topics: new Set(),
48
48
  groups: new Set(authMeta?.groups ?? []),
49
49
  id,
50
- meta: authMeta?.meta,
50
+ ...(authMeta?.userId !== undefined ? { userId: authMeta.userId } : {}),
51
+ ...(authMeta?.meta !== undefined ? { meta: authMeta.meta } : {}),
51
52
  connectedAt: Date.now(),
52
53
  };
53
54
  // bun-types requires the WebSocketData options arg when Data != undefined
@@ -2,8 +2,9 @@
2
2
  * Client app-level Ping/Pong heartbeat — detects half-open connections and
3
3
  * forces a close so the reconnect path re-establishes the socket.
4
4
  */
5
- import { sendControl } from "./client-wire";
5
+
6
6
  import type { ClientState } from "./client-state";
7
+ import { sendControl } from "./client-wire";
7
8
 
8
9
  export function startHeartbeat(state: ClientState): void {
9
10
  const ms = state.opts.heartbeatMs ?? 15000;
@@ -3,10 +3,17 @@
3
3
  * drives the state machine. `connect` is passed in by the composition root so
4
4
  * the timer can re-establish the socket.
5
5
  */
6
- import { setStatus, type ClientState, type IgnClientOptions, type IgnReconnectOptions } from "./client-state";
6
+
7
+ import type { Bindings } from "../bindings/types";
8
+ import {
9
+ type ClientState,
10
+ type IgnClientOptions,
11
+ type IgnReconnectOptions,
12
+ setStatus,
13
+ } from "./client-state";
7
14
 
8
15
  /** Resolve the effective reconnect options (defaults applied). */
9
- export function reconnectOpts(opts: IgnClientOptions): IgnReconnectOptions | null {
16
+ export function reconnectOpts(opts: IgnClientOptions<Bindings>): IgnReconnectOptions | null {
10
17
  const rc = opts.reconnect;
11
18
  if (rc === undefined || rc === false) return null;
12
19
  if (rc === true) return { initialDelay: 250, maxDelay: 30000, jitter: true };