@ignex/nova 0.1.3 → 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.
- package/README.md +4 -1
- package/docs/ai/TREE.md +69 -9
- package/docs/architecture.md +75 -27
- package/docs/events.md +83 -1
- package/docs/generic-bindings.md +10 -0
- package/docs/wire-format.md +65 -18
- package/package.json +2 -1
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/generate.ts +97 -3
- package/public/server.ts +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +376 -17
- package/src/bridge/nats/inbound.ts +46 -0
- package/src/bridge/nats/index.ts +131 -0
- package/src/bridge/nats/real-transport.ts +133 -0
- package/src/bridge/nats/types.ts +80 -0
- package/src/codegen/constants.ts +14 -4
- package/src/codegen/direct-gen.ts +20 -6
- package/src/codegen/registry-gen.ts +10 -6
- package/src/codegen/rust-glue-gen.ts +10 -3
- package/src/codegen/schema-model.ts +28 -3
- package/src/codegen/ts-ser-gen.ts +12 -3
- package/src/core/auth.ts +65 -4
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +42 -0
- package/src/core/client-wire.ts +142 -8
- package/src/core/client.ts +72 -3
- package/src/core/groups.ts +5 -0
- package/src/core/metrics.ts +38 -21
- package/src/core/outbound.ts +50 -6
- package/src/core/rate-limit.ts +69 -0
- package/src/core/replay.ts +41 -1
- package/src/core/resume.ts +181 -0
- package/src/core/rooms.ts +10 -3
- package/src/core/routing.ts +128 -5
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/{server.ts → server/index.ts} +112 -120
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +73 -1
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +18 -0
- package/src/events/cluster/dedupe.ts +43 -0
- package/src/events/cluster/envelope.ts +149 -0
- package/src/events/cluster/index.ts +50 -0
- package/src/events/cluster/keys.ts +33 -0
- package/src/events/cluster/kinds.ts +32 -0
- package/src/events/cluster/presence-table.ts +99 -0
- package/src/events/cluster/presence.ts +53 -0
- package/src/events/cluster/redis-client.ts +50 -0
- package/src/events/cluster/store-memory.ts +67 -0
- package/src/events/cluster/store-redis.ts +44 -0
- package/src/events/cluster/subjects.ts +30 -0
- package/src/events/cluster/sync.ts +476 -0
- package/src/events/cluster/transport-nats.ts +24 -0
- package/src/events/cluster/transport-redis.ts +120 -0
- package/src/events/cluster-rpc.ts +196 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +57 -11
- package/src/events/hub/context-factory.ts +79 -0
- package/src/events/hub/dispatch.ts +86 -0
- package/src/events/hub/index.ts +536 -0
- package/src/events/hub/internal.ts +31 -0
- package/src/events/hub/metrics-snapshot.ts +84 -0
- package/src/events/hub/resolve-cluster.ts +49 -0
- package/src/events/queue.ts +36 -9
- package/src/events/registry.ts +90 -54
- package/src/events/schedule.ts +73 -0
- package/src/events/trace.ts +283 -0
- package/src/events/types/client.ts +68 -0
- package/src/events/types/cluster.ts +40 -0
- package/src/events/types/context.ts +50 -0
- package/src/events/types/emit-target.ts +29 -0
- package/src/events/types/groups.ts +35 -0
- package/src/events/types/hub.ts +124 -0
- package/src/events/types/index.ts +30 -0
- package/src/events/types/metrics.ts +52 -0
- package/src/events/types/options.ts +62 -0
- package/src/generated/direct-ser.ts +146 -59
- package/src/generated/fbs/backend.fbs +23 -0
- package/src/generated/registry.ts +92 -33
- package/src/generated/rust/backend_generated.rs +503 -0
- package/src/generated/ts/backend.ts +4 -0
- package/src/generated/ts/resume.ts +74 -0
- package/src/generated/ts/resumed.ts +88 -0
- package/src/generated/ts/rpc-call.ts +112 -0
- package/src/generated/ts/rpc-result.ts +126 -0
- package/src/generated/ts/snapshot-request.ts +19 -5
- package/src/generated/ts-ser.ts +109 -16
- package/src/generated/wire-registry.json +7 -3
- package/src/schema/index.ts +45 -1
- package/src/transport/transport.ts +117 -77
- package/src/bridge/nats.ts +0 -309
- package/src/events/cluster.ts +0 -732
- package/src/events/hub.ts +0 -481
- package/src/events/types.ts +0 -378
- package/src/transport/stats.ts +0 -48
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS bridge — bidirectional FlatBuffer transport over NATS (composition
|
|
3
|
+
* root). Decomposed by concern:
|
|
4
|
+
*
|
|
5
|
+
* types.ts — options / stats / NatsTransport / NatsBridge contracts
|
|
6
|
+
* real-transport.ts — the eager, non-blocking connection with retry loop
|
|
7
|
+
* inbound.ts — the NATS→clients decode + filter pipeline
|
|
8
|
+
*
|
|
9
|
+
* OUTBOUND: the server encodes each event ONCE (Rust FFI → scratch), fans the
|
|
10
|
+
* same frame out to WS clients, then hands a COPY to `bridge.publish(subject,
|
|
11
|
+
* frame)` so other applications consume the identical wire bytes. Best-effort:
|
|
12
|
+
* if NATS is down the frame is dropped and counted in `bridgeErrors` — it
|
|
13
|
+
* never blocks or throws on the WS hot path.
|
|
14
|
+
*
|
|
15
|
+
* INBOUND: when `inbound` is enabled the bridge subscribes to `{prefix}.
|
|
16
|
+
* inbound.>` and forwards decodable app events to `onInbound` (wired by the
|
|
17
|
+
* server to fan out to clients). Control frames and unknown ids are dropped.
|
|
18
|
+
*
|
|
19
|
+
* HORIZONTAL SCALING: when `bridgeClientEvents` is set, the server re-publishes
|
|
20
|
+
* every accepted client-sent event to `{prefix}.inbound.<event>` so OTHER
|
|
21
|
+
* server instances (and BE consumers) receive it. Loop prevention: frames that
|
|
22
|
+
* arrive via NATS are forwarded through `onInbound` and never re-bridged.
|
|
23
|
+
*
|
|
24
|
+
* GENERIC: decodes inbound frames with the given `Bindings` (default: the
|
|
25
|
+
* built-in registry), so the bridge works for ANY schema. Tests can inject an
|
|
26
|
+
* `NatsTransport` fake — no broker needed in CI.
|
|
27
|
+
*/
|
|
28
|
+
import { defaultBindings } from "../../bindings/default";
|
|
29
|
+
import type { Bindings } from "../../bindings/types";
|
|
30
|
+
import { createSubjectBuilder } from "../subjects";
|
|
31
|
+
import { subscribeInboundSubject } from "./inbound";
|
|
32
|
+
import { createRealTransport } from "./real-transport";
|
|
33
|
+
import type {
|
|
34
|
+
NatsBridge,
|
|
35
|
+
NatsBridgeOptions,
|
|
36
|
+
NatsBridgeStats,
|
|
37
|
+
NatsBridgeStatus,
|
|
38
|
+
NatsTransport,
|
|
39
|
+
} from "./types";
|
|
40
|
+
|
|
41
|
+
export type {
|
|
42
|
+
NatsBridge,
|
|
43
|
+
NatsBridgeOptions,
|
|
44
|
+
NatsBridgeStats,
|
|
45
|
+
NatsBridgeStatus,
|
|
46
|
+
NatsTransport,
|
|
47
|
+
} from "./types";
|
|
48
|
+
|
|
49
|
+
export function createNatsBridge(
|
|
50
|
+
opts: NatsBridgeOptions = {},
|
|
51
|
+
transport?: NatsTransport,
|
|
52
|
+
bindings?: Bindings,
|
|
53
|
+
): NatsBridge {
|
|
54
|
+
const b = bindings ?? opts.bindings ?? defaultBindings;
|
|
55
|
+
const t = transport ?? createRealTransport(opts);
|
|
56
|
+
const subjects = createSubjectBuilder(opts.subjectPrefix ?? b.subjectPrefix ?? "ignex");
|
|
57
|
+
const stats: NatsBridgeStats = {
|
|
58
|
+
bridged: 0,
|
|
59
|
+
bridgedBytes: 0,
|
|
60
|
+
bridgeErrors: 0,
|
|
61
|
+
bridgeInbound: 0,
|
|
62
|
+
bridgeInboundErrors: 0,
|
|
63
|
+
};
|
|
64
|
+
let closed = false;
|
|
65
|
+
let onInbound: ((name: string, payload: unknown) => void) | null = null;
|
|
66
|
+
// stable accessor (the callback slot is set once by the server later)
|
|
67
|
+
const getOnInbound = (): ((name: string, payload: unknown) => void) | null => onInbound;
|
|
68
|
+
const allowlist = opts.inboundEvents ? new Set(opts.inboundEvents) : null;
|
|
69
|
+
|
|
70
|
+
// inbound subscriptions (lazy — the transport queues them until connected)
|
|
71
|
+
const unsubs: Array<() => void> = [];
|
|
72
|
+
if (opts.inbound) {
|
|
73
|
+
const subjectsList = opts.inboundSubjects?.length
|
|
74
|
+
? opts.inboundSubjects
|
|
75
|
+
: [subjects.inboundPrefix()];
|
|
76
|
+
for (const subject of subjectsList) {
|
|
77
|
+
unsubs.push(
|
|
78
|
+
subscribeInboundSubject({
|
|
79
|
+
transport: t,
|
|
80
|
+
bindings: b,
|
|
81
|
+
stats,
|
|
82
|
+
subject,
|
|
83
|
+
allowlist,
|
|
84
|
+
getOnInbound,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
get status(): NatsBridgeStatus {
|
|
92
|
+
if (closed) return "closed";
|
|
93
|
+
return t.connected ? "connected" : "connecting";
|
|
94
|
+
},
|
|
95
|
+
get subjects() {
|
|
96
|
+
return subjects;
|
|
97
|
+
},
|
|
98
|
+
get stats() {
|
|
99
|
+
return stats;
|
|
100
|
+
},
|
|
101
|
+
get clientEvents(): boolean {
|
|
102
|
+
return opts.bridgeClientEvents ?? false;
|
|
103
|
+
},
|
|
104
|
+
publish(subject, frame) {
|
|
105
|
+
if (!t.connected) {
|
|
106
|
+
stats.bridgeErrors++;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// the frame view is a reused scratch — copy before handing to NATS
|
|
110
|
+
const copy = frame.slice();
|
|
111
|
+
try {
|
|
112
|
+
t.publish(subject, copy);
|
|
113
|
+
stats.bridged++;
|
|
114
|
+
stats.bridgedBytes += copy.byteLength;
|
|
115
|
+
} catch {
|
|
116
|
+
stats.bridgeErrors++;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
setOnInbound(cb) {
|
|
120
|
+
onInbound = cb;
|
|
121
|
+
},
|
|
122
|
+
subscribeRaw(subject, cb) {
|
|
123
|
+
return t.subscribe(subject, (data) => cb(data));
|
|
124
|
+
},
|
|
125
|
+
async close() {
|
|
126
|
+
closed = true;
|
|
127
|
+
for (const u of unsubs) u();
|
|
128
|
+
await t.close();
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real NATS transport — an eager, non-blocking `NatsConnection` wrapper with
|
|
3
|
+
* an initial-connect retry loop, so a server can start while NATS is down.
|
|
4
|
+
*
|
|
5
|
+
* Subscriptions are RE-SYNCED on every (re)connect: the desired subject set
|
|
6
|
+
* is kept locally and replayed onto whichever connection is current —
|
|
7
|
+
* nats.js re-subscribes automatically after a reconnect, but the resync
|
|
8
|
+
* guards against edge cases (e.g. a replaced connection).
|
|
9
|
+
*/
|
|
10
|
+
import { connect, type NatsConnection } from "nats";
|
|
11
|
+
import type { NatsBridgeOptions, NatsTransport } from "./types";
|
|
12
|
+
|
|
13
|
+
interface PendingSub {
|
|
14
|
+
subject: string;
|
|
15
|
+
cb: (data: Uint8Array) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createRealTransport(opts: NatsBridgeOptions): NatsTransport {
|
|
19
|
+
let nc: NatsConnection | null = null;
|
|
20
|
+
let connected = false;
|
|
21
|
+
let closed = false;
|
|
22
|
+
// desired subscriptions — replayed on every (re)connect via sync()
|
|
23
|
+
const subs: Array<PendingSub> = [];
|
|
24
|
+
let unsubs: Array<() => void> = [];
|
|
25
|
+
|
|
26
|
+
/** Tear down the current subscription iterators and re-subscribe all. */
|
|
27
|
+
const sync = (): void => {
|
|
28
|
+
for (const u of unsubs) u();
|
|
29
|
+
unsubs = [];
|
|
30
|
+
if (!nc) return;
|
|
31
|
+
for (const s of subs) {
|
|
32
|
+
const sub = nc.subscribe(s.subject);
|
|
33
|
+
unsubs.push(() => sub.unsubscribe());
|
|
34
|
+
void (async () => {
|
|
35
|
+
try {
|
|
36
|
+
for await (const m of sub) s.cb(new Uint8Array(m.data));
|
|
37
|
+
} catch {
|
|
38
|
+
// subscription ended / connection closed
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Track connection liveness + trigger resyncs from nats.js status events. */
|
|
45
|
+
const attachStatus = (conn: NatsConnection): void => {
|
|
46
|
+
void conn
|
|
47
|
+
.closed()
|
|
48
|
+
.then(() => {
|
|
49
|
+
connected = false;
|
|
50
|
+
if (nc === conn) nc = null;
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {
|
|
53
|
+
connected = false;
|
|
54
|
+
});
|
|
55
|
+
void (async () => {
|
|
56
|
+
try {
|
|
57
|
+
for await (const st of conn.status()) {
|
|
58
|
+
if (st.type === "disconnect") connected = false;
|
|
59
|
+
else if (st.type === "reconnect") {
|
|
60
|
+
connected = true;
|
|
61
|
+
sync(); // nats.js re-subscribes automatically; resync to be safe
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
connected = false;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let connecting = false;
|
|
71
|
+
const tryConnect = async (): Promise<void> => {
|
|
72
|
+
// one attempt at a time; a live connection (even mid-reconnect, which
|
|
73
|
+
// nats.js drives internally) is never replaced by a duplicate dial
|
|
74
|
+
if (closed || connecting || nc !== null) return;
|
|
75
|
+
connecting = true;
|
|
76
|
+
try {
|
|
77
|
+
const conn = await connect({
|
|
78
|
+
servers: opts.servers ?? ["nats://localhost:4222"],
|
|
79
|
+
...(opts.token !== undefined ? { token: opts.token } : {}),
|
|
80
|
+
timeout: opts.connectTimeout ?? 5000,
|
|
81
|
+
reconnect: opts.reconnect ?? true,
|
|
82
|
+
maxReconnectAttempts: -1,
|
|
83
|
+
});
|
|
84
|
+
nc = conn;
|
|
85
|
+
connected = true;
|
|
86
|
+
attachStatus(conn);
|
|
87
|
+
sync();
|
|
88
|
+
} catch {
|
|
89
|
+
connected = false;
|
|
90
|
+
} finally {
|
|
91
|
+
connecting = false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// background retry loop — the caller NEVER awaits a dial
|
|
96
|
+
void (async () => {
|
|
97
|
+
while (!closed) {
|
|
98
|
+
if (nc === null) await tryConnect();
|
|
99
|
+
await Bun.sleep(opts.connectRetryMs ?? 2000);
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
get connected() {
|
|
105
|
+
return connected;
|
|
106
|
+
},
|
|
107
|
+
publish(subject, data) {
|
|
108
|
+
if (!nc) throw new Error("nats: not connected");
|
|
109
|
+
nc.publish(subject, data);
|
|
110
|
+
},
|
|
111
|
+
subscribe(subject, cb) {
|
|
112
|
+
subs.push({ subject, cb });
|
|
113
|
+
sync();
|
|
114
|
+
return () => {
|
|
115
|
+
const i = subs.findIndex((s) => s.subject === subject && s.cb === cb);
|
|
116
|
+
if (i >= 0) subs.splice(i, 1);
|
|
117
|
+
sync();
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
async close() {
|
|
121
|
+
closed = true;
|
|
122
|
+
if (nc) {
|
|
123
|
+
try {
|
|
124
|
+
await nc.close();
|
|
125
|
+
} catch {
|
|
126
|
+
// already closed
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
nc = null;
|
|
130
|
+
connected = false;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS bridge contracts — options, stats, and the transport/bridge surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Type + contract module: the runtime lives in `real-transport.ts` (the
|
|
5
|
+
* eager connection) and `index.ts` (the bridge itself).
|
|
6
|
+
*/
|
|
7
|
+
import type { SubjectBuilder } from "../subjects";
|
|
8
|
+
import type { Bindings } from "../../bindings/types";
|
|
9
|
+
|
|
10
|
+
export type NatsBridgeStatus = "connected" | "connecting" | "closed";
|
|
11
|
+
|
|
12
|
+
export interface NatsBridgeOptions {
|
|
13
|
+
/** NATS servers, default ["nats://localhost:4222"] */
|
|
14
|
+
servers?: string[];
|
|
15
|
+
/** subject prefix, default "ignex" (or the bindings' subjectPrefix) */
|
|
16
|
+
subjectPrefix?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The wire stack used to decode inbound frames (default: built-in registry).
|
|
19
|
+
* Pass your own generated bindings so the bridge decodes YOUR events.
|
|
20
|
+
*/
|
|
21
|
+
bindings?: Bindings;
|
|
22
|
+
/** connect timeout (ms), default 5000 */
|
|
23
|
+
connectTimeout?: number;
|
|
24
|
+
/** how long to wait before retrying a failed initial connect (ms), default 2000 */
|
|
25
|
+
connectRetryMs?: number;
|
|
26
|
+
/** reconnect handled by nats.js (core NATS, no durable queues), default true */
|
|
27
|
+
reconnect?: boolean;
|
|
28
|
+
/** optional NATS token (auth) */
|
|
29
|
+
token?: string;
|
|
30
|
+
/** subscribe to inbound subjects and forward events to clients, default false */
|
|
31
|
+
inbound?: boolean;
|
|
32
|
+
/** inbound subjects (default `{prefix}.inbound.>`), requires `inbound` */
|
|
33
|
+
inboundSubjects?: string[];
|
|
34
|
+
/** only forward these inbound events (default: every app event) */
|
|
35
|
+
inboundEvents?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Re-publish every accepted client-sent event to `{prefix}.inbound.<event>`
|
|
38
|
+
* so other servers in the cluster (and BE consumers) receive it, default
|
|
39
|
+
* false. See the horizontal-scaling docs.
|
|
40
|
+
*/
|
|
41
|
+
bridgeClientEvents?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Counters folded into `server.getMetrics()`. */
|
|
45
|
+
export interface NatsBridgeStats {
|
|
46
|
+
bridged: number;
|
|
47
|
+
bridgedBytes: number;
|
|
48
|
+
bridgeErrors: number;
|
|
49
|
+
bridgeInbound: number;
|
|
50
|
+
bridgeInboundErrors: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Minimal transport — a real NATS connection or a test fake. */
|
|
54
|
+
export interface NatsTransport {
|
|
55
|
+
readonly connected: boolean;
|
|
56
|
+
/** synchronously send bytes; throws when not connected (bridge catches + counts) */
|
|
57
|
+
publish(subject: string, data: Uint8Array): void;
|
|
58
|
+
/** subscribe; `cb` receives message bytes; returns an unsubscribe function */
|
|
59
|
+
subscribe(subject: string, cb: (data: Uint8Array) => void): () => void;
|
|
60
|
+
close(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface NatsBridge {
|
|
64
|
+
readonly status: NatsBridgeStatus;
|
|
65
|
+
readonly subjects: SubjectBuilder;
|
|
66
|
+
readonly stats: NatsBridgeStats;
|
|
67
|
+
/** whether client-sent events are re-published to `{prefix}.inbound.<event>` */
|
|
68
|
+
readonly clientEvents: boolean;
|
|
69
|
+
/** publish a frame to `subject` (copies the bytes — safe after scratch reuse) */
|
|
70
|
+
publish(subject: string, frame: Uint8Array): void;
|
|
71
|
+
/**
|
|
72
|
+
* Raw byte subscription (used by the events cluster layer). Unlike the
|
|
73
|
+
* inbound path this does NOT decode or forward — bytes are handed to `cb`
|
|
74
|
+
* verbatim, re-subscribed automatically after a NATS reconnect.
|
|
75
|
+
*/
|
|
76
|
+
subscribeRaw(subject: string, cb: (data: Uint8Array) => void): () => void;
|
|
77
|
+
/** wire the inbound → clients forward (set once by the server) */
|
|
78
|
+
setOnInbound(cb: (name: string, payload: unknown) => void): void;
|
|
79
|
+
close(): Promise<void>;
|
|
80
|
+
}
|
package/src/codegen/constants.ts
CHANGED
|
@@ -9,10 +9,20 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/** Wire format version. Bump on any BREAKING envelope change. */
|
|
12
|
-
export const WIRE_VERSION =
|
|
12
|
+
export const WIRE_VERSION = 2;
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Envelope header length in bytes:
|
|
16
|
-
*
|
|
15
|
+
* Envelope header length in bytes:
|
|
16
|
+
* [version:1][event_id:u32 LE][flags:1][seq:u64 LE]
|
|
17
|
+
* The size-prefixed FlatBuffer payload follows immediately after.
|
|
18
|
+
*
|
|
19
|
+
* `flags` bit0 = seq-valid: the server stamps a per-CONNECTION delivery seq on
|
|
20
|
+
* every frame it writes to a socket (mutated in place just before `ws.send`,
|
|
21
|
+
* which copies). Clients use it for gap detection + resume. Frames that were
|
|
22
|
+
* not per-destination stamped (client-encoded, replay history copies before
|
|
23
|
+
* stamping) carry flags=0 / seq=0.
|
|
17
24
|
*/
|
|
18
|
-
export const WIRE_HEADER_LEN =
|
|
25
|
+
export const WIRE_HEADER_LEN = 14;
|
|
26
|
+
|
|
27
|
+
/** flags bit: `seq` field carries a valid per-connection delivery sequence. */
|
|
28
|
+
export const WIRE_FLAG_SEQ = 1;
|
|
@@ -29,7 +29,13 @@ import { isDirectableEvent, plainTsType, toSnake } from "./schema-model";
|
|
|
29
29
|
export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
|
|
30
30
|
const directEvents = m.events.filter((ev) => isDirectableEvent(m, ev));
|
|
31
31
|
const userMode = ctx.schemaImport === null;
|
|
32
|
-
|
|
32
|
+
let libImport = "";
|
|
33
|
+
if (userMode) {
|
|
34
|
+
libImport =
|
|
35
|
+
ctx.libraryImport === undefined || ctx.libraryImport === "@ignex/nova"
|
|
36
|
+
? "@ignex/nova/internal"
|
|
37
|
+
: ctx.libraryImport;
|
|
38
|
+
}
|
|
33
39
|
|
|
34
40
|
const enumConsts = new Map<string, string>(); // enumName -> const name
|
|
35
41
|
for (const e of m.enums) {
|
|
@@ -71,7 +77,9 @@ export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
|
|
|
71
77
|
for (const ev of directEvents) {
|
|
72
78
|
const fields = fieldsOf(m, ev);
|
|
73
79
|
const args = fields.flatMap(directArgTypes).concat("ptr", "usize");
|
|
74
|
-
|
|
80
|
+
// Quote the key — dotted event names ("chat.send") would otherwise emit
|
|
81
|
+
// an invalid identifier (`fb_chat.send_serialize:`).
|
|
82
|
+
lines.push(` ${JSON.stringify(directSymbol(ev))}: {`);
|
|
75
83
|
lines.push(` args: [${args.map((a) => JSON.stringify(a)).join(", ")}],`);
|
|
76
84
|
lines.push(' returns: "u64_fast",');
|
|
77
85
|
lines.push(" },");
|
|
@@ -200,12 +208,12 @@ export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
|
|
|
200
208
|
lines.push(
|
|
201
209
|
"export const hasNulEncoders: Partial<Record<AnyEventName, (o: unknown) => boolean>> = {",
|
|
202
210
|
);
|
|
203
|
-
for (const ev of directEvents) lines.push(` ${ev.name}: hasNul${pascal(ev.name)},`);
|
|
211
|
+
for (const ev of directEvents) lines.push(` ${JSON.stringify(ev.name)}: hasNul${pascal(ev.name)},`);
|
|
204
212
|
lines.push("};");
|
|
205
213
|
lines.push("");
|
|
206
214
|
|
|
207
215
|
lines.push("export const directSymbolNames: Partial<Record<AnyEventName, string>> = {");
|
|
208
|
-
for (const ev of directEvents) lines.push(` ${ev.name}: ${JSON.stringify(directSymbol(ev))},`);
|
|
216
|
+
for (const ev of directEvents) lines.push(` ${JSON.stringify(ev.name)}: ${JSON.stringify(directSymbol(ev))},`);
|
|
209
217
|
lines.push("};");
|
|
210
218
|
lines.push("");
|
|
211
219
|
lines.push(
|
|
@@ -213,7 +221,7 @@ export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
|
|
|
213
221
|
);
|
|
214
222
|
lines.push("");
|
|
215
223
|
lines.push("export const directEncoders: Partial<Record<AnyEventName, DirectEncoder>> = {");
|
|
216
|
-
for (const ev of directEvents) lines.push(` ${ev.name}: encode${pascal(ev.name)},`);
|
|
224
|
+
for (const ev of directEvents) lines.push(` ${JSON.stringify(ev.name)}: encode${pascal(ev.name)},`);
|
|
217
225
|
lines.push("};");
|
|
218
226
|
lines.push("");
|
|
219
227
|
|
|
@@ -546,5 +554,11 @@ function scalarVecWrite(f: FieldDef, item: string, enumConsts: Map<string, strin
|
|
|
546
554
|
}
|
|
547
555
|
|
|
548
556
|
function pascal(s: string): string {
|
|
549
|
-
|
|
557
|
+
// "chat.send" → "ChatSend": split on non-alphanumerics so event names with
|
|
558
|
+
// dots/hyphens still produce valid identifiers (encodeChatSendPayload...).
|
|
559
|
+
return s
|
|
560
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
561
|
+
.filter((part) => part.length > 0)
|
|
562
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
563
|
+
.join("");
|
|
550
564
|
}
|
|
@@ -39,9 +39,13 @@ export function emitRegistry(m: Model, fingerprint: number, ctx: EmitContext = {
|
|
|
39
39
|
|
|
40
40
|
const userMode = ctx.schemaImport === null;
|
|
41
41
|
const schemaImport = userMode ? "" : (ctx.schemaImport ?? "../schema");
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
let poolImport = "../transport/byte-buffer-pool";
|
|
43
|
+
if (userMode) {
|
|
44
|
+
poolImport =
|
|
45
|
+
ctx.libraryImport === undefined || ctx.libraryImport === "@ignex/nova"
|
|
46
|
+
? "@ignex/nova/internal"
|
|
47
|
+
: ctx.libraryImport;
|
|
48
|
+
}
|
|
45
49
|
|
|
46
50
|
const lines: string[] = [];
|
|
47
51
|
lines.push("// @ts-nocheck — generated file: not subject to hand-typed strictness gates");
|
|
@@ -72,17 +76,17 @@ export function emitRegistry(m: Model, fingerprint: number, ctx: EmitContext = {
|
|
|
72
76
|
if (userMode) lines.push(...emitLocalTypes(m, neededTables, appEvents, controlEvents));
|
|
73
77
|
lines.push("export const eventNameToId: Record<EventName, number> = {");
|
|
74
78
|
for (const ev of appEvents)
|
|
75
|
-
lines.push(` ${ev.name}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
|
|
79
|
+
lines.push(` ${JSON.stringify(ev.name)}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
|
|
76
80
|
lines.push("};");
|
|
77
81
|
lines.push("");
|
|
78
82
|
lines.push("export const controlEventNameToId: Record<ControlEventName, number> = {");
|
|
79
83
|
for (const ev of controlEvents)
|
|
80
|
-
lines.push(` ${ev.name}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
|
|
84
|
+
lines.push(` ${JSON.stringify(ev.name)}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
|
|
81
85
|
lines.push("};");
|
|
82
86
|
lines.push("");
|
|
83
87
|
lines.push("/** merged app + control registry (used by encodeToScratch / JS encoder). */");
|
|
84
88
|
lines.push("export const anyEventNameToId: Record<string, number> = {");
|
|
85
|
-
for (const ev of m.events) lines.push(` ${ev.name}: ${eventId(ev.name)},`);
|
|
89
|
+
for (const ev of m.events) lines.push(` ${JSON.stringify(ev.name)}: ${eventId(ev.name)},`);
|
|
86
90
|
lines.push("};");
|
|
87
91
|
lines.push("");
|
|
88
92
|
lines.push("export const idToEventName: Record<number, EventName> = {");
|
|
@@ -368,6 +368,9 @@ function emitDirectFn(m: Model, ev: EventDef): string {
|
|
|
368
368
|
const table = m.tables.find((t) => t.name === ev.tableName)!;
|
|
369
369
|
const id = eventId(ev.name);
|
|
370
370
|
const snake = toSnake(ev.name);
|
|
371
|
+
// Dotted event names ("chat.send") must not leak into the Rust fn
|
|
372
|
+
// identifier — keep the C-ABI symbol exact via #[export_name] instead.
|
|
373
|
+
const fnId = snake.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
371
374
|
const decls = table.fields.map(directArgDecl).join("\n ");
|
|
372
375
|
const decodes = table.fields
|
|
373
376
|
.map(directDecodeLines)
|
|
@@ -379,8 +382,8 @@ function emitDirectFn(m: Model, ev: EventDef): string {
|
|
|
379
382
|
.join("\n ");
|
|
380
383
|
const assigns = table.fields.map(directArgAssign).join("\n ");
|
|
381
384
|
return [
|
|
382
|
-
`#[
|
|
383
|
-
`pub unsafe extern "C" fn fb_${
|
|
385
|
+
`#[export_name = "fb_${snake}_serialize"]`,
|
|
386
|
+
`pub unsafe extern "C" fn fb_${fnId}_serialize(`,
|
|
384
387
|
` ${decls}`,
|
|
385
388
|
` out: *mut u8,`,
|
|
386
389
|
` out_cap: usize,`,
|
|
@@ -409,6 +412,10 @@ function emitDirectFn(m: Model, ev: EventDef): string {
|
|
|
409
412
|
` *out.add(0) = WIRE_VERSION;`,
|
|
410
413
|
` let id_bytes = ${id}u32.to_le_bytes();`,
|
|
411
414
|
` std::ptr::copy_nonoverlapping(id_bytes.as_ptr(), out.add(1), 4);`,
|
|
415
|
+
` // [flags:1][seq:u64 LE] — pristine (flags=0, seq=0); the`,
|
|
416
|
+
` // server stamps per-destination delivery seqs on send.`,
|
|
417
|
+
` *out.add(5) = 0;`,
|
|
418
|
+
` std::ptr::write_bytes(out.add(6), 0, 8);`,
|
|
412
419
|
` }`,
|
|
413
420
|
` needed`,
|
|
414
421
|
` }`,
|
|
@@ -444,7 +451,7 @@ export function emitRustGlue(m: Model, fingerprint: number): string {
|
|
|
444
451
|
lines.push("");
|
|
445
452
|
lines.push(`pub const WIRE_VERSION: u8 = ${WIRE_VERSION};`);
|
|
446
453
|
lines.push(
|
|
447
|
-
`pub const WIRE_HEADER_LEN: usize = ${WIRE_HEADER_LEN}; // [version:1][event_id:u32 LE]`,
|
|
454
|
+
`pub const WIRE_HEADER_LEN: usize = ${WIRE_HEADER_LEN}; // [version:1][event_id:u32 LE][flags:1][seq:u64 LE]`,
|
|
448
455
|
);
|
|
449
456
|
lines.push(`pub const SCHEMA_FINGERPRINT: u64 = ${fingerprint}; // fnv1a32(canonical model)`);
|
|
450
457
|
lines.push("");
|
|
@@ -199,6 +199,17 @@ function ensureTable(ctx: Ctx, schema: AnySchema, fallbackName: string): string
|
|
|
199
199
|
return name;
|
|
200
200
|
}
|
|
201
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
|
+
|
|
202
213
|
function resolveFieldType(
|
|
203
214
|
ctx: Ctx,
|
|
204
215
|
schema: AnySchema,
|
|
@@ -216,7 +227,11 @@ function resolveFieldType(
|
|
|
216
227
|
const tableName = ensureTable(ctx, tableMember, `${parentName}${toPascal(jsonName)}`);
|
|
217
228
|
return { kind: "table", tableName };
|
|
218
229
|
}
|
|
219
|
-
|
|
230
|
+
throw unsupportedFieldType(
|
|
231
|
+
parentName,
|
|
232
|
+
jsonName,
|
|
233
|
+
"union of mixed members (only string-literal enums and single-object unions are supported)",
|
|
234
|
+
);
|
|
220
235
|
}
|
|
221
236
|
|
|
222
237
|
switch (schema.type) {
|
|
@@ -262,12 +277,22 @@ function resolveFieldType(
|
|
|
262
277
|
}
|
|
263
278
|
}
|
|
264
279
|
case "object": {
|
|
265
|
-
if (!isObjectLike(schema))
|
|
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
|
+
}
|
|
266
287
|
const tableName = ensureTable(ctx, schema, `${parentName}${toPascal(jsonName)}`);
|
|
267
288
|
return { kind: "table", tableName };
|
|
268
289
|
}
|
|
269
290
|
default:
|
|
270
|
-
|
|
291
|
+
throw unsupportedFieldType(
|
|
292
|
+
parentName,
|
|
293
|
+
jsonName,
|
|
294
|
+
`unrecognized TypeBox type ${schema.type === undefined ? "(Type.Any()/Type.Unknown()?)" : `"${String(schema.type)}"`}`,
|
|
295
|
+
);
|
|
271
296
|
}
|
|
272
297
|
}
|
|
273
298
|
|
|
@@ -122,7 +122,7 @@ export function emitTsSer(m: Model, ctx: EmitContext = {}): string {
|
|
|
122
122
|
lines.push("");
|
|
123
123
|
lines.push("export const jsEncoders: Record<string, JsEncoder> = {");
|
|
124
124
|
for (const ev of m.events)
|
|
125
|
-
lines.push(` ${ev.name}: encode${pascal(ev.name)}Payload as JsEncoder,`);
|
|
125
|
+
lines.push(` ${JSON.stringify(ev.name)}: encode${pascal(ev.name)}Payload as JsEncoder,`);
|
|
126
126
|
lines.push("};");
|
|
127
127
|
lines.push("");
|
|
128
128
|
lines.push("/** Encode a payload (app or control event) → size-prefixed FlatBuffer. */");
|
|
@@ -135,7 +135,7 @@ export function emitTsSer(m: Model, ctx: EmitContext = {}): string {
|
|
|
135
135
|
lines.push("}");
|
|
136
136
|
lines.push("");
|
|
137
137
|
lines.push(
|
|
138
|
-
"/** Encode a payload into a full wire frame `[version][event_id:u32][size-prefixed FB]`. */",
|
|
138
|
+
"/** Encode a payload into a full wire frame `[version][event_id:u32][flags:1][seq:u64 LE][size-prefixed FB]`. */",
|
|
139
139
|
);
|
|
140
140
|
lines.push(
|
|
141
141
|
"export function encodeEventFrame(name: AnyEventName, o: unknown, b?: flatbuffers.Builder): Uint8Array {",
|
|
@@ -145,6 +145,9 @@ export function emitTsSer(m: Model, ctx: EmitContext = {}): string {
|
|
|
145
145
|
lines.push(" const frame = new Uint8Array(WIRE_HEADER_LEN + payload.byteLength);");
|
|
146
146
|
lines.push(" frame[0] = WIRE_VERSION;");
|
|
147
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);");
|
|
148
151
|
lines.push(" frame.set(payload, WIRE_HEADER_LEN);");
|
|
149
152
|
lines.push(" return frame;");
|
|
150
153
|
lines.push("}");
|
|
@@ -217,5 +220,11 @@ function ctorArg(f: FieldDef, o: string): string {
|
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
function pascal(s: string): string {
|
|
220
|
-
|
|
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("");
|
|
221
230
|
}
|