@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,363 @@
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
+ /**
203
+ * Error for TypeBox field kinds that have no FlatBuffers representation. These
204
+ * used to be silently coerced to `string` fields (which forced JSON.stringify
205
+ * on the app side and put raw JSON text on the wire) — fail loudly instead.
206
+ */
207
+ function unsupportedFieldType(parentName: string, jsonName: string, reason: string): Error {
208
+ return new Error(
209
+ `generateBindings: field "${jsonName}" in "${parentName}" has no FlatBuffers representation (${reason}) — model it explicitly: Type.String() for a JSON payload, a typed object/table, or a flat scalar/vector type`,
210
+ );
211
+ }
212
+
213
+ function resolveFieldType(
214
+ ctx: Ctx,
215
+ schema: AnySchema,
216
+ jsonName: string,
217
+ parentName: string,
218
+ ): { kind: FieldKind; enumName?: string; tableName?: string; bigint?: boolean } {
219
+ const members = unionMembers(schema);
220
+ if (members.length > 0) {
221
+ if (members.every(isStringLiteral)) {
222
+ const enumName = registerEnum(ctx, schema, members.map((m) => m.const as string), jsonName);
223
+ return { kind: "enum", enumName };
224
+ }
225
+ const tableMember = members.find(isObjectLike);
226
+ if (tableMember && members.filter((m) => !isNullMember(m)).length === 1) {
227
+ const tableName = ensureTable(ctx, tableMember, `${parentName}${toPascal(jsonName)}`);
228
+ return { kind: "table", tableName };
229
+ }
230
+ throw unsupportedFieldType(
231
+ parentName,
232
+ jsonName,
233
+ "union of mixed members (only string-literal enums and single-object unions are supported)",
234
+ );
235
+ }
236
+
237
+ switch (schema.type) {
238
+ case "string": {
239
+ if (typeof schema.const === "string") {
240
+ const enumName = registerEnum(ctx, schema, [schema.const], jsonName);
241
+ return { kind: "enum", enumName };
242
+ }
243
+ return { kind: "string" };
244
+ }
245
+ case "number":
246
+ return { kind: "double" };
247
+ case "integer":
248
+ return { kind: "int64" };
249
+ case "bigint":
250
+ // Type.BigInt() → EXACT int64 (decoded/encoded as bigint, not number)
251
+ return { kind: "int64", bigint: true };
252
+ case "boolean":
253
+ return { kind: "bool" };
254
+ case "array": {
255
+ const items = schema.items as AnySchema | undefined;
256
+ if (!items) return { kind: "vector-double" };
257
+ if (isObjectLike(items)) {
258
+ const tableName = ensureTable(ctx, items, `${parentName}${toPascal(jsonName)}Item`);
259
+ return { kind: "vector-table", tableName };
260
+ }
261
+ const itemMembers = unionMembers(items);
262
+ if (itemMembers.length > 0 && itemMembers.every(isStringLiteral)) {
263
+ const enumName = registerEnum(ctx, items, itemMembers.map((m) => m.const as string), jsonName);
264
+ return { kind: "vector-enum", enumName };
265
+ }
266
+ switch (items.type) {
267
+ case "string":
268
+ return { kind: "vector-string" };
269
+ case "number":
270
+ return { kind: "vector-double" };
271
+ case "integer":
272
+ return { kind: "vector-int64" };
273
+ case "boolean":
274
+ return { kind: "vector-bool" };
275
+ default:
276
+ return { kind: "vector-double" };
277
+ }
278
+ }
279
+ case "object": {
280
+ if (!isObjectLike(schema)) {
281
+ throw unsupportedFieldType(
282
+ parentName,
283
+ jsonName,
284
+ "dynamic-key object (Type.Record / additionalProperties) has no FlatBuffers table representation",
285
+ );
286
+ }
287
+ const tableName = ensureTable(ctx, schema, `${parentName}${toPascal(jsonName)}`);
288
+ return { kind: "table", tableName };
289
+ }
290
+ default:
291
+ throw unsupportedFieldType(
292
+ parentName,
293
+ jsonName,
294
+ `unrecognized TypeBox type ${schema.type === undefined ? "(Type.Any()/Type.Unknown()?)" : `"${String(schema.type)}"`}`,
295
+ );
296
+ }
297
+ }
298
+
299
+ /** TS type expression for a plain-object field (used by user-mode codegen). */
300
+ export function plainTsType(m: Model, f: FieldDef, suffix = "Payload"): string {
301
+ switch (f.kind) {
302
+ case "string":
303
+ return f.required ? "string" : "string | undefined";
304
+ case "double":
305
+ return "number";
306
+ case "int64":
307
+ return f.bigint ? "bigint" : "number";
308
+ case "bool":
309
+ return "boolean";
310
+ case "enum":
311
+ return enumUnion(m, f);
312
+ case "table":
313
+ return f.required ? `${f.tableName}${suffix}` : `${f.tableName}${suffix} | undefined`;
314
+ case "vector-string":
315
+ return `string[]${f.required ? "" : " | undefined"}`;
316
+ case "vector-double":
317
+ return `number[]${f.required ? "" : " | undefined"}`;
318
+ case "vector-int64":
319
+ return `number[]${f.required ? "" : " | undefined"}`;
320
+ case "vector-bool":
321
+ return `boolean[]${f.required ? "" : " | undefined"}`;
322
+ case "vector-enum":
323
+ return `(${enumUnion(m, f)})[]${f.required ? "" : " | undefined"}`;
324
+ case "vector-table":
325
+ return `${f.tableName}${suffix}[]${f.required ? "" : " | undefined"}`;
326
+ default:
327
+ return "unknown";
328
+ }
329
+ }
330
+
331
+ /** string literal union of the enum values, e.g. `"buy" | "sell"`. */
332
+ export function enumUnion(m: Model, f: FieldDef): string {
333
+ const values = m.enums.find((e) => e.name === f.enumName)?.values ?? [];
334
+ return values.map((v) => JSON.stringify(v)).join(" | ");
335
+ }
336
+
337
+ export function buildModel(
338
+ namedSchemas: Record<string, TSchema>,
339
+ eventSchemas: Record<string, TSchema>,
340
+ controlEventSchemas: Record<string, TSchema> = {},
341
+ ): Model {
342
+ const ctx: Ctx = {
343
+ named: new Map(),
344
+ used: new Set(),
345
+ enums: [],
346
+ tables: [],
347
+ events: [],
348
+ enumById: new Map(),
349
+ tableById: new Map(),
350
+ };
351
+ for (const [name, schema] of Object.entries(namedSchemas)) {
352
+ ctx.named.set(schema as object, name);
353
+ }
354
+ for (const [name, schema] of Object.entries(eventSchemas)) {
355
+ const tableName = ensureTable(ctx, schema as AnySchema, toPascal(name));
356
+ ctx.events.push({ name, tableName });
357
+ }
358
+ for (const [name, schema] of Object.entries(controlEventSchemas)) {
359
+ const tableName = ensureTable(ctx, schema as AnySchema, toPascal(name));
360
+ ctx.events.push({ name, tableName, control: true });
361
+ }
362
+ return { enums: ctx.enums, tables: ctx.tables, events: ctx.events };
363
+ }
@@ -0,0 +1,230 @@
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(` ${JSON.stringify(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][flags:1][seq:u64 LE][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(" // pristine delivery header (flags=0, seq=0) — client-sent frames are not stamped");
149
+ lines.push(" frame[WIRE_HEADER_LEN - 9] = 0;");
150
+ lines.push(" new DataView(frame.buffer).setBigUint64(WIRE_HEADER_LEN - 8, 0n, true);");
151
+ lines.push(" frame.set(payload, WIRE_HEADER_LEN);");
152
+ lines.push(" return frame;");
153
+ lines.push("}");
154
+ lines.push("");
155
+
156
+ return lines.join("\n");
157
+ }
158
+
159
+ function appEventsOf(m: Model) {
160
+ return m.events.filter((e) => !e.control);
161
+ }
162
+ function controlEventsOf(m: Model) {
163
+ return m.events.filter((e) => e.control);
164
+ }
165
+
166
+ /** Local `EventName` / `Events` / `ControlEvents` type map over the XxxPlain types. */
167
+ function emitLocalTypeMap(
168
+ _m: Model,
169
+ appEvents: Model["events"],
170
+ controlEvents: Model["events"],
171
+ ): string[] {
172
+ const lines: string[] = [];
173
+ lines.push(`export type EventName = ${appEvents.map((e) => `"${e.name}"`).join(" | ")};`);
174
+ lines.push(
175
+ `export type ControlEventName = ${controlEvents.map((e) => `"${e.name}"`).join(" | ")};`,
176
+ );
177
+ lines.push("export type AnyEventName = EventName | ControlEventName;");
178
+ lines.push("");
179
+ lines.push("export type Events = {");
180
+ for (const ev of appEvents) lines.push(` ${JSON.stringify(ev.name)}: ${ev.tableName}Plain;`);
181
+ lines.push("};");
182
+ lines.push("export type ControlEvents = {");
183
+ for (const ev of controlEvents) lines.push(` ${JSON.stringify(ev.name)}: ${ev.tableName}Plain;`);
184
+ lines.push("};");
185
+ lines.push("");
186
+ return lines;
187
+ }
188
+
189
+ function ctorArg(f: FieldDef, o: string): string {
190
+ const j = `${o}.${f.jsonName}`;
191
+ switch (f.kind) {
192
+ case "string":
193
+ // flatc .T strings are nullable; absent optional → null
194
+ return f.required ? j : `${j} ?? null`;
195
+ case "double":
196
+ return j;
197
+ case "int64":
198
+ // bigint-annotated fields are already bigint (pass through); plain int64
199
+ // are `number` in the Events type → BigInt for the flatc object API
200
+ return f.bigint ? j : `${j} === undefined ? 0n : BigInt(${j})`;
201
+ case "bool":
202
+ return j;
203
+ case "enum":
204
+ return `${toSnake(f.enumName!).toUpperCase()}[${j}]`;
205
+ case "table":
206
+ return f.required ? `build${f.tableName}T(${j})` : `${j} ? build${f.tableName}T(${j}) : null`;
207
+ case "vector-string":
208
+ case "vector-double":
209
+ case "vector-bool":
210
+ return `(${j} ?? [])`;
211
+ case "vector-int64":
212
+ return `(${j} ?? []).map((v) => BigInt(v))`;
213
+ case "vector-enum":
214
+ return `(${j} ?? []).map((v) => ${toSnake(f.enumName!).toUpperCase()}[v])`;
215
+ case "vector-table":
216
+ return `(${j} ?? []).map((v) => build${f.tableName}T(v))`;
217
+ default:
218
+ return j;
219
+ }
220
+ }
221
+
222
+ function pascal(s: string): string {
223
+ // "chat.send" → "ChatSend": split on non-alphanumerics so event names with
224
+ // dots/hyphens still produce valid identifiers (encodeChatSendPayload...).
225
+ return s
226
+ .split(/[^a-zA-Z0-9]+/)
227
+ .filter((part) => part.length > 0)
228
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
229
+ .join("");
230
+ }
@@ -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
+ }