@ignex/nova 0.1.1 → 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 +136 -33
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +292 -0
- package/docs/architecture.md +101 -28
- package/docs/events.md +252 -0
- package/docs/generic-bindings.md +207 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +74 -20
- package/index.ts +75 -27
- package/package.json +13 -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 +510 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +52 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +377 -17
- 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/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/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +28 -0
- package/src/codegen/direct-gen.ts +564 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +246 -0
- package/src/codegen/rust-glue-gen.ts +552 -0
- package/src/codegen/schema-model.ts +363 -0
- package/src/codegen/ts-ser-gen.ts +230 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +67 -5
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +63 -8
- package/src/core/client-wire.ts +148 -15
- package/src/core/client.ts +105 -31
- package/src/core/groups.ts +8 -0
- package/src/core/metrics.ts +42 -21
- package/src/core/outbound.ts +62 -11
- 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 +144 -12
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/server/index.ts +360 -0
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +124 -14
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +174 -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/data.ts +38 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +173 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- 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/index.ts +61 -0
- package/src/events/queue.ts +123 -0
- package/src/events/registry.ts +214 -0
- 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 +148 -60
- package/src/generated/fbs/backend.fbs +24 -1
- package/src/generated/registry.ts +94 -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 +110 -17
- package/src/generated/wire-registry.json +7 -2
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +49 -2
- package/src/server.ts +7 -3
- package/src/transport/transport.ts +200 -79
- package/src/bridge/nats.ts +0 -269
- package/src/core/server.ts +0 -294
- package/src/transport/stats.ts +0 -44
package/src/core/auth.ts
CHANGED
|
@@ -7,9 +7,64 @@
|
|
|
7
7
|
* `authenticate` may return a `ClientMeta` (`{id, groups, meta}`) to pin the
|
|
8
8
|
* client's identity for targeted sends / grouping; otherwise a UUID is
|
|
9
9
|
* auto-assigned. A duplicate explicit id rejects the new connection (409).
|
|
10
|
+
*
|
|
11
|
+
* Literal bearer tokens are compared in CONSTANT TIME (no length or prefix
|
|
12
|
+
* oracle for a brute-forcing caller). The same gate protects the HTTP admin
|
|
13
|
+
* surface (`GET /clients`) via `authorizeHttp` — introspection endpoints must
|
|
14
|
+
* never be wider than the WebSocket they introspect.
|
|
10
15
|
*/
|
|
16
|
+
import { timingSafeEqual } from "node:crypto";
|
|
11
17
|
import type { ClientMeta, ServerState, WsData } from "./state";
|
|
12
18
|
|
|
19
|
+
/** Constant-time string equality (UTF-8 compared; length-safe). */
|
|
20
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
21
|
+
const ab = Buffer.from(a, "utf8");
|
|
22
|
+
const bb = Buffer.from(b, "utf8");
|
|
23
|
+
if (ab.byteLength !== bb.byteLength) {
|
|
24
|
+
// burn comparable time so mismatched-length guesses aren't cheaper
|
|
25
|
+
timingSafeEqual(ab, ab);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return timingSafeEqual(ab, bb);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Evaluate the server's token gate against a raw Bearer value. */
|
|
32
|
+
export function tokenOk(state: ServerState, bearer: string): boolean {
|
|
33
|
+
if (state.token === undefined) return true;
|
|
34
|
+
return typeof state.token === "function" ? state.token(bearer) : safeEqual(bearer, state.token);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bearerOf(req: Request): string {
|
|
38
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
39
|
+
return auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* HTTP admin gate (defense-in-depth for non-WebSocket routes): when a `token`
|
|
44
|
+
* is configured the request MUST carry a valid Bearer; otherwise, when an
|
|
45
|
+
* `authenticate` hook exists it must accept the request. Unauthenticated
|
|
46
|
+
* servers stay unauthenticated (documented dev behavior).
|
|
47
|
+
*/
|
|
48
|
+
export async function authorizeHttp(
|
|
49
|
+
state: ServerState,
|
|
50
|
+
req: Request,
|
|
51
|
+
): Promise<Response | undefined> {
|
|
52
|
+
if (state.token !== undefined || state.authenticate !== undefined) {
|
|
53
|
+
if (!tokenOk(state, bearerOf(req))) return new Response("unauthorized", { status: 401 });
|
|
54
|
+
if (state.token === undefined && state.authenticate !== undefined) {
|
|
55
|
+
// a failing auth backend must deny, not blow up the serve loop
|
|
56
|
+
let allowed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
allowed = await state.authenticate(req);
|
|
59
|
+
} catch {
|
|
60
|
+
return new Response("unauthorized", { status: 401 });
|
|
61
|
+
}
|
|
62
|
+
if (!allowed) return new Response("unauthorized", { status: 401 });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
13
68
|
export async function checkUpgrade(
|
|
14
69
|
state: ServerState,
|
|
15
70
|
req: Request,
|
|
@@ -25,14 +80,19 @@ export async function checkUpgrade(
|
|
|
25
80
|
}
|
|
26
81
|
}
|
|
27
82
|
if (state.token) {
|
|
28
|
-
const
|
|
29
|
-
const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
|
|
30
|
-
const ok = typeof state.token === "function" ? state.token(bearer) : bearer === state.token;
|
|
83
|
+
const ok = tokenOk(state, bearerOf(req));
|
|
31
84
|
if (!ok) return new Response("unauthorized", { status: 401 });
|
|
32
85
|
}
|
|
33
86
|
let authMeta: ClientMeta | undefined;
|
|
34
87
|
if (state.authenticate) {
|
|
35
|
-
|
|
88
|
+
// a throwing / rejecting hook denies the upgrade cleanly (401) instead of
|
|
89
|
+
// surfacing an unhandled error through Bun.serve's fetch loop
|
|
90
|
+
let res: Awaited<ReturnType<typeof state.authenticate>>;
|
|
91
|
+
try {
|
|
92
|
+
res = await state.authenticate(req);
|
|
93
|
+
} catch {
|
|
94
|
+
return new Response("unauthorized", { status: 401 });
|
|
95
|
+
}
|
|
36
96
|
if (!res) return new Response("unauthorized", { status: 401 });
|
|
37
97
|
if (typeof res === "object") authMeta = res;
|
|
38
98
|
}
|
|
@@ -44,10 +104,12 @@ export async function checkUpgrade(
|
|
|
44
104
|
}
|
|
45
105
|
const data: WsData = {
|
|
46
106
|
lastSeq: 0,
|
|
107
|
+
sendSeq: 1,
|
|
47
108
|
topics: new Set(),
|
|
48
109
|
groups: new Set(authMeta?.groups ?? []),
|
|
49
110
|
id,
|
|
50
|
-
|
|
111
|
+
...(authMeta?.userId !== undefined ? { userId: authMeta.userId } : {}),
|
|
112
|
+
...(authMeta?.meta !== undefined ? { meta: authMeta.meta } : {}),
|
|
51
113
|
connectedAt: Date.now(),
|
|
52
114
|
};
|
|
53
115
|
// 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 };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client RPC — the request/response half of `client.request(name, payload)`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the client composition root so the wire-level plumbing
|
|
5
|
+
* (base64 framing, pending-call registry, timeout bookkeeping) lives in one
|
|
6
|
+
* small module. The reply side (`rpcResult` handling) is in `client-wire.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import type { Bindings } from "../bindings/types";
|
|
9
|
+
import { sendControl } from "./client-wire";
|
|
10
|
+
import type { ClientState } from "./client-state";
|
|
11
|
+
|
|
12
|
+
/** Encode bytes as base64 without pulling in a Buffer dependency (browser-safe). */
|
|
13
|
+
export function bytesToB64(bytes: Uint8Array): string {
|
|
14
|
+
let s = "";
|
|
15
|
+
for (let i = 0; i < bytes.byteLength; i++) s += String.fromCharCode(bytes[i]!);
|
|
16
|
+
return btoa(s);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reject every pending rpc call (client close / socket loss). */
|
|
20
|
+
export function failAllPending(
|
|
21
|
+
state: ClientState,
|
|
22
|
+
err: Error,
|
|
23
|
+
): void {
|
|
24
|
+
for (const [id, call] of state.rpcPending) {
|
|
25
|
+
clearTimeout(call.timer);
|
|
26
|
+
call.reject(err);
|
|
27
|
+
state.rpcPending.delete(id);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run a request/response round-trip: encode `payload` with the event's own
|
|
33
|
+
* schema, send it inside an `rpcCall` control frame, and settle with
|
|
34
|
+
* `{ payload }` from the responder. Rejects on timeout, transport loss, or a
|
|
35
|
+
* server-side error — never leaves a pending entry behind.
|
|
36
|
+
*/
|
|
37
|
+
export function createRpcRequest<B extends Bindings>(
|
|
38
|
+
state: ClientState,
|
|
39
|
+
name: string,
|
|
40
|
+
payload: unknown,
|
|
41
|
+
opts: { readonly timeoutMs?: number } | undefined,
|
|
42
|
+
bindings: B,
|
|
43
|
+
): Promise<unknown> {
|
|
44
|
+
const ws = state.ws;
|
|
45
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
46
|
+
return Promise.reject(new Error("ignex: client is not connected"));
|
|
47
|
+
|
|
48
|
+
const id = crypto.randomUUID();
|
|
49
|
+
const inner = bindings.encodeFrame(name, payload);
|
|
50
|
+
const b64 = bytesToB64(inner);
|
|
51
|
+
const timeoutMs = opts?.timeoutMs ?? state.requestTimeoutMs;
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
state.rpcPending.delete(id);
|
|
56
|
+
reject(new Error(`ignex rpc "${name}" timed out after ${timeoutMs}ms`));
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
|
|
59
|
+
// registered BEFORE the send so a fast reply can never race the insert
|
|
60
|
+
state.rpcPending.set(id, {
|
|
61
|
+
name,
|
|
62
|
+
// the wire resolves with the bare payload — wrap it into RpcResult here
|
|
63
|
+
resolve: (out) => resolve({ payload: out }),
|
|
64
|
+
reject,
|
|
65
|
+
timer,
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
sendControl(state, "rpcCall", { id, name, payloadB64: b64 });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
state.rpcPending.delete(id);
|
|
72
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
package/src/core/client-state.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* explicit state object the client action functions read/mutate. The option /
|
|
4
4
|
* status types are the PUBLIC surface and are re-exported by `public/client.ts`.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
import { defaultBindings } from "../bindings/default";
|
|
8
|
+
import type { Bindings, DefaultBindings } from "../bindings/types";
|
|
7
9
|
|
|
8
10
|
export type ClientStatus = "connecting" | "connected" | "disconnected" | "reconnecting" | "closed";
|
|
9
11
|
|
|
@@ -16,23 +18,40 @@ export interface IgnReconnectOptions {
|
|
|
16
18
|
jitter?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export interface IgnClientOptions {
|
|
21
|
+
export interface IgnClientOptions<B extends Bindings = DefaultBindings> {
|
|
22
|
+
/**
|
|
23
|
+
* The wire stack (event ids, decoders, encoders). Defaults to the built-in
|
|
24
|
+
* registry; pass your own (from `generateBindings` + `assembleBindings`) to
|
|
25
|
+
* speak YOUR schema. When provided, the client API (`on` / `send` / ...) is
|
|
26
|
+
* typed against your `Events`.
|
|
27
|
+
*/
|
|
28
|
+
bindings?: B;
|
|
20
29
|
/** auto-reconnect on unexpected close, default false (boolean or options) */
|
|
21
30
|
reconnect?: boolean | IgnReconnectOptions;
|
|
22
31
|
/** app-level ping interval in ms (0 disables), default 15000 */
|
|
23
32
|
heartbeatMs?: number;
|
|
24
33
|
/** miss this many heartbeats before assuming the connection is dead, default 2 */
|
|
25
34
|
heartbeatMisses?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Gap-free delivery (requires the server started with `resume`): track the
|
|
37
|
+
* server's per-connection delivery seqs, detect gaps, and automatically
|
|
38
|
+
* request re-delivery via the `resume` control frame. Buffered out-of-order
|
|
39
|
+
* frames are bounded by `maxPending`. Default: true (no-op against servers
|
|
40
|
+
* that don't stamp seqs).
|
|
41
|
+
*/
|
|
42
|
+
resume?: boolean | { maxPending?: number; timeoutMs?: number };
|
|
43
|
+
/** request/response default timeout (ms), default 10_000 */
|
|
44
|
+
requestTimeoutMs?: number;
|
|
26
45
|
}
|
|
27
46
|
|
|
28
|
-
type Handler<K extends EventName> = (payload: Events[K]) => void;
|
|
29
|
-
|
|
30
47
|
export interface ClientState {
|
|
31
48
|
url: string;
|
|
32
|
-
opts: IgnClientOptions
|
|
49
|
+
opts: IgnClientOptions<Bindings>;
|
|
50
|
+
/** the wire stack this client speaks (ids / decoders / encoders). */
|
|
51
|
+
bindings: Bindings;
|
|
33
52
|
ws: WebSocket | null;
|
|
34
|
-
handlers: Map<
|
|
35
|
-
anyHandlers: Set<(name:
|
|
53
|
+
handlers: Map<string, Set<(payload: unknown) => void>>;
|
|
54
|
+
anyHandlers: Set<(name: string, payload: unknown) => void>;
|
|
36
55
|
errorCbs: Set<(err: Error) => void>;
|
|
37
56
|
statusCbs: Set<(status: ClientStatus) => void>;
|
|
38
57
|
closed: boolean;
|
|
@@ -46,12 +65,34 @@ export interface ClientState {
|
|
|
46
65
|
clientId: string;
|
|
47
66
|
/** server-side groups this client belongs to (from `welcome`; [] until known) */
|
|
48
67
|
groups: string[];
|
|
68
|
+
|
|
69
|
+
// ── delivery-seq tracking / gap recovery (envelope v2) ──────────────────
|
|
70
|
+
/** resolved resume options (enabled: false when off) */
|
|
71
|
+
resume: { enabled: boolean; maxPending: number; timeoutMs: number };
|
|
72
|
+
/** last CONTIGUOUS delivery seq processed (0 = none yet) */
|
|
73
|
+
rxSeq: number;
|
|
74
|
+
/** frames held out-of-order while a gap is being filled (seq → frame bytes) */
|
|
75
|
+
pending: Map<number, Uint8Array[]>;
|
|
76
|
+
/** seq the pending buffer is waiting to fill from */
|
|
77
|
+
pendingFrom: number;
|
|
78
|
+
/** in-flight resume request flag (throttles re-asks) */
|
|
79
|
+
resumeInFlight: boolean;
|
|
80
|
+
/** force-flush timer for an unfillable gap */
|
|
81
|
+
gapTimer: ReturnType<typeof setTimeout> | null;
|
|
82
|
+
/** request/response: correlation id → pending call */
|
|
83
|
+
rpcPending: Map<string, { resolve: (payload: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout>; name: string }>;
|
|
84
|
+
/** default request timeout (ms) */
|
|
85
|
+
requestTimeoutMs: number;
|
|
49
86
|
}
|
|
50
87
|
|
|
51
|
-
export function createClientState
|
|
88
|
+
export function createClientState<B extends Bindings = DefaultBindings>(
|
|
89
|
+
url: string,
|
|
90
|
+
opts: IgnClientOptions<B> = {},
|
|
91
|
+
): ClientState {
|
|
52
92
|
return {
|
|
53
93
|
url,
|
|
54
94
|
opts,
|
|
95
|
+
bindings: opts.bindings ?? defaultBindings,
|
|
55
96
|
ws: null,
|
|
56
97
|
handlers: new Map(),
|
|
57
98
|
anyHandlers: new Set(),
|
|
@@ -66,6 +107,20 @@ export function createClientState(url: string, opts: IgnClientOptions = {}): Cli
|
|
|
66
107
|
lastPong: 0,
|
|
67
108
|
clientId: "",
|
|
68
109
|
groups: [],
|
|
110
|
+
resume: {
|
|
111
|
+
enabled: opts.resume !== false,
|
|
112
|
+
maxPending:
|
|
113
|
+
(typeof opts.resume === "object" ? opts.resume.maxPending : undefined) ?? 1024,
|
|
114
|
+
timeoutMs:
|
|
115
|
+
(typeof opts.resume === "object" ? opts.resume.timeoutMs : undefined) ?? 5_000,
|
|
116
|
+
},
|
|
117
|
+
rxSeq: 0,
|
|
118
|
+
pending: new Map(),
|
|
119
|
+
pendingFrom: 0,
|
|
120
|
+
resumeInFlight: false,
|
|
121
|
+
gapTimer: null,
|
|
122
|
+
rpcPending: new Map(),
|
|
123
|
+
requestTimeoutMs: opts.requestTimeoutMs ?? 10_000,
|
|
69
124
|
};
|
|
70
125
|
}
|
|
71
126
|
|
package/src/core/client-wire.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client wire handling — outbound frame sends + inbound decode/dispatch.
|
|
3
3
|
* `handleMessage` decodes the envelope, filters control frames, and fans app
|
|
4
|
-
* events out to the registered handlers.
|
|
4
|
+
* events out to the registered handlers. All decode/encode goes through
|
|
5
|
+
* `state.bindings`, so a client speaks whatever wire stack it was given.
|
|
6
|
+
*
|
|
7
|
+
* With envelope v2 (delivery seqs) and `resume` enabled, app frames pass
|
|
8
|
+
* through a small ordering gate: contiguous seqs deliver immediately; a GAP
|
|
9
|
+
* buffers out-of-order frames and asks the server to re-send the hole
|
|
10
|
+
* (`resume` control frame); replayed hole-fills keep their original seqs so
|
|
11
|
+
* delivery stays in-order and duplicate-free.
|
|
5
12
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import { encodeEventFrame } from "../generated/ts-ser";
|
|
8
|
-
import type { ControlEventName, ControlEvents, EventName } from "../schema";
|
|
13
|
+
import type { ControlEventName, ControlEvents } from "../schema";
|
|
9
14
|
import type { ClientState } from "./client-state";
|
|
10
15
|
|
|
11
16
|
/** Send an encoded frame, if the socket is open. */
|
|
@@ -21,21 +26,118 @@ export function sendControl<K extends ControlEventName>(
|
|
|
21
26
|
name: K,
|
|
22
27
|
payload: ControlEvents[K],
|
|
23
28
|
): void {
|
|
24
|
-
sendFrame(state,
|
|
29
|
+
sendFrame(state, state.bindings.encodeFrame(name, payload));
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function emitError(state: ClientState, err: Error): void {
|
|
28
33
|
for (const cb of state.errorCbs) cb(err);
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** Delivery header offsets derived from the bindings' header length. */
|
|
37
|
+
const flagsAt = (state: ClientState): number => state.bindings.wireHeaderLen - 9;
|
|
38
|
+
const seqAt = (state: ClientState): number => state.bindings.wireHeaderLen - 8;
|
|
39
|
+
|
|
40
|
+
function readDeliverySeq(state: ClientState, bytes: Uint8Array): number | null {
|
|
41
|
+
const len = state.bindings.wireHeaderLen;
|
|
42
|
+
if (len < 14 || bytes.byteLength < len) return null;
|
|
43
|
+
if ((bytes[flagsAt(state)]! & 1) === 0) return null;
|
|
44
|
+
return Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(seqAt(state), true));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Ask the server to re-send everything after `state.rxSeq` (throttled). */
|
|
48
|
+
export function requestResume(state: ClientState): void {
|
|
49
|
+
if (!state.resume.enabled || state.resumeInFlight) return;
|
|
50
|
+
try {
|
|
51
|
+
sendControl(state, "resume", { lastSeq: state.rxSeq });
|
|
52
|
+
state.resumeInFlight = true;
|
|
53
|
+
} catch {
|
|
54
|
+
// not connected — reconnect flow will carry lastSeq in `hello`
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Force-drain the pending buffer after an unfillable gap (accept loss). */
|
|
59
|
+
export function flushPending(state: ClientState): void {
|
|
60
|
+
if (state.pending.size === 0) return;
|
|
61
|
+
const seqs = [...state.pending.keys()].sort((a, b) => a - b);
|
|
62
|
+
for (const s of seqs) {
|
|
63
|
+
for (const f of state.pending.get(s)!) dispatchAppFrame(state, f);
|
|
64
|
+
state.pending.delete(s);
|
|
65
|
+
}
|
|
66
|
+
if (seqs.length > 0 && seqs[seqs.length - 1]! > state.rxSeq) {
|
|
67
|
+
state.rxSeq = seqs[seqs.length - 1]!;
|
|
68
|
+
}
|
|
69
|
+
state.pendingFrom = 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Dispatch one decoded APP frame to handlers (no ordering). */
|
|
73
|
+
function dispatchAppFrame(state: ClientState, bytes: Uint8Array): void {
|
|
74
|
+
const frame = state.bindings.decodeFrame(bytes);
|
|
75
|
+
if (!frame) return;
|
|
76
|
+
const set = state.handlers.get(frame.name);
|
|
77
|
+
if (set) for (const cb of set) cb(frame.payload);
|
|
78
|
+
for (const cb of state.anyHandlers) cb(frame.name, frame.payload);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ordered delivery of one raw app frame. Returns true when the frame was
|
|
83
|
+
* consumed here (contiguous / buffered / replayed); false = not tracking.
|
|
84
|
+
*/
|
|
85
|
+
function orderedDeliver(state: ClientState, bytes: Uint8Array, seq: number): boolean {
|
|
86
|
+
if (!state.resume.enabled) return false;
|
|
87
|
+
if (seq === state.rxSeq + 1) {
|
|
88
|
+
state.rxSeq = seq;
|
|
89
|
+
dispatchAppFrame(state, bytes);
|
|
90
|
+
// drain anything the gap-fill delivered contiguously behind us
|
|
91
|
+
while (state.pending.has(state.rxSeq + 1)) {
|
|
92
|
+
const next = state.rxSeq + 1;
|
|
93
|
+
for (const f of state.pending.get(next)!) dispatchAppFrame(state, f);
|
|
94
|
+
state.pending.delete(next);
|
|
95
|
+
state.rxSeq = next;
|
|
96
|
+
}
|
|
97
|
+
if (state.pending.size === 0) state.pendingFrom = 0;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (seq <= state.rxSeq) {
|
|
101
|
+
// Duplicate of an already-dispatched frame. Every seq ≤ rxSeq has been
|
|
102
|
+
// delivered (contiguously or via the pending drain), so a late replay of
|
|
103
|
+
// it must NOT be dispatched again. This happens when a `resume`/`hello`
|
|
104
|
+
// replay range overlaps frames the client already received live — e.g.
|
|
105
|
+
// the server processed our `hello { lastSeq }` AFTER the frames were sent
|
|
106
|
+
// (reconnect race), or a gap-fill replay re-sends frames that were already
|
|
107
|
+
// drained out of `pending`. Redelivering here would break the duplicate-
|
|
108
|
+
// free contract, so we consume and drop.
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// seq > rxSeq + 1 → GAP: buffer, then ask the server to fill it
|
|
112
|
+
let list = state.pending.get(seq);
|
|
113
|
+
if (!list) {
|
|
114
|
+
if (state.pending.size >= state.resume.maxPending) {
|
|
115
|
+
flushPending(state); // accept loss rather than grow unbounded
|
|
116
|
+
}
|
|
117
|
+
list = [];
|
|
118
|
+
state.pending.set(seq, list);
|
|
119
|
+
}
|
|
120
|
+
list.push(bytes);
|
|
121
|
+
if (state.pendingFrom === 0) state.pendingFrom = state.rxSeq;
|
|
122
|
+
requestResume(state);
|
|
123
|
+
if (state.gapTimer === null) {
|
|
124
|
+
state.gapTimer = setTimeout(() => {
|
|
125
|
+
state.gapTimer = null;
|
|
126
|
+
// server could not fill the hole (resume-miss / offline) — accept loss
|
|
127
|
+
flushPending(state);
|
|
128
|
+
}, state.resume.timeoutMs);
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
31
133
|
export function handleControl(state: ClientState, name: ControlEventName, payload: unknown): void {
|
|
32
134
|
switch (name) {
|
|
33
135
|
case "hello": {
|
|
34
136
|
const p = payload as ControlEvents["hello"];
|
|
35
|
-
if (p.version !==
|
|
137
|
+
if (p.version !== state.bindings.wireVersion) {
|
|
36
138
|
// server speaks a different wire version — refuse + surface
|
|
37
139
|
state.ws?.close(1002, "wire version mismatch");
|
|
38
|
-
emitError(state, new Error(`ignex: server wire version ${p.version} does not match ${
|
|
140
|
+
emitError(state, new Error(`ignex: server wire version ${p.version} does not match ${state.bindings.wireVersion}`));
|
|
39
141
|
}
|
|
40
142
|
break;
|
|
41
143
|
}
|
|
@@ -45,6 +147,37 @@ export function handleControl(state: ClientState, name: ControlEventName, payloa
|
|
|
45
147
|
state.groups = [...p.groups];
|
|
46
148
|
break;
|
|
47
149
|
}
|
|
150
|
+
case "resumed": {
|
|
151
|
+
state.resumeInFlight = false;
|
|
152
|
+
const p = payload as ControlEvents["resumed"];
|
|
153
|
+
if (!p.ok && p.from === 0) {
|
|
154
|
+
// server has nothing after our seq (fresh process / grave evicted) —
|
|
155
|
+
// accept the loss and continue from the buffered frames
|
|
156
|
+
flushPending(state);
|
|
157
|
+
}
|
|
158
|
+
// ok=true: replayed frames follow with their original seqs and slot
|
|
159
|
+
// into the ordering gate automatically.
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "rpcResult": {
|
|
163
|
+
const p = payload as ControlEvents["rpcResult"];
|
|
164
|
+
const call = state.rpcPending.get(p.id);
|
|
165
|
+
if (!call) break;
|
|
166
|
+
state.rpcPending.delete(p.id);
|
|
167
|
+
clearTimeout(call.timer);
|
|
168
|
+
if (!p.ok) {
|
|
169
|
+
call.reject(new Error(`ignex rpc "${call.name}" failed: ${p.err}`));
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const bytes = Uint8Array.from(atob(p.payloadB64), (c) => c.charCodeAt(0));
|
|
174
|
+
const frame = state.bindings.decodeFrame(bytes);
|
|
175
|
+
call.resolve(frame?.payload);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
call.reject(err instanceof Error ? err : new Error("ignex rpc: bad result"));
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
48
181
|
case "pong":
|
|
49
182
|
state.lastPong = Date.now();
|
|
50
183
|
break;
|
|
@@ -56,17 +189,17 @@ export function handleControl(state: ClientState, name: ControlEventName, payloa
|
|
|
56
189
|
export function handleMessage(state: ClientState, data: ArrayBuffer | string): void {
|
|
57
190
|
if (typeof data === "string") return; // ignore text frames
|
|
58
191
|
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : (data as Uint8Array);
|
|
59
|
-
|
|
60
|
-
|
|
192
|
+
// fast envelope check before any decode
|
|
193
|
+
const header = state.bindings.readFrameHeader(bytes);
|
|
194
|
+
if (!header) {
|
|
61
195
|
emitError(state, new Error("ignex: undecodable / version-mismatched frame dropped"));
|
|
62
196
|
return;
|
|
63
197
|
}
|
|
64
|
-
if (isControlId(
|
|
65
|
-
handleControl(state,
|
|
198
|
+
if (state.bindings.isControlId(header.id)) {
|
|
199
|
+
handleControl(state, header.name as ControlEventName, state.bindings.decodePayload(header.id, bytes));
|
|
66
200
|
return;
|
|
67
201
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
for (const cb of state.anyHandlers) cb(name, frame.payload);
|
|
202
|
+
const seq = readDeliverySeq(state, bytes);
|
|
203
|
+
if (seq !== null && orderedDeliver(state, bytes, seq)) return;
|
|
204
|
+
dispatchAppFrame(state, bytes);
|
|
72
205
|
}
|