@ignex/nova 0.1.5 → 0.1.7
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/package.json +1 -1
- package/public/events.ts +1 -0
- package/src/core/client-state.ts +11 -0
- package/src/core/client-wire.ts +41 -0
- package/src/core/client.ts +13 -2
- package/src/events/emit.ts +7 -1
- package/src/events/global.ts +12 -0
- package/src/events/hub/context-factory.ts +2 -0
- package/src/events/hub/index.ts +8 -0
- package/src/events/index.ts +1 -0
- package/src/events/types/context.ts +2 -0
- package/src/events/types/emit-target.ts +4 -1
- package/src/events/types/hub.ts +5 -0
package/package.json
CHANGED
package/public/events.ts
CHANGED
package/src/core/client-state.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface IgnClientOptions<B extends Bindings = DefaultBindings> {
|
|
|
32
32
|
heartbeatMs?: number;
|
|
33
33
|
/** miss this many heartbeats before assuming the connection is dead, default 2 */
|
|
34
34
|
heartbeatMisses?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Buffer app-event `send()` calls made before the socket is open and flush
|
|
37
|
+
* them automatically on connect (default true). This lets new users write
|
|
38
|
+
* `client.send(...)` right after `createClient(...)` — even before
|
|
39
|
+
* `connect()` — without waiting for the `connected` status. Set `false` to
|
|
40
|
+
* keep the strict behaviour of throwing `"ignex: client is not connected"`.
|
|
41
|
+
*/
|
|
42
|
+
queueSends?: boolean;
|
|
35
43
|
/**
|
|
36
44
|
* Gap-free delivery (requires the server started with `resume`): track the
|
|
37
45
|
* server's per-connection delivery seqs, detect gaps, and automatically
|
|
@@ -77,6 +85,8 @@ export interface ClientState {
|
|
|
77
85
|
pendingFrom: number;
|
|
78
86
|
/** in-flight resume request flag (throttles re-asks) */
|
|
79
87
|
resumeInFlight: boolean;
|
|
88
|
+
/** app frames queued while the socket was not open (flushed on open) */
|
|
89
|
+
pendingSends: Uint8Array[];
|
|
80
90
|
/** force-flush timer for an unfillable gap */
|
|
81
91
|
gapTimer: ReturnType<typeof setTimeout> | null;
|
|
82
92
|
/** request/response: correlation id → pending call */
|
|
@@ -118,6 +128,7 @@ export function createClientState<B extends Bindings = DefaultBindings>(
|
|
|
118
128
|
pending: new Map(),
|
|
119
129
|
pendingFrom: 0,
|
|
120
130
|
resumeInFlight: false,
|
|
131
|
+
pendingSends: [],
|
|
121
132
|
gapTimer: null,
|
|
122
133
|
rpcPending: new Map(),
|
|
123
134
|
requestTimeoutMs: opts.requestTimeoutMs ?? 10_000,
|
package/src/core/client-wire.ts
CHANGED
|
@@ -29,6 +29,47 @@ export function sendControl<K extends ControlEventName>(
|
|
|
29
29
|
sendFrame(state, state.bindings.encodeFrame(name, payload));
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Send an encoded APP frame, queueing it until the socket is open.
|
|
34
|
+
*
|
|
35
|
+
* New-user friendly: `client.send(...)` immediately after `createClient(...)`
|
|
36
|
+
* (even before `connect()`) buffers the frame and flushes it once the socket
|
|
37
|
+
* opens — callers don't have to wait for the `connected` status. When
|
|
38
|
+
* `opts.queueSends` is `false` this throws `"ignex: client is not connected"`
|
|
39
|
+
* (the previous strict behaviour); a closed client always throws.
|
|
40
|
+
*/
|
|
41
|
+
export function sendFrameQueued(state: ClientState, frame: Uint8Array): void {
|
|
42
|
+
const ws = state.ws;
|
|
43
|
+
if (ws !== null && ws.readyState === WebSocket.OPEN) {
|
|
44
|
+
ws.send(frame as Uint8Array<ArrayBuffer>);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (state.closed) throw new Error("ignex: client is closed");
|
|
48
|
+
if (state.opts.queueSends === false) throw new Error("ignex: client is not connected");
|
|
49
|
+
state.pendingSends.push(frame);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Send every queued app frame in order. Called once the socket is open (after
|
|
54
|
+
* the `hello` + reconnect-subscribe control frames, which must precede app
|
|
55
|
+
* traffic). If the socket drops mid-flush the remainder stays queued.
|
|
56
|
+
*/
|
|
57
|
+
export function flushPendingSends(state: ClientState): void {
|
|
58
|
+
if (state.pendingSends.length === 0) return;
|
|
59
|
+
const ws = state.ws;
|
|
60
|
+
if (ws === null || ws.readyState !== WebSocket.OPEN) return;
|
|
61
|
+
const q = state.pendingSends;
|
|
62
|
+
state.pendingSends = [];
|
|
63
|
+
for (let i = 0; i < q.length; i++) {
|
|
64
|
+
const w = state.ws;
|
|
65
|
+
if (w !== ws || w.readyState !== WebSocket.OPEN) {
|
|
66
|
+
state.pendingSends = q.slice(i); // connection changed — put the rest back
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
w.send(q[i]! as Uint8Array<ArrayBuffer>);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
32
73
|
export function emitError(state: ClientState, err: Error): void {
|
|
33
74
|
for (const cb of state.errorCbs) cb(err);
|
|
34
75
|
}
|
package/src/core/client.ts
CHANGED
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../bindings/types";
|
|
14
14
|
import { createClientState, setStatus, type ClientState, type ClientStatus, type IgnClientOptions } from "./client-state";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
handleMessage,
|
|
17
|
+
emitError,
|
|
18
|
+
sendControl,
|
|
19
|
+
sendFrameQueued,
|
|
20
|
+
flushPendingSends,
|
|
21
|
+
flushPending,
|
|
22
|
+
} from "./client-wire";
|
|
16
23
|
import { startHeartbeat, stopHeartbeat } from "./client-heartbeat";
|
|
17
24
|
import { scheduleReconnect } from "./client-reconnect";
|
|
18
25
|
import { failAllPending, createRpcRequest } from "./client-rpc";
|
|
@@ -107,6 +114,9 @@ export function createClient<B extends Bindings = DefaultBindings>(
|
|
|
107
114
|
});
|
|
108
115
|
// re-subscribe topics from before the disconnect (server cleared them)
|
|
109
116
|
for (const t of state.subscribedTopics) sendControl(state, "subscribe", { topic: t });
|
|
117
|
+
// deliver app events that were sent before the socket was open (send()
|
|
118
|
+
// queues by default — no need to wait for the connected status)
|
|
119
|
+
flushPendingSends(state);
|
|
110
120
|
startHeartbeat(state);
|
|
111
121
|
};
|
|
112
122
|
ws.onmessage = (ev) => {
|
|
@@ -204,13 +214,14 @@ export function createClient<B extends Bindings = DefaultBindings>(
|
|
|
204
214
|
}
|
|
205
215
|
state.pending.clear();
|
|
206
216
|
state.pendingFrom = 0;
|
|
217
|
+
state.pendingSends = []; // never delivered — drop the queued app frames
|
|
207
218
|
failAllPending(state, new Error("ignex: client closed"));
|
|
208
219
|
state.ws?.close();
|
|
209
220
|
state.ws = null;
|
|
210
221
|
setStatus(state, "closed");
|
|
211
222
|
},
|
|
212
223
|
send(name, payload) {
|
|
213
|
-
|
|
224
|
+
sendFrameQueued(state, state.bindings.encodeFrame(name, payload));
|
|
214
225
|
},
|
|
215
226
|
request(name, payload, opts) {
|
|
216
227
|
return createRpcRequest(
|
package/src/events/emit.ts
CHANGED
|
@@ -146,7 +146,13 @@ export function createEmitter(opts: EmitterOptions): EmitEngine {
|
|
|
146
146
|
if (cluster) {
|
|
147
147
|
const msgId = crypto.randomUUID();
|
|
148
148
|
const traceId = parentTraceId ?? "";
|
|
149
|
-
|
|
149
|
+
// `user … anywhere: true` forces the full-mesh wildcard publish instead
|
|
150
|
+
// of presence routing — the user is reached on EVERY instance/service in
|
|
151
|
+
// the mesh, regardless of where presence thinks they are.
|
|
152
|
+
const routeable =
|
|
153
|
+
target.type === "client" ||
|
|
154
|
+
(target.type === "user" && target.anywhere !== true);
|
|
155
|
+
if (opts.routeInstances !== undefined && routeable) {
|
|
150
156
|
// ROUTED targeted delivery: only the owning instances receive it
|
|
151
157
|
const instances = opts.routeInstances(target);
|
|
152
158
|
if (instances !== null) {
|
package/src/events/global.ts
CHANGED
|
@@ -68,6 +68,18 @@ export function emitToUser<K extends EventName>(userId: string, name: K, payload
|
|
|
68
68
|
requireHub().emitToUser(userId, name as never, payload as never);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Deliver to the user on EVERY instance/service in the cluster mesh (full
|
|
73
|
+
* mesh, no presence routing) — reaches the user wherever they are connected.
|
|
74
|
+
*/
|
|
75
|
+
export function emitToUserAnywhere<K extends EventName>(
|
|
76
|
+
userId: string,
|
|
77
|
+
name: K,
|
|
78
|
+
payload: Events[K],
|
|
79
|
+
): void {
|
|
80
|
+
requireHub().emitToUserAnywhere(userId, name as never, payload as never);
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
export function emitToClient<K extends EventName>(
|
|
72
84
|
clientId: string,
|
|
73
85
|
name: K,
|
|
@@ -38,6 +38,8 @@ export function createContextFactory<B extends Bindings>(deps: {
|
|
|
38
38
|
emit: (name, payload, target) => hub.emit(name as never, payload as never, target),
|
|
39
39
|
emitToGroup: (group, name, payload) => hub.emitToGroup(group, name as never, payload as never),
|
|
40
40
|
emitToUser: (userId, name, payload) => hub.emitToUser(userId, name as never, payload as never),
|
|
41
|
+
emitToUserAnywhere: (userId, name, payload) =>
|
|
42
|
+
hub.emitToUserAnywhere(userId, name as never, payload as never),
|
|
41
43
|
emitToClient: (clientId, name, payload) =>
|
|
42
44
|
hub.emitToClient(clientId, name as never, payload as never),
|
|
43
45
|
emitToTopic: (topic, name, payload) => hub.emitToTopic(topic, name as never, payload as never),
|
package/src/events/hub/index.ts
CHANGED
|
@@ -364,6 +364,14 @@ export function createEventsHub<B extends Bindings = DefaultBindings>(
|
|
|
364
364
|
emitToUser(userId, name, payload) {
|
|
365
365
|
api.emit(name, payload, { type: "user", userId });
|
|
366
366
|
},
|
|
367
|
+
/**
|
|
368
|
+
* Deliver to the user on EVERY instance/service in the cluster mesh — an
|
|
369
|
+
* explicit full-mesh emit (no presence routing). Use when the user may be
|
|
370
|
+
* connected to any service sharing the cluster transport.
|
|
371
|
+
*/
|
|
372
|
+
emitToUserAnywhere(userId, name, payload) {
|
|
373
|
+
api.emit(name, payload, { type: "user", userId, anywhere: true });
|
|
374
|
+
},
|
|
367
375
|
emitToClient(clientId, name, payload) {
|
|
368
376
|
api.emit(name, payload, { type: "client", clientId });
|
|
369
377
|
},
|
package/src/events/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface EventContext<B extends Bindings = DefaultBindings> {
|
|
|
33
33
|
emit<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K], target?: EmitTarget): void;
|
|
34
34
|
emitToGroup<K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]): void;
|
|
35
35
|
emitToUser<K extends EventNameOf<B>>(userId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
36
|
+
/** Deliver to the user on every instance/service in the cluster mesh. */
|
|
37
|
+
emitToUserAnywhere<K extends EventNameOf<B>>(userId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
36
38
|
emitToClient<K extends EventNameOf<B>>(clientId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
37
39
|
emitToTopic<K extends EventNameOf<B>>(topic: string, name: K, payload: EventsOf<B>[K]): void;
|
|
38
40
|
}
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* - `{ type: "topic", topic }` — subscribers of a topic (rooms + replay).
|
|
14
14
|
* - `{ type: "group", group }` — members of a server-side group.
|
|
15
15
|
* - `{ type: "user", userId }` — every socket acting on behalf of `userId`.
|
|
16
|
+
* - `{ type: "user", userId, anywhere: true }` — same, but ALWAYS fanned out
|
|
17
|
+
* to every instance/service in the cluster mesh (ignores presence routing), so
|
|
18
|
+
* the user is reached no matter which instance holds their socket.
|
|
16
19
|
* - `{ type: "client", clientId }` — one specific connection.
|
|
17
20
|
*
|
|
18
21
|
* Local delivery is synchronous and allocation-free (the transport scratch +
|
|
@@ -23,7 +26,7 @@ export type EmitTarget =
|
|
|
23
26
|
| { type: "broadcast" }
|
|
24
27
|
| { type: "topic"; topic: string }
|
|
25
28
|
| { type: "group"; group: string }
|
|
26
|
-
| { type: "user"; userId: string }
|
|
29
|
+
| { type: "user"; userId: string; anywhere?: true }
|
|
27
30
|
| { type: "client"; clientId: string };
|
|
28
31
|
|
|
29
32
|
export type EmitTargetKind = EmitTarget["type"];
|
package/src/events/types/hub.ts
CHANGED
|
@@ -45,6 +45,11 @@ export interface EventsHub<B extends Bindings = DefaultBindings> {
|
|
|
45
45
|
emitToTopic<K extends EventNameOf<B>>(topic: string, name: K, payload: EventsOf<B>[K]): void;
|
|
46
46
|
emitToGroup<K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]): void;
|
|
47
47
|
emitToUser<K extends EventNameOf<B>>(userId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
48
|
+
/**
|
|
49
|
+
* Deliver to the user on EVERY instance/service in the cluster mesh (full
|
|
50
|
+
* mesh, no presence routing) — reaches the user wherever they are.
|
|
51
|
+
*/
|
|
52
|
+
emitToUserAnywhere<K extends EventNameOf<B>>(userId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
48
53
|
emitToClient<K extends EventNameOf<B>>(clientId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
49
54
|
|
|
50
55
|
// ── client records ("who is connected, on whose behalf") ──────────────
|