@ignex/nova 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -32
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +232 -0
- package/docs/architecture.md +35 -10
- package/docs/events.md +170 -0
- package/docs/generic-bindings.md +197 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +9 -2
- package/index.ts +75 -27
- package/package.json +12 -2
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/bindings.ts +24 -0
- package/public/client.ts +5 -1
- package/public/events.ts +71 -0
- package/public/generate.ts +416 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +42 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/transcode/generated.rs +2 -1
- package/src/bindings/assemble.ts +73 -0
- package/src/bindings/default.ts +65 -0
- package/src/bindings/types.ts +113 -0
- package/src/bridge/nats.ts +53 -13
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +18 -0
- package/src/codegen/direct-gen.ts +550 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +242 -0
- package/src/codegen/rust-glue-gen.ts +545 -0
- package/src/codegen/schema-model.ts +338 -0
- package/src/codegen/ts-ser-gen.ts +221 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +2 -1
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-state.ts +21 -8
- package/src/core/client-wire.ts +10 -11
- package/src/core/client.ts +34 -29
- package/src/core/groups.ts +3 -0
- package/src/core/metrics.ts +7 -3
- package/src/core/outbound.ts +12 -5
- package/src/core/routing.ts +17 -8
- package/src/core/server.ts +108 -34
- package/src/core/state.ts +51 -13
- package/src/events/clients.ts +156 -0
- package/src/events/cluster.ts +732 -0
- package/src/events/data.ts +38 -0
- package/src/events/emit.ts +127 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub.ts +481 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +96 -0
- package/src/events/registry.ts +178 -0
- package/src/events/types.ts +378 -0
- package/src/generated/direct-ser.ts +2 -1
- package/src/generated/fbs/backend.fbs +1 -1
- package/src/generated/registry.ts +3 -1
- package/src/generated/ts-ser.ts +1 -1
- package/src/generated/wire-registry.json +1 -0
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +5 -2
- package/src/server.ts +7 -3
- package/src/transport/stats.ts +8 -4
- package/src/transport/transport.ts +149 -68
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|