@dxos/rpc 0.10.0 → 0.11.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/dist/lib/index.mjs +948 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/types/src/effect-rpc.d.ts +22 -0
- package/dist/types/src/effect-rpc.d.ts.map +1 -0
- package/dist/types/src/effect-rpc.test.d.ts +2 -0
- package/dist/types/src/effect-rpc.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -11
- package/src/effect-rpc.test.ts +93 -0
- package/src/effect-rpc.ts +179 -0
- package/src/index.ts +1 -0
- package/dist/lib/neutral/index.mjs +0 -751
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/rpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A lightweight, transport-agnostic RPC implementation",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
".": {
|
|
17
17
|
"source": "./src/index.ts",
|
|
18
18
|
"types": "./dist/types/src/index.d.ts",
|
|
19
|
-
"
|
|
19
|
+
"import": "./dist/lib/index.mjs"
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -25,15 +25,21 @@
|
|
|
25
25
|
"src"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@
|
|
29
|
-
"@
|
|
30
|
-
"
|
|
31
|
-
"@dxos/
|
|
32
|
-
"@dxos/
|
|
33
|
-
"@dxos/
|
|
34
|
-
"@dxos/
|
|
35
|
-
"@dxos/
|
|
36
|
-
"@dxos/
|
|
28
|
+
"@effect/platform": "0.96.2",
|
|
29
|
+
"@effect/rpc": "0.75.1",
|
|
30
|
+
"effect": "3.21.4",
|
|
31
|
+
"@dxos/async": "0.11.0",
|
|
32
|
+
"@dxos/codec-protobuf": "0.11.0",
|
|
33
|
+
"@dxos/context": "0.11.0",
|
|
34
|
+
"@dxos/debug": "0.11.0",
|
|
35
|
+
"@dxos/invariant": "0.11.0",
|
|
36
|
+
"@dxos/log": "0.11.0",
|
|
37
|
+
"@dxos/util": "0.11.0",
|
|
38
|
+
"@dxos/node-std": "0.11.0",
|
|
39
|
+
"@dxos/protocols": "0.11.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@dxos/effect": "0.11.0"
|
|
37
43
|
},
|
|
38
44
|
"publishConfig": {
|
|
39
45
|
"access": "public"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Rpc from '@effect/rpc/Rpc';
|
|
6
|
+
import * as RpcClient from '@effect/rpc/RpcClient';
|
|
7
|
+
import * as RpcGroup from '@effect/rpc/RpcGroup';
|
|
8
|
+
import * as RpcServer from '@effect/rpc/RpcServer';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Exit from 'effect/Exit';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as ManagedRuntime from 'effect/ManagedRuntime';
|
|
13
|
+
import * as Schema from 'effect/Schema';
|
|
14
|
+
import * as Scope from 'effect/Scope';
|
|
15
|
+
import * as Stream from 'effect/Stream';
|
|
16
|
+
import { describe, onTestFinished, test } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { sleep } from '@dxos/async';
|
|
19
|
+
import { EffectEx } from '@dxos/effect';
|
|
20
|
+
|
|
21
|
+
import { layerProtocolRpcPortServer, makeProtocolRpcPortClient } from './effect-rpc';
|
|
22
|
+
import { createLinkedPorts } from './testing';
|
|
23
|
+
|
|
24
|
+
class TestRpcs extends RpcGroup.make(
|
|
25
|
+
Rpc.make('echo', {
|
|
26
|
+
payload: { message: Schema.String },
|
|
27
|
+
success: Schema.String,
|
|
28
|
+
}),
|
|
29
|
+
Rpc.make('countdown', {
|
|
30
|
+
payload: { from: Schema.Int },
|
|
31
|
+
success: Schema.Int,
|
|
32
|
+
stream: true,
|
|
33
|
+
}),
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
const handlers = TestRpcs.toLayer(
|
|
37
|
+
Effect.succeed({
|
|
38
|
+
echo: ({ message }: { message: string }) => Effect.succeed(`echo: ${message}`),
|
|
39
|
+
countdown: ({ from }: { from: number }) => Stream.fromIterable(Array.from({ length: from }, (_, i) => from - i)),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
describe('effect-rpc over RpcPort', () => {
|
|
44
|
+
const setup = async (options?: { serverDelay?: number }) => {
|
|
45
|
+
const [clientPort, serverPort] = createLinkedPorts();
|
|
46
|
+
|
|
47
|
+
const serverLayer = RpcServer.layer(TestRpcs, { disableTracing: true }).pipe(
|
|
48
|
+
Layer.provide(handlers),
|
|
49
|
+
Layer.provide(layerProtocolRpcPortServer(serverPort)),
|
|
50
|
+
);
|
|
51
|
+
const serverRuntime = ManagedRuntime.make(serverLayer);
|
|
52
|
+
onTestFinished(() => serverRuntime.dispose());
|
|
53
|
+
const startServer = async () => {
|
|
54
|
+
if (options?.serverDelay) {
|
|
55
|
+
await sleep(options.serverDelay);
|
|
56
|
+
}
|
|
57
|
+
await serverRuntime.runPromise(Effect.void);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const scope = Effect.runSync(Scope.make());
|
|
61
|
+
onTestFinished(() => EffectEx.runPromise(Scope.close(scope, Exit.void)));
|
|
62
|
+
const clientPromise = EffectEx.runPromise(
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const protocol = yield* makeProtocolRpcPortClient(clientPort);
|
|
65
|
+
return yield* RpcClient.make(TestRpcs, { disableTracing: true }).pipe(
|
|
66
|
+
Effect.provideService(RpcClient.Protocol, protocol),
|
|
67
|
+
);
|
|
68
|
+
}).pipe(Scope.extend(scope)),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const [client] = await Promise.all([clientPromise, startServer()]);
|
|
72
|
+
return client;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
test('unary round trip', async ({ expect }) => {
|
|
76
|
+
const client = await setup();
|
|
77
|
+
const result = await EffectEx.runPromise(client.echo({ message: 'hello' }));
|
|
78
|
+
expect(result).toEqual('echo: hello');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('stream round trip', async ({ expect }) => {
|
|
82
|
+
const client = await setup();
|
|
83
|
+
const values = await EffectEx.runPromise(client.countdown({ from: 3 }).pipe(Stream.runCollect));
|
|
84
|
+
expect([...values]).toEqual([3, 2, 1]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('client connects when the server attaches late', async ({ expect }) => {
|
|
88
|
+
// The client retries its handshake Ping until the server starts listening.
|
|
89
|
+
const client = await setup({ serverDelay: 700 });
|
|
90
|
+
const result = await EffectEx.runPromise(client.echo({ message: 'late' }));
|
|
91
|
+
expect(result).toEqual('echo: late');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as RpcClient from '@effect/rpc/RpcClient';
|
|
6
|
+
import * as RpcClientError from '@effect/rpc/RpcClientError';
|
|
7
|
+
import * as RpcMessage from '@effect/rpc/RpcMessage';
|
|
8
|
+
import * as RpcSerialization from '@effect/rpc/RpcSerialization';
|
|
9
|
+
import * as RpcServer from '@effect/rpc/RpcServer';
|
|
10
|
+
import * as Duration from 'effect/Duration';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import * as Mailbox from 'effect/Mailbox';
|
|
14
|
+
import * as Option from 'effect/Option';
|
|
15
|
+
import type * as Scope from 'effect/Scope';
|
|
16
|
+
|
|
17
|
+
import { log } from '@dxos/log';
|
|
18
|
+
|
|
19
|
+
import { type RpcPort } from './rpc';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Interval at which the client re-sends the initial Ping while waiting for the server to attach.
|
|
23
|
+
*/
|
|
24
|
+
const HANDSHAKE_RETRY_INTERVAL = Duration.millis(50);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Effect RPC protocols over a {@link RpcPort} — a transport-agnostic, reliable, ordered,
|
|
28
|
+
* binary message channel. Message envelopes are framed with msgpack; RPC payloads are expected
|
|
29
|
+
* to already be binary-safe (e.g. protobuf-encoded by the payload schemas).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const subscribePort = (port: RpcPort) =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const mailbox = yield* Mailbox.make<Uint8Array>();
|
|
35
|
+
const unsubscribe = port.subscribe((message) => {
|
|
36
|
+
mailbox.unsafeOffer(message);
|
|
37
|
+
});
|
|
38
|
+
yield* Effect.addFinalizer(() =>
|
|
39
|
+
Effect.sync(() => {
|
|
40
|
+
unsubscribe?.();
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
return mailbox;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const sendFrame = (port: RpcPort, frame: Uint8Array | string | undefined): Effect.Effect<void, Error> =>
|
|
47
|
+
frame === undefined || typeof frame === 'string'
|
|
48
|
+
? Effect.dieMessage('rpc-port protocol requires binary frames')
|
|
49
|
+
: // Copy the frame: msgpack encoders reuse their output buffer, but RpcPort.send may be
|
|
50
|
+
// asynchronous (e.g. postMessage) and read the bytes after the encoder has overwritten them.
|
|
51
|
+
Effect.tryPromise({
|
|
52
|
+
try: async () => port.send(frame.slice()),
|
|
53
|
+
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Client-side effect-rpc protocol over an {@link RpcPort}.
|
|
58
|
+
*
|
|
59
|
+
* Performs a Ping/Pong handshake on construction: the server answers Pings as soon as it is
|
|
60
|
+
* running, so construction blocks until the peer is reachable and fails fast under an outer
|
|
61
|
+
* timeout instead of buffering requests towards a peer that never attaches.
|
|
62
|
+
*/
|
|
63
|
+
export const makeProtocolRpcPortClient = (
|
|
64
|
+
port: RpcPort,
|
|
65
|
+
): Effect.Effect<RpcClient.Protocol['Type'], never, Scope.Scope> =>
|
|
66
|
+
RpcClient.Protocol.make(
|
|
67
|
+
Effect.fnUntraced(function* (writeResponse) {
|
|
68
|
+
const parser = RpcSerialization.msgPack.unsafeMake();
|
|
69
|
+
const mailbox = yield* subscribePort(port);
|
|
70
|
+
|
|
71
|
+
const decodeFrame = (frame: Uint8Array) =>
|
|
72
|
+
Effect.try({
|
|
73
|
+
try: () => parser.decode(frame) as ReadonlyArray<RpcMessage.FromServerEncoded>,
|
|
74
|
+
catch: (cause) => {
|
|
75
|
+
log.warn('rpc-port client: failed to decode frame', { cause });
|
|
76
|
+
return [] as ReadonlyArray<RpcMessage.FromServerEncoded>;
|
|
77
|
+
},
|
|
78
|
+
}).pipe(Effect.merge);
|
|
79
|
+
|
|
80
|
+
const send = (request: RpcMessage.FromClientEncoded): Effect.Effect<void, RpcClientError.RpcClientError> =>
|
|
81
|
+
Effect.suspend(() => sendFrame(port, parser.encode(request))).pipe(
|
|
82
|
+
Effect.mapError(
|
|
83
|
+
(cause) =>
|
|
84
|
+
new RpcClientError.RpcClientError({
|
|
85
|
+
reason: 'Protocol',
|
|
86
|
+
message: 'Failed to send message over RpcPort',
|
|
87
|
+
cause,
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Handshake: resend Ping until the server responds, forwarding any other early responses.
|
|
93
|
+
// Transport failures during the handshake are unrecoverable for this connection.
|
|
94
|
+
yield* Effect.gen(function* () {
|
|
95
|
+
let connected = false;
|
|
96
|
+
while (!connected) {
|
|
97
|
+
yield* send(RpcMessage.constPing);
|
|
98
|
+
const frame = yield* mailbox.take.pipe(Effect.timeoutOption(HANDSHAKE_RETRY_INTERVAL));
|
|
99
|
+
if (Option.isNone(frame)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const response of yield* decodeFrame(frame.value)) {
|
|
103
|
+
if (response._tag === 'Pong') {
|
|
104
|
+
connected = true;
|
|
105
|
+
} else {
|
|
106
|
+
yield* writeResponse(response);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}).pipe(Effect.orDie);
|
|
111
|
+
|
|
112
|
+
yield* mailbox.take.pipe(
|
|
113
|
+
Effect.flatMap(decodeFrame),
|
|
114
|
+
Effect.flatMap((responses) => Effect.forEach(responses, writeResponse, { discard: true })),
|
|
115
|
+
Effect.forever,
|
|
116
|
+
Effect.orDie,
|
|
117
|
+
Effect.interruptible,
|
|
118
|
+
Effect.forkScoped,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
send,
|
|
123
|
+
supportsAck: true,
|
|
124
|
+
supportsTransferables: false,
|
|
125
|
+
};
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
export const layerProtocolRpcPortClient = (port: RpcPort): Layer.Layer<RpcClient.Protocol> =>
|
|
130
|
+
Layer.scoped(RpcClient.Protocol, makeProtocolRpcPortClient(port));
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Server-side effect-rpc protocol over an {@link RpcPort}.
|
|
134
|
+
* The port carries a single logical client for the lifetime of the protocol.
|
|
135
|
+
*/
|
|
136
|
+
export const makeProtocolRpcPortServer = (
|
|
137
|
+
port: RpcPort,
|
|
138
|
+
): Effect.Effect<RpcServer.Protocol['Type'], never, Scope.Scope> =>
|
|
139
|
+
RpcServer.Protocol.make(
|
|
140
|
+
Effect.fnUntraced(function* (writeRequest) {
|
|
141
|
+
const parser = RpcSerialization.msgPack.unsafeMake();
|
|
142
|
+
const mailbox = yield* subscribePort(port);
|
|
143
|
+
const disconnects = yield* Mailbox.make<number>();
|
|
144
|
+
const clientId = 0;
|
|
145
|
+
|
|
146
|
+
yield* mailbox.take.pipe(
|
|
147
|
+
Effect.flatMap((frame) =>
|
|
148
|
+
Effect.try({
|
|
149
|
+
try: () => parser.decode(frame) as ReadonlyArray<RpcMessage.FromClientEncoded>,
|
|
150
|
+
catch: (cause) => {
|
|
151
|
+
log.warn('rpc-port server: failed to decode frame', { cause });
|
|
152
|
+
return [] as ReadonlyArray<RpcMessage.FromClientEncoded>;
|
|
153
|
+
},
|
|
154
|
+
}).pipe(Effect.merge),
|
|
155
|
+
),
|
|
156
|
+
Effect.flatMap((requests) =>
|
|
157
|
+
Effect.forEach(requests, (request) => writeRequest(clientId, request), { discard: true }),
|
|
158
|
+
),
|
|
159
|
+
Effect.forever,
|
|
160
|
+
Effect.interruptible,
|
|
161
|
+
Effect.forkScoped,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
disconnects,
|
|
166
|
+
send: (_clientId: number, response: RpcMessage.FromServerEncoded) =>
|
|
167
|
+
Effect.suspend(() => sendFrame(port, parser.encode(response))).pipe(Effect.orDie),
|
|
168
|
+
end: (_clientId: number) => Effect.void,
|
|
169
|
+
clientIds: Effect.sync(() => new Set([clientId])),
|
|
170
|
+
initialMessage: Effect.succeed(Option.none()),
|
|
171
|
+
supportsAck: true,
|
|
172
|
+
supportsTransferables: false,
|
|
173
|
+
supportsSpanPropagation: false,
|
|
174
|
+
};
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
export const layerProtocolRpcPortServer = (port: RpcPort): Layer.Layer<RpcServer.Protocol> =>
|
|
179
|
+
Layer.scoped(RpcServer.Protocol, makeProtocolRpcPortServer(port));
|