@nativedesktop/rpc 0.1.0
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 +31 -0
- package/src/backoff.test.ts +87 -0
- package/src/backoff.ts +72 -0
- package/src/client.test.ts +313 -0
- package/src/client.ts +700 -0
- package/src/fake-transport.ts +101 -0
- package/src/index.ts +6 -0
- package/src/react.ts +17 -0
- package/src/status-store.test.ts +46 -0
- package/src/status-store.ts +56 -0
- package/src/transport.test.ts +48 -0
- package/src/transport.ts +142 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Test double for the Transport seam (not exported from index.ts). Mimics
|
|
2
|
+
// the built-in transports' contract exactly: events fire asynchronously
|
|
3
|
+
// never during the factory call, and nothing is delivered after the client
|
|
4
|
+
// calls close().
|
|
5
|
+
|
|
6
|
+
import type { Transport, TransportFactory, TransportHandlers } from "./transport.ts";
|
|
7
|
+
|
|
8
|
+
export interface FakeFrame {
|
|
9
|
+
jsonrpc: "2.0";
|
|
10
|
+
id?: number;
|
|
11
|
+
method?: string;
|
|
12
|
+
params?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class FakeConn {
|
|
16
|
+
readonly sent: string[] = [];
|
|
17
|
+
clientClosed = false;
|
|
18
|
+
#handlers: TransportHandlers;
|
|
19
|
+
#serverClosed = false;
|
|
20
|
+
|
|
21
|
+
constructor(handlers: TransportHandlers) {
|
|
22
|
+
this.#handlers = handlers;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get transport(): Transport {
|
|
26
|
+
return {
|
|
27
|
+
send: (frame) => {
|
|
28
|
+
if (!this.clientClosed && !this.#serverClosed) this.sent.push(frame);
|
|
29
|
+
},
|
|
30
|
+
close: () => {
|
|
31
|
+
this.clientClosed = true;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get closed(): boolean {
|
|
37
|
+
return this.clientClosed || this.#serverClosed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Frames the client sent, parsed. */
|
|
41
|
+
calls(): FakeFrame[] {
|
|
42
|
+
return this.sent.map((s) => JSON.parse(s) as FakeFrame);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lastCall(): FakeFrame | undefined {
|
|
46
|
+
return this.calls().at(-1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
callFor(method: string): FakeFrame | undefined {
|
|
50
|
+
return this.calls().find((c) => c.method === method);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
open(): void {
|
|
54
|
+
if (!this.closed) this.#handlers.onOpen();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
deliver(obj: unknown): void {
|
|
58
|
+
if (!this.closed) this.#handlers.onMessage(JSON.stringify(obj));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
reply(id: number, result: unknown): void {
|
|
62
|
+
this.deliver({ jsonrpc: "2.0", id, result });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
replyError(id: number, code: number, message: string, data?: unknown): void {
|
|
66
|
+
this.deliver({ jsonrpc: "2.0", id, error: { code, message, data } });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
notify(method: string, params: unknown): void {
|
|
70
|
+
this.deliver({ jsonrpc: "2.0", method, params });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Server-side drop (RST/close). Inert after the client already closed. */
|
|
74
|
+
drop(): void {
|
|
75
|
+
if (this.closed) return;
|
|
76
|
+
this.#serverClosed = true;
|
|
77
|
+
this.#handlers.onClose();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FakeTransport {
|
|
82
|
+
factory: TransportFactory;
|
|
83
|
+
conns: FakeConn[];
|
|
84
|
+
latest(): FakeConn;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function fakeTransport(): FakeTransport {
|
|
88
|
+
const conns: FakeConn[] = [];
|
|
89
|
+
return {
|
|
90
|
+
factory: (handlers) => {
|
|
91
|
+
const conn = new FakeConn(handlers);
|
|
92
|
+
conns.push(conn);
|
|
93
|
+
return conn.transport;
|
|
94
|
+
},
|
|
95
|
+
conns,
|
|
96
|
+
latest: () => {
|
|
97
|
+
if (conns.length === 0) throw new Error("no fake connection dialed yet");
|
|
98
|
+
return conns[conns.length - 1]!;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { backoffDelayMs, ConnectionLadder, RECONNECT_BASE_MS, RECONNECT_MAX_MS, STABILITY_WINDOW_MS } from "./backoff.ts";
|
|
2
|
+
export type { LadderOptions } from "./backoff.ts";
|
|
3
|
+
export { socketTransport, webSocketTransport } from "./transport.ts";
|
|
4
|
+
export type { Transport, TransportFactory, TransportHandlers } from "./transport.ts";
|
|
5
|
+
export { RpcClient, RpcError } from "./client.ts";
|
|
6
|
+
export type { RpcClientOptions, RpcContract, RpcState } from "./client.ts";
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// React binding, at its own entry point (`@nativedesktop/rpc/react`) so the
|
|
2
|
+
// core client stays free of a React dependency. Hooks come from
|
|
3
|
+
// @nativedesktop/react, never react directly (dev-react.ts's
|
|
4
|
+
// pinned-dispatcher contract for `nd dev` hot re-eval).
|
|
5
|
+
|
|
6
|
+
import { useMemo, useSyncExternalStore } from "@nativedesktop/react";
|
|
7
|
+
import type { RpcClient, RpcContract } from "./client.ts";
|
|
8
|
+
import { createRpcStatusStore } from "./status-store.ts";
|
|
9
|
+
import type { RpcStatus } from "./status-store.ts";
|
|
10
|
+
|
|
11
|
+
export type { RpcStatus } from "./status-store.ts";
|
|
12
|
+
|
|
13
|
+
/** Subscribes a component to the client's connection status. */
|
|
14
|
+
export function useRpcStatus<C extends RpcContract>(client: RpcClient<C>): RpcStatus {
|
|
15
|
+
const source = useMemo(() => createRpcStatusStore(client), [client]);
|
|
16
|
+
return useSyncExternalStore(source.subscribe, source.get);
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Pins the subscribe-time resync: a transition landing between the store's
|
|
2
|
+
// creation (render) and subscribe (passive effect) fires into an empty
|
|
3
|
+
// handler set, and subscribe must rebuild the snapshot so the
|
|
4
|
+
// useSyncExternalStore post-subscribe re-read can observe it.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { RpcClient } from "./client.ts";
|
|
8
|
+
import type { RpcContract } from "./client.ts";
|
|
9
|
+
import { fakeTransport } from "./fake-transport.ts";
|
|
10
|
+
import { createRpcStatusStore } from "./status-store.ts";
|
|
11
|
+
|
|
12
|
+
describe("createRpcStatusStore", () => {
|
|
13
|
+
test("subscribe resyncs a transition that fired before any subscriber", async () => {
|
|
14
|
+
const ft = fakeTransport();
|
|
15
|
+
const client = new RpcClient<RpcContract>({
|
|
16
|
+
transport: ft.factory,
|
|
17
|
+
handshake: { method: "hello", params: {} },
|
|
18
|
+
});
|
|
19
|
+
const p = client.connect();
|
|
20
|
+
const store = createRpcStatusStore(client); // "renders" while connecting
|
|
21
|
+
expect(store.get().state).toBe("connecting");
|
|
22
|
+
const conn = ft.latest();
|
|
23
|
+
conn.open();
|
|
24
|
+
conn.reply(conn.callFor("hello")!.id!, { ok: true });
|
|
25
|
+
await p; // ready now, with nothing subscribed
|
|
26
|
+
let notified = 0;
|
|
27
|
+
const off = store.subscribe(() => (notified += 1));
|
|
28
|
+
expect(store.get().state).toBe("ready");
|
|
29
|
+
expect(notified).toBe(1);
|
|
30
|
+
off();
|
|
31
|
+
client.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("subscribe with no missed transition leaves the snapshot alone", () => {
|
|
35
|
+
const ft = fakeTransport();
|
|
36
|
+
const client = new RpcClient<RpcContract>({ transport: ft.factory });
|
|
37
|
+
const store = createRpcStatusStore(client);
|
|
38
|
+
const before = store.get();
|
|
39
|
+
let notified = 0;
|
|
40
|
+
const off = store.subscribe(() => (notified += 1));
|
|
41
|
+
expect(store.get()).toBe(before);
|
|
42
|
+
expect(notified).toBe(0);
|
|
43
|
+
off();
|
|
44
|
+
client.close();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Snapshot store behind useRpcStatus, React-free so the subscribe-time
|
|
2
|
+
// resync is testable without a renderer. The snapshot is rebuilt inside the
|
|
3
|
+
// state-change callback, where `attempt`/`nextRetryInMs` are guaranteed to
|
|
4
|
+
// already reflect the transition being notified.
|
|
5
|
+
|
|
6
|
+
import type { RpcClient, RpcContract, RpcState } from "./client.ts";
|
|
7
|
+
|
|
8
|
+
export interface RpcStatus {
|
|
9
|
+
state: RpcState;
|
|
10
|
+
attempt: number;
|
|
11
|
+
nextRetryInMs: number | undefined;
|
|
12
|
+
detail: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RpcStatusStore {
|
|
16
|
+
subscribe(onChange: () => void): () => void;
|
|
17
|
+
get(): RpcStatus;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createRpcStatusStore<C extends RpcContract>(client: RpcClient<C>): RpcStatusStore {
|
|
21
|
+
let snapshot: RpcStatus = {
|
|
22
|
+
state: client.state,
|
|
23
|
+
attempt: client.attempt,
|
|
24
|
+
nextRetryInMs: client.nextRetryInMs,
|
|
25
|
+
detail: undefined,
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
subscribe: (onChange) => {
|
|
29
|
+
const off = client.onStateChange((state, detail) => {
|
|
30
|
+
snapshot = { state, attempt: client.attempt, nextRetryInMs: client.nextRetryInMs, detail };
|
|
31
|
+
onChange();
|
|
32
|
+
});
|
|
33
|
+
// A transition in the render-to-subscribe gap (a local handshake can
|
|
34
|
+
// settle before passive effects flush) fired into an empty handler
|
|
35
|
+
// set. Rebuild the snapshot so useSyncExternalStore's post-subscribe
|
|
36
|
+
// getSnapshot() re-read observes it; returning the stale capture
|
|
37
|
+
// would defeat that guard and stick the UI on the old state forever.
|
|
38
|
+
// Its detail belonged to the missed transition, so it resets.
|
|
39
|
+
if (
|
|
40
|
+
client.state !== snapshot.state ||
|
|
41
|
+
client.attempt !== snapshot.attempt ||
|
|
42
|
+
client.nextRetryInMs !== snapshot.nextRetryInMs
|
|
43
|
+
) {
|
|
44
|
+
snapshot = {
|
|
45
|
+
state: client.state,
|
|
46
|
+
attempt: client.attempt,
|
|
47
|
+
nextRetryInMs: client.nextRetryInMs,
|
|
48
|
+
detail: undefined,
|
|
49
|
+
};
|
|
50
|
+
onChange();
|
|
51
|
+
}
|
|
52
|
+
return off;
|
|
53
|
+
},
|
|
54
|
+
get: () => snapshot,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pins socketTransport's drain-driven outbox: Bun's socket.write accepts
|
|
2
|
+
// fewer bytes than given under backpressure, and the remainder must survive
|
|
3
|
+
// to the next drain with byte-exact framing (a dropped tail mis-frames the
|
|
4
|
+
// NDJSON stream for the rest of the connection).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { socketTransport } from "./transport.ts";
|
|
8
|
+
|
|
9
|
+
describe("socketTransport", () => {
|
|
10
|
+
test("a frame larger than the send buffer arrives intact, in order", async () => {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
let lineBuffer = "";
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
const gotTwo = Promise.withResolvers<void>();
|
|
15
|
+
const server = Bun.listen({
|
|
16
|
+
hostname: "127.0.0.1",
|
|
17
|
+
port: 0,
|
|
18
|
+
socket: {
|
|
19
|
+
data(_s, chunk) {
|
|
20
|
+
lineBuffer += decoder.decode(chunk, { stream: true });
|
|
21
|
+
let newline: number;
|
|
22
|
+
while ((newline = lineBuffer.indexOf("\n")) >= 0) {
|
|
23
|
+
lines.push(lineBuffer.slice(0, newline));
|
|
24
|
+
lineBuffer = lineBuffer.slice(newline + 1);
|
|
25
|
+
}
|
|
26
|
+
if (lines.length >= 2) gotTwo.resolve();
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const opened = Promise.withResolvers<void>();
|
|
31
|
+
const transport = socketTransport({ host: "127.0.0.1", port: server.port })({
|
|
32
|
+
onOpen: () => opened.resolve(),
|
|
33
|
+
onMessage: () => {},
|
|
34
|
+
onClose: () => {},
|
|
35
|
+
});
|
|
36
|
+
await opened.promise;
|
|
37
|
+
// Multi-byte characters make the frame's byte length differ from its
|
|
38
|
+
// string length, so a character-based outbox offset would corrupt it.
|
|
39
|
+
const big = JSON.stringify({ body: "π".repeat(2 * 1024 * 1024) });
|
|
40
|
+
transport.send(big);
|
|
41
|
+
transport.send("tail"); // queues behind the still-draining big frame
|
|
42
|
+
await gotTwo.promise;
|
|
43
|
+
expect(lines[0]).toBe(big);
|
|
44
|
+
expect(lines[1]).toBe("tail");
|
|
45
|
+
transport.close();
|
|
46
|
+
server.stop();
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Transport seam for RpcClient: one factory call per dial, WebSocket
|
|
2
|
+
// semantics (constructing it starts the connect). The client never touches
|
|
3
|
+
// the underlying socket; `close()` must detach every underlying handler
|
|
4
|
+
// BEFORE closing, so a dying socket can't deliver late events against the
|
|
5
|
+
// fresh connection the client dials next (client.ts invariant (a) lives
|
|
6
|
+
// here for the built-in transports).
|
|
7
|
+
|
|
8
|
+
export interface Transport {
|
|
9
|
+
send(frame: string): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TransportHandlers {
|
|
14
|
+
onOpen(): void;
|
|
15
|
+
onMessage(frame: string): void;
|
|
16
|
+
onClose(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Called once per dial. Constructing it starts the connect (WebSocket semantics).
|
|
20
|
+
* A synchronous throw here is caught by the client and rides the ladder. */
|
|
21
|
+
export type TransportFactory = (handlers: TransportHandlers) => Transport;
|
|
22
|
+
|
|
23
|
+
export function webSocketTransport(url: string, protocols?: string | string[]): TransportFactory {
|
|
24
|
+
return (handlers) => {
|
|
25
|
+
const ws = new WebSocket(url, protocols);
|
|
26
|
+
let closed = false;
|
|
27
|
+
ws.onopen = () => {
|
|
28
|
+
if (!closed) handlers.onOpen();
|
|
29
|
+
};
|
|
30
|
+
ws.onmessage = (ev) => {
|
|
31
|
+
if (!closed) handlers.onMessage(String(ev.data));
|
|
32
|
+
};
|
|
33
|
+
ws.onclose = () => {
|
|
34
|
+
if (closed) return;
|
|
35
|
+
closed = true;
|
|
36
|
+
handlers.onClose();
|
|
37
|
+
};
|
|
38
|
+
ws.onerror = () => {
|
|
39
|
+
/* the close event that follows drives all recovery/rejection logic */
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
send: (frame) => ws.send(frame),
|
|
43
|
+
close: () => {
|
|
44
|
+
closed = true;
|
|
45
|
+
ws.onopen = null;
|
|
46
|
+
ws.onmessage = null;
|
|
47
|
+
ws.onclose = null;
|
|
48
|
+
ws.onerror = null;
|
|
49
|
+
try {
|
|
50
|
+
ws.close();
|
|
51
|
+
} catch {
|
|
52
|
+
// A dead socket may throw on close; the client doesn't care.
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Raw socket transport with NDJSON framing (one JSON frame per newline).
|
|
60
|
+
* `path` dials a unix socket, `host`/`port` a TCP one. */
|
|
61
|
+
export function socketTransport(target: { path: string } | { host: string; port: number }): TransportFactory {
|
|
62
|
+
return (handlers) => {
|
|
63
|
+
let closed = false;
|
|
64
|
+
let sock: import("bun").Socket | undefined;
|
|
65
|
+
let buffer = "";
|
|
66
|
+
const decoder = new TextDecoder();
|
|
67
|
+
const encoder = new TextEncoder();
|
|
68
|
+
// Outbound backpressure (the hazard runtime/ndp.ts documents): a Bun
|
|
69
|
+
// socket write can accept fewer bytes than given (or -1 while closing),
|
|
70
|
+
// and the unwritten remainder must be queued and flushed from `drain`,
|
|
71
|
+
// or it is silently lost and the NDJSON stream is mis-framed from that
|
|
72
|
+
// point on. A FIFO of whole frames — byte-encoded up front so the
|
|
73
|
+
// offset arithmetic survives multi-byte UTF-8 — keeps a second send()
|
|
74
|
+
// ordered behind a still-draining large frame.
|
|
75
|
+
const outbox: Uint8Array[] = [];
|
|
76
|
+
let outboxOffset = 0;
|
|
77
|
+
const pump = (s: import("bun").Socket): void => {
|
|
78
|
+
while (outbox.length > 0) {
|
|
79
|
+
const front = outbox[0]!;
|
|
80
|
+
const n = s.write(front.subarray(outboxOffset));
|
|
81
|
+
if (n <= 0) return; // buffer full (or closing); wait for the next drain
|
|
82
|
+
outboxOffset += n;
|
|
83
|
+
if (outboxOffset >= front.length) {
|
|
84
|
+
outbox.shift();
|
|
85
|
+
outboxOffset = 0;
|
|
86
|
+
} else {
|
|
87
|
+
return; // partial write; the rest goes out on drain
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const emitClose = (): void => {
|
|
92
|
+
if (closed) return;
|
|
93
|
+
closed = true;
|
|
94
|
+
handlers.onClose();
|
|
95
|
+
};
|
|
96
|
+
const socketHandlers = {
|
|
97
|
+
open(s: import("bun").Socket) {
|
|
98
|
+
sock = s;
|
|
99
|
+
if (closed) s.end();
|
|
100
|
+
else handlers.onOpen();
|
|
101
|
+
},
|
|
102
|
+
data(_s: import("bun").Socket, chunk: Uint8Array) {
|
|
103
|
+
if (closed) return;
|
|
104
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
105
|
+
let newline: number;
|
|
106
|
+
while (!closed && (newline = buffer.indexOf("\n")) >= 0) {
|
|
107
|
+
const line = buffer.slice(0, newline);
|
|
108
|
+
buffer = buffer.slice(newline + 1);
|
|
109
|
+
if (line.length > 0) handlers.onMessage(line);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
drain(s: import("bun").Socket) {
|
|
113
|
+
if (!closed) pump(s);
|
|
114
|
+
},
|
|
115
|
+
close() {
|
|
116
|
+
emitClose();
|
|
117
|
+
},
|
|
118
|
+
error() {
|
|
119
|
+
/* the close event that follows drives all recovery/rejection logic */
|
|
120
|
+
},
|
|
121
|
+
connectError() {
|
|
122
|
+
emitClose();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const connecting =
|
|
126
|
+
"path" in target
|
|
127
|
+
? Bun.connect({ unix: target.path, socket: socketHandlers })
|
|
128
|
+
: Bun.connect({ hostname: target.host, port: target.port, socket: socketHandlers });
|
|
129
|
+
connecting.catch(() => emitClose());
|
|
130
|
+
return {
|
|
131
|
+
send: (frame) => {
|
|
132
|
+
if (closed || !sock) return;
|
|
133
|
+
outbox.push(encoder.encode(frame + "\n"));
|
|
134
|
+
pump(sock);
|
|
135
|
+
},
|
|
136
|
+
close: () => {
|
|
137
|
+
closed = true;
|
|
138
|
+
sock?.end();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
}
|