@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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-connection app data store — the "what to store for each client" half of
|
|
3
|
+
* the client record. A tiny Map-backed key/value bag with JSON export, created
|
|
4
|
+
* on attach and discarded on detach (no leak across reconnects).
|
|
5
|
+
*/
|
|
6
|
+
import type { ClientData } from "./types";
|
|
7
|
+
|
|
8
|
+
export function createClientData(): ClientData {
|
|
9
|
+
const map = new Map<string, unknown>();
|
|
10
|
+
return {
|
|
11
|
+
get(key) {
|
|
12
|
+
return map.get(key);
|
|
13
|
+
},
|
|
14
|
+
set(key, value) {
|
|
15
|
+
map.set(key, value);
|
|
16
|
+
},
|
|
17
|
+
has(key) {
|
|
18
|
+
return map.has(key);
|
|
19
|
+
},
|
|
20
|
+
delete(key) {
|
|
21
|
+
return map.delete(key);
|
|
22
|
+
},
|
|
23
|
+
clear() {
|
|
24
|
+
map.clear();
|
|
25
|
+
},
|
|
26
|
+
keys() {
|
|
27
|
+
return [...map.keys()];
|
|
28
|
+
},
|
|
29
|
+
entries() {
|
|
30
|
+
return [...map.entries()];
|
|
31
|
+
},
|
|
32
|
+
toJSON() {
|
|
33
|
+
const out: Record<string, unknown> = {};
|
|
34
|
+
for (const [k, v] of map) out[k] = v;
|
|
35
|
+
return out;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit engine — the "global emit" hot path. Encodes ONCE through the transport
|
|
3
|
+
* scratch (zero-alloc on the happy path), delivers to the target's local
|
|
4
|
+
* sockets synchronously, then hands the SAME frame to the external bridge
|
|
5
|
+
* (existing NATS broadcast/topic/group subjects) and the cluster sync
|
|
6
|
+
* (offloaded) so other instances deliver it too.
|
|
7
|
+
*
|
|
8
|
+
* Hot-path guarantees:
|
|
9
|
+
* - encode + local delivery are synchronous and allocation-free (Bun copies
|
|
10
|
+
* on `ws.send`);
|
|
11
|
+
* - the bridge `publish` copies the scratch frame (never holds a stale view);
|
|
12
|
+
* - cluster work is enqueued on the offload queue — a slow broker can never
|
|
13
|
+
* stall the socket loop.
|
|
14
|
+
*/
|
|
15
|
+
import type { ServerWebSocket } from "bun";
|
|
16
|
+
import type { NatsBridge } from "../bridge/nats";
|
|
17
|
+
import type { ServerState, WsData } from "../core/state";
|
|
18
|
+
import { sendFrame } from "../core/outbound";
|
|
19
|
+
import { publishToGroup } from "../core/groups";
|
|
20
|
+
import { publishToRoom } from "../core/rooms";
|
|
21
|
+
import type { ClusterSync } from "./cluster";
|
|
22
|
+
import type { EmitTarget, EmitTargetKind } from "./types";
|
|
23
|
+
|
|
24
|
+
export interface EmitCounters {
|
|
25
|
+
emitted: number;
|
|
26
|
+
emittedByTarget: Record<EmitTargetKind, number>;
|
|
27
|
+
deliveredLocal: number;
|
|
28
|
+
clusterPublished: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EmitEngine {
|
|
32
|
+
emit(name: string, payload: unknown, target: EmitTarget): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EmitterOptions {
|
|
36
|
+
state: ServerState;
|
|
37
|
+
bridge?: NatsBridge;
|
|
38
|
+
cluster?: ClusterSync;
|
|
39
|
+
/** local sockets acting on behalf of a userId (user-target delivery) */
|
|
40
|
+
userSockets: (userId: string) => ServerWebSocket<WsData>[];
|
|
41
|
+
counters: EmitCounters;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Deliver `frame` to the target's LOCAL sockets; returns the number written. */
|
|
45
|
+
export function deliverLocal(
|
|
46
|
+
state: ServerState,
|
|
47
|
+
target: EmitTarget,
|
|
48
|
+
frame: Uint8Array,
|
|
49
|
+
userSockets: (userId: string) => ServerWebSocket<WsData>[],
|
|
50
|
+
): number {
|
|
51
|
+
switch (target.type) {
|
|
52
|
+
case "broadcast": {
|
|
53
|
+
for (const ws of state.sockets) sendFrame(state, ws, frame);
|
|
54
|
+
return state.sockets.size;
|
|
55
|
+
}
|
|
56
|
+
case "topic": {
|
|
57
|
+
const n = state.rooms.get(target.topic)?.size ?? 0;
|
|
58
|
+
publishToRoom(state, target.topic, frame);
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
case "group": {
|
|
62
|
+
const n = state.groups.get(target.group)?.size ?? 0;
|
|
63
|
+
publishToGroup(state, target.group, frame);
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
case "user": {
|
|
67
|
+
const list = userSockets(target.userId);
|
|
68
|
+
for (const ws of list) sendFrame(state, ws, frame);
|
|
69
|
+
return list.length;
|
|
70
|
+
}
|
|
71
|
+
case "client": {
|
|
72
|
+
const ws = state.clients.get(target.clientId);
|
|
73
|
+
if (!ws) return 0;
|
|
74
|
+
sendFrame(state, ws, frame);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function targetKey(target: EmitTarget): string | undefined {
|
|
81
|
+
switch (target.type) {
|
|
82
|
+
case "broadcast":
|
|
83
|
+
return undefined;
|
|
84
|
+
case "topic":
|
|
85
|
+
return target.topic;
|
|
86
|
+
case "group":
|
|
87
|
+
return target.group;
|
|
88
|
+
case "user":
|
|
89
|
+
return target.userId;
|
|
90
|
+
case "client":
|
|
91
|
+
return target.clientId;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createEmitter(opts: EmitterOptions): EmitEngine {
|
|
96
|
+
const { state, bridge, cluster } = opts;
|
|
97
|
+
|
|
98
|
+
const bridgeSubject = (target: EmitTarget, name: string): string | undefined => {
|
|
99
|
+
switch (target.type) {
|
|
100
|
+
case "broadcast":
|
|
101
|
+
return bridge?.subjects.broadcast(name);
|
|
102
|
+
case "topic":
|
|
103
|
+
return bridge?.subjects.topic(target.topic, name);
|
|
104
|
+
case "group":
|
|
105
|
+
return bridge?.subjects.group(target.group, name);
|
|
106
|
+
default:
|
|
107
|
+
return undefined; // user/client targets are not bridged externally
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
emit(name, payload, target) {
|
|
113
|
+
const frame = state.transport.encodeToScratch(name, payload);
|
|
114
|
+
opts.counters.emitted++;
|
|
115
|
+
opts.counters.emittedByTarget[target.type]++;
|
|
116
|
+
opts.counters.deliveredLocal += deliverLocal(state, target, frame, opts.userSockets);
|
|
117
|
+
if (bridge) {
|
|
118
|
+
const subject = bridgeSubject(target, name);
|
|
119
|
+
if (subject) bridge.publish(subject, frame); // copies the scratch view
|
|
120
|
+
}
|
|
121
|
+
if (cluster) {
|
|
122
|
+
opts.counters.clusterPublished++;
|
|
123
|
+
cluster.publish(target.type, targetKey(target), name, frame);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global emit — the module-level singleton the events layer exposes so ANY
|
|
3
|
+
* module in the app can send events through websockets without holding a
|
|
4
|
+
* server reference (the "global emit" of the events file pattern):
|
|
5
|
+
*
|
|
6
|
+
* import { on, emit } from "ignex-nova/events";
|
|
7
|
+
* on("order.created", (ctx) => { ... });
|
|
8
|
+
* emit("quote", { symbol: "AAPL", ... }); // broadcast
|
|
9
|
+
* emit("order", payload, { type: "group", group: "traders" }); // to a group
|
|
10
|
+
* emit("alert", payload, { type: "user", userId: "u-1" }); // to a user
|
|
11
|
+
*
|
|
12
|
+
* Bound automatically when `createServer({ events: {...} })` runs (last hub
|
|
13
|
+
* wins — one active hub per process is the supported topology; use
|
|
14
|
+
* `server.events` directly for per-server control). Calls before binding
|
|
15
|
+
* throw a descriptive error; calls after `close()` are dropped silently.
|
|
16
|
+
*
|
|
17
|
+
* Typed against the BUILT-IN event registry. Custom-schema apps should use
|
|
18
|
+
* `server.events.emit` / `server.events.on` (fully typed against YOUR events).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { DefaultBindings } from "../bindings/types";
|
|
22
|
+
import type { EventName, Events } from "../schema";
|
|
23
|
+
import type { EmitTarget, EventContext, EventHandler, EventsHub } from "./types";
|
|
24
|
+
|
|
25
|
+
let bound: EventsHub | null = null;
|
|
26
|
+
|
|
27
|
+
/** Bind the singleton to a hub (called by the hub on creation). */
|
|
28
|
+
export function bindEvents(hub: EventsHub): void {
|
|
29
|
+
bound = hub;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Unbind the singleton (called by the hub on close). */
|
|
33
|
+
export function unbindEvents(): void {
|
|
34
|
+
bound = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isEventsBound(): boolean {
|
|
38
|
+
return bound !== null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The currently bound hub (null when none). */
|
|
42
|
+
export function getEventsHub(): EventsHub | null {
|
|
43
|
+
return bound;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requireHub(): EventsHub {
|
|
47
|
+
if (!bound) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"ignex events: no events hub bound — pass `events: {}` to createServer() (or call bindEvents(hub) once)",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return bound;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function emit<K extends EventName>(name: K, payload: Events[K], target?: EmitTarget): void {
|
|
56
|
+
requireHub().emit(name as never, payload as never, target);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function emitToTopic<K extends EventName>(topic: string, name: K, payload: Events[K]): void {
|
|
60
|
+
requireHub().emitToTopic(topic, name as never, payload as never);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function emitToGroup<K extends EventName>(group: string, name: K, payload: Events[K]): void {
|
|
64
|
+
requireHub().emitToGroup(group, name as never, payload as never);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function emitToUser<K extends EventName>(userId: string, name: K, payload: Events[K]): void {
|
|
68
|
+
requireHub().emitToUser(userId, name as never, payload as never);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function emitToClient<K extends EventName>(
|
|
72
|
+
clientId: string,
|
|
73
|
+
name: K,
|
|
74
|
+
payload: Events[K],
|
|
75
|
+
): void {
|
|
76
|
+
requireHub().emitToClient(clientId, name as never, payload as never);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function on<K extends EventName>(name: K, handler: EventHandler<DefaultBindings, K>): void {
|
|
80
|
+
requireHub().on(name as never, handler as never);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function off<K extends EventName>(
|
|
84
|
+
name: K,
|
|
85
|
+
handler?: EventHandler<DefaultBindings, K>,
|
|
86
|
+
): void {
|
|
87
|
+
requireHub().off(name as never, handler as never);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function once<K extends EventName>(
|
|
91
|
+
name: K,
|
|
92
|
+
handler: EventHandler<DefaultBindings, K>,
|
|
93
|
+
): void {
|
|
94
|
+
requireHub().once(name as never, handler as never);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function onAny(cb: (name: string, payload: unknown, ctx: EventContext) => void): void {
|
|
98
|
+
requireHub().onAny(cb as never);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function offAny(cb: (name: string, payload: unknown, ctx: EventContext) => void): void {
|
|
102
|
+
requireHub().offAny(cb as never);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function onServerEvent<K extends EventName>(
|
|
106
|
+
name: K,
|
|
107
|
+
handler: EventHandler<DefaultBindings, K>,
|
|
108
|
+
): void {
|
|
109
|
+
requireHub().onServerEvent(name as never, handler as never);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function offServerEvent<K extends EventName>(
|
|
113
|
+
name: K,
|
|
114
|
+
handler?: EventHandler<DefaultBindings, K>,
|
|
115
|
+
): void {
|
|
116
|
+
requireHub().offServerEvent(name as never, handler as never);
|
|
117
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Groups — named fan-out dimensions with explicit differentiation:
|
|
3
|
+
*
|
|
4
|
+
* - CLIENT groups (`hub.group(name)`): membership by connection id. These
|
|
5
|
+
* reuse the transport's group registry (`state.groups`), so auth-seeded
|
|
6
|
+
* groups, control frames (`client.joinGroup`), `server.joinGroup` and the
|
|
7
|
+
* events layer all see the SAME membership.
|
|
8
|
+
* - USER groups (`hub.userGroup(name)`): membership by `userId`. Emitting to
|
|
9
|
+
* a user group fans out to every socket acting on behalf of each member
|
|
10
|
+
* user — the "broadcast to a group of people" case, independent of how
|
|
11
|
+
* many devices each person has open.
|
|
12
|
+
*
|
|
13
|
+
* Both are cluster-aware at the hub level: emit → `emitToGroup` / `emitToUser`
|
|
14
|
+
* (which publish to the cluster subjects), and membership syncs to the shared
|
|
15
|
+
* state store when one is configured.
|
|
16
|
+
*/
|
|
17
|
+
import type { ServerWebSocket } from "bun";
|
|
18
|
+
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../bindings/types";
|
|
19
|
+
import { activeGroups, groupMembers as stateGroupMembers, joinGroup, leaveGroup } from "../core/groups";
|
|
20
|
+
import type { ServerState, WsData } from "../core/state";
|
|
21
|
+
import type { ClientGroup, UserGroup } from "./types";
|
|
22
|
+
|
|
23
|
+
export interface GroupManager<B extends Bindings = DefaultBindings> {
|
|
24
|
+
clientGroup(name: string): ClientGroup<B>;
|
|
25
|
+
clientGroups(): string[];
|
|
26
|
+
userGroup(name: string): UserGroup<B>;
|
|
27
|
+
userGroups(): string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GroupManagerOptions<B extends Bindings = DefaultBindings> {
|
|
31
|
+
state: ServerState;
|
|
32
|
+
emitToGroup: <K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]) => void;
|
|
33
|
+
emitToUser: <K extends EventNameOf<B>>(userId: string, name: K, payload: EventsOf<B>[K]) => void;
|
|
34
|
+
/** fired on user-group membership change (hub wires cluster sync here) */
|
|
35
|
+
onUserGroupChange?: (name: string, members: ReadonlySet<string>) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createGroupManager<B extends Bindings = DefaultBindings>(opts: GroupManagerOptions<B>): GroupManager<B> {
|
|
39
|
+
const userGroupsMap = new Map<string, Set<string>>();
|
|
40
|
+
|
|
41
|
+
const touchUserGroup = (name: string): void => {
|
|
42
|
+
opts.onUserGroupChange?.(name, userGroupsMap.get(name) ?? new Set());
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const userGroupHandle = (name: string): UserGroup<B> => {
|
|
46
|
+
let set = userGroupsMap.get(name);
|
|
47
|
+
if (!set) {
|
|
48
|
+
set = new Set();
|
|
49
|
+
userGroupsMap.set(name, set);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
get name(): string {
|
|
53
|
+
return name;
|
|
54
|
+
},
|
|
55
|
+
add(userId) {
|
|
56
|
+
set!.add(userId);
|
|
57
|
+
touchUserGroup(name);
|
|
58
|
+
},
|
|
59
|
+
remove(userId) {
|
|
60
|
+
const existed = set!.delete(userId);
|
|
61
|
+
if (existed) {
|
|
62
|
+
if (set!.size === 0) userGroupsMap.delete(name);
|
|
63
|
+
touchUserGroup(name);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
has(userId) {
|
|
67
|
+
return set!.has(userId);
|
|
68
|
+
},
|
|
69
|
+
members() {
|
|
70
|
+
return [...set!];
|
|
71
|
+
},
|
|
72
|
+
get size(): number {
|
|
73
|
+
return set!.size;
|
|
74
|
+
},
|
|
75
|
+
emit(event, payload) {
|
|
76
|
+
const snapshot = [...set!];
|
|
77
|
+
for (const userId of snapshot) opts.emitToUser(userId, event, payload);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const clientGroupHandle = (name: string): ClientGroup<B> => {
|
|
83
|
+
const socket = (clientId: string): ServerWebSocket<WsData> | undefined => opts.state.clients.get(clientId);
|
|
84
|
+
return {
|
|
85
|
+
get name(): string {
|
|
86
|
+
return name;
|
|
87
|
+
},
|
|
88
|
+
add(clientId) {
|
|
89
|
+
const ws = socket(clientId);
|
|
90
|
+
if (ws) joinGroup(opts.state, ws, name);
|
|
91
|
+
},
|
|
92
|
+
remove(clientId) {
|
|
93
|
+
const ws = socket(clientId);
|
|
94
|
+
if (ws) leaveGroup(opts.state, ws, name);
|
|
95
|
+
},
|
|
96
|
+
has(clientId) {
|
|
97
|
+
const ws = socket(clientId);
|
|
98
|
+
return ws !== undefined && ws.data.groups.has(name);
|
|
99
|
+
},
|
|
100
|
+
members() {
|
|
101
|
+
return stateGroupMembers(opts.state, name);
|
|
102
|
+
},
|
|
103
|
+
get size(): number {
|
|
104
|
+
return stateGroupMembers(opts.state, name).length;
|
|
105
|
+
},
|
|
106
|
+
emit(event, payload) {
|
|
107
|
+
opts.emitToGroup(name, event, payload);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
clientGroup: clientGroupHandle,
|
|
114
|
+
clientGroups: () => activeGroups(opts.state),
|
|
115
|
+
userGroup: userGroupHandle,
|
|
116
|
+
userGroups: () => [...userGroupsMap.keys()],
|
|
117
|
+
};
|
|
118
|
+
}
|