@ignex/nova 0.1.5 → 0.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignex/nova",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "TypeBox-driven FlatBuffer transport: Rust FFI serializer + Bun WebSocket + typed pub/sub API for server and FE.",
6
6
  "license": "MIT",
@@ -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,
@@ -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
  }
@@ -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 { handleMessage, emitError, sendControl, sendFrame, flushPending } from "./client-wire";
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
- sendFrame(state, state.bindings.encodeFrame(name, payload));
224
+ sendFrameQueued(state, state.bindings.encodeFrame(name, payload));
214
225
  },
215
226
  request(name, payload, opts) {
216
227
  return createRpcRequest(