@trevrpc/effect 0.0.0-stage → 1.6.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 trev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # @trevrpc/effect
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Effect](https://effect.website) adapter for the TrevRPC browser client, [`@trevrpc/trevrpc-js`](https://www.npmjs.com/package/@trevrpc/trevrpc-js): it makes clients whose calls are effects and streams.
4
+
5
+ ```sh
6
+ npm install @trevrpc/effect @trevrpc/trevrpc-js @bufbuild/protobuf effect
7
+ ```
8
+
9
+ ```ts
10
+ import { createChannel } from "@trevrpc/trevrpc-js";
11
+ import { createEffectClient } from "@trevrpc/effect";
12
+ import { Stream } from "effect";
13
+
14
+ import { Greeter } from "./gen/greeter/v1/greeter_pb.js";
15
+
16
+ const channel = createChannel("https://example.com/trevrpc");
17
+ const greeter = createEffectClient(Greeter, channel);
18
+
19
+ // Effect<HelloReply, TrevRpcError>
20
+ const reply = greeter.sayHello({ name: "Ada" });
21
+ // Stream<Update, TrevRpcError>
22
+ const updates = greeter.watch({ name: "Ada" });
23
+ // Effect<UploadReply, TrevRpcError>
24
+ const upload = greeter.upload(Stream.make({ chunk: "a" }, { chunk: "b" }));
25
+ // Stream<ChatReply, TrevRpcError>
26
+ const replies = greeter.chat(
27
+ Stream.fromIterable(["hi", "bye"]).pipe(Stream.map((text) => ({ text }))),
28
+ );
29
+ ```
30
+
31
+ `createEffectClient(service, transport, options?)` takes what `createClient` of `@trevrpc/trevrpc-js` takes: a service descriptor that [protobuf-es](https://github.com/bufbuild/protobuf-es)'s `protoc-gen-es` generates, a transport such as a channel, and default call options and interceptors. It returns a client with one method per RPC, named by its `localName`:
32
+
33
+ | RPC kind | Effect method |
34
+ | ----------------------- | --------------------------------------------------------------------------------------- |
35
+ | unary | `(request, options?) => Effect<Response, TrevRpcError>` |
36
+ | server streaming | `(request, options?) => Stream<Response, TrevRpcError>` |
37
+ | client streaming | `(requests: Stream<Request, E, R>, options?) => Effect<Response, TrevRpcError \| E, R>` |
38
+ | bidirectional streaming | `(requests: Stream<Request, E, R>, options?) => Stream<Response, TrevRpcError \| E, R>` |
39
+
40
+ `options` are the usual [call options](https://trev.zip/llc/TrevRPC/wiki/JavaScript-Guide#call-options).
41
+
42
+ - **Cancellation.** Interrupting an effect or stream cancels its call, which the server sees as `CANCELLED`; so does taking part of a response stream (`Stream.take`) or a failure elsewhere in the program. A `signal` in the call options also cancels the call.
43
+ - **Request streams.** Client-streaming and bidirectional calls pull their requests from the stream as the transport accepts them, so a request stream is only consumed as fast as the server reads it. When the server ends the call first, the rest of the stream is not pulled, and the server's outcome is the result: its response or response stream if it succeeded, or its `TrevRpcError`. A request stream that fails cancels the call, and the call fails with the request stream's error.
44
+ - **Errors.** Calls fail with the `TrevRpcError` of the [client](https://trev.zip/llc/TrevRPC/wiki/JavaScript-Guide#errors-cancellation-and-streams), whose `_tag` is `"TrevRpcError"`, so `Effect.catchTag("TrevRpcError", ...)` handles it. Anything else a call rejects with becomes a `TrevRpcError` with `Code.Unknown` and the original as `cause`, as `toTrevRpcError(error)` does.
45
+ - **Rendering.** Response streams deliver buffered messages without returning to the event loop, as the client does; see [Rendering long streams](https://trev.zip/llc/TrevRPC/wiki/JavaScript-Guide#rendering-long-streams) before rendering a long stream message by message.
46
+
47
+ ## Development
48
+
49
+ The package is developed against the trevrpc-js of this checkout, which it links as `file:..`, so install that first:
50
+
51
+ ```sh
52
+ npm ci --prefix ..
53
+ npm ci
54
+ npm run check # format check, lint, typecheck, tests
55
+ ```
56
+
57
+ The tests run the real client against the in-memory server of trevrpc-js's own tests. `nix build .#trevrpc-js-effect` runs them against the built trevrpc-js, and checks the published files.
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@trevrpc/effect",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.6.0",
4
+ "description": "Effect adapter for TrevRPC browser clients",
5
+ "homepage": "https://trev.zip/llc/TrevRPC",
6
+ "bugs": {
7
+ "url": "https://trev.zip/llc/TrevRPC/issues"
8
+ },
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://trev.zip/llc/TrevRPC.git",
13
+ "directory": "trevrpc-js/effect"
14
+ },
15
+ "files": [
16
+ "src/**",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "types": "./src/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./src/index.d.ts",
26
+ "import": "./src/index.js"
27
+ }
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "node test/generate.js",
34
+ "format": "oxfmt --write",
35
+ "format:check": "oxfmt --check",
36
+ "lint": "oxlint --deny-warnings src test",
37
+ "typecheck": "npm run build && tsc -p tsconfig.json",
38
+ "test": "npm run build && node --test test/*.test.js",
39
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm test"
40
+ },
41
+ "devDependencies": {
42
+ "@bufbuild/protobuf": "^2.17.0",
43
+ "@bufbuild/protoc-gen-es": "^2.17.0",
44
+ "@trevrpc/trevrpc-js": "file:..",
45
+ "effect": "^4.1.2",
46
+ "typescript": "^7.0.2"
47
+ },
48
+ "peerDependencies": {
49
+ "@bufbuild/protobuf": "^2.17.0",
50
+ "@trevrpc/trevrpc-js": "^1.6.0",
51
+ "effect": "^4.1.2"
52
+ },
53
+ "engines": {
54
+ "node": ">=24"
55
+ },
56
+ "packageManager": "npm@12.2.2"
57
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { DescMessage, DescService, MessageInitShape, MessageShape } from "@bufbuild/protobuf";
2
+ import type { CallOptions, ClientOptions, Transport, TrevRpcError } from "@trevrpc/trevrpc-js";
3
+ import type { Effect, Stream } from "effect";
4
+
5
+ /**
6
+ * The Effect form of a method of a protobuf-es service descriptor:
7
+ *
8
+ * - unary: `(request, options?) => Effect<Response, TrevRpcError>`
9
+ * - server streaming: `(request, options?) => Stream<Response, TrevRpcError>`
10
+ * - client streaming: `(requests, options?) => Effect<Response, TrevRpcError | E, R>`
11
+ * - bidirectional streaming: `(requests, options?) => Stream<Response, TrevRpcError | E, R>`
12
+ *
13
+ * where a request is a message or the init object `create()` takes, `requests` is a
14
+ * `Stream<Request, E, R>`, and a response is a message.
15
+ */
16
+ export type EffectMethod<Method> = Method extends {
17
+ methodKind: infer Kind;
18
+ input: infer I extends DescMessage;
19
+ output: infer O extends DescMessage;
20
+ }
21
+ ? Kind extends "unary"
22
+ ? (
23
+ request: MessageInitShape<I>,
24
+ options?: CallOptions,
25
+ ) => Effect.Effect<MessageShape<O>, TrevRpcError>
26
+ : Kind extends "server_streaming"
27
+ ? (
28
+ request: MessageInitShape<I>,
29
+ options?: CallOptions,
30
+ ) => Stream.Stream<MessageShape<O>, TrevRpcError>
31
+ : Kind extends "client_streaming"
32
+ ? <E = never, R = never>(
33
+ requests: Stream.Stream<MessageInitShape<I>, E, R>,
34
+ options?: CallOptions,
35
+ ) => Effect.Effect<MessageShape<O>, TrevRpcError | E, R>
36
+ : Kind extends "bidi_streaming"
37
+ ? <E = never, R = never>(
38
+ requests: Stream.Stream<MessageInitShape<I>, E, R>,
39
+ options?: CallOptions,
40
+ ) => Stream.Stream<MessageShape<O>, TrevRpcError | E, R>
41
+ : never
42
+ : never;
43
+
44
+ /** The Effect client of a service: one method per RPC, named by its `localName`. */
45
+ export type EffectClient<S extends DescService> = {
46
+ [Name in keyof S["method"]]: EffectMethod<S["method"][Name]>;
47
+ };
48
+
49
+ /**
50
+ * Creates a client for `service`, a service descriptor that protoc-gen-es generates (such as
51
+ * `Greeter` from `greeter_pb.js`), whose calls are Effect values. `transport` and `options` are
52
+ * those of `createClient` in `@trevrpc/trevrpc-js`.
53
+ *
54
+ * Interrupting an effect or stream cancels its call, which the server sees as `Code.Cancelled`.
55
+ * A `signal` in the call options cancels it too. Client-streaming and bidirectional calls pull
56
+ * their requests from the stream as the transport accepts them. When the server ends the call
57
+ * first, the rest of the stream is not pulled, and the server's outcome is the result. A request
58
+ * stream that fails cancels the call, and the effect or stream fails with the request stream's
59
+ * error.
60
+ *
61
+ * Calls fail with a `TrevRpcError`; anything else a call rejects with becomes one with
62
+ * `Code.Unknown`, and with the original as `cause`.
63
+ *
64
+ * @throws TrevRpcError with `Code.InvalidArgument` where `createClient` throws it.
65
+ */
66
+ export function createEffectClient<S extends DescService>(
67
+ service: S,
68
+ transport: Transport,
69
+ options?: ClientOptions,
70
+ ): EffectClient<S>;
71
+
72
+ /** Returns a `TrevRpcError` as is, and wraps anything else in one with `Code.Unknown`. */
73
+ export function toTrevRpcError(error: unknown): TrevRpcError;
package/src/index.js ADDED
@@ -0,0 +1,137 @@
1
+ import { Code, TrevRpcError, createClient } from "@trevrpc/trevrpc-js";
2
+ import { Effect, Stream } from "effect";
3
+
4
+ /** Returns a `TrevRpcError` as is, and wraps anything else in one with `Code.Unknown`. */
5
+ export function toTrevRpcError(error) {
6
+ if (error instanceof TrevRpcError) {
7
+ return error;
8
+ }
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ return new TrevRpcError(Code.Unknown, message, undefined, { cause: error });
11
+ }
12
+
13
+ /**
14
+ * Creates a client for `service`, a service descriptor that protoc-gen-es generates, whose calls
15
+ * are Effect values: unary calls return an `Effect`, server-streaming calls a `Stream`, and
16
+ * client-streaming and bidirectional calls take their requests as a `Stream`. Interrupting the
17
+ * effect or stream cancels the call. `transport` and `options` are those of `createClient`.
18
+ */
19
+ export function createEffectClient(service, transport, options) {
20
+ const client = createClient(service, transport, options);
21
+ const result = {};
22
+ for (const method of service.methods) {
23
+ const call = client[method.localName];
24
+ result[method.localName] = Lifts[method.methodKind](call);
25
+ }
26
+ return result;
27
+ }
28
+
29
+ /** How each protobuf-es method kind's calls become Effect values. */
30
+ const Lifts = Object.freeze({
31
+ __proto__: null,
32
+ unary: liftUnary,
33
+ server_streaming: liftServerStreaming,
34
+ client_streaming: liftClientStreaming,
35
+ bidi_streaming: liftBidirectionalStreaming,
36
+ });
37
+
38
+ function liftUnary(call) {
39
+ return (request, options) =>
40
+ Effect.tryPromise({
41
+ try: (signal) => call(request, withSignal(options, signal)),
42
+ catch: toTrevRpcError,
43
+ });
44
+ }
45
+
46
+ function liftServerStreaming(call) {
47
+ return (request, options) =>
48
+ Stream.unwrap(
49
+ Effect.gen(function* () {
50
+ const signal = yield* abortOnClose;
51
+ const responses = yield* open(() => call(request, withSignal(options, signal)));
52
+ return Stream.fromAsyncIterable(responses, toTrevRpcError);
53
+ }),
54
+ );
55
+ }
56
+
57
+ function liftClientStreaming(call) {
58
+ return (requests, options) =>
59
+ Effect.scoped(
60
+ Effect.gen(function* () {
61
+ const signal = yield* abortOnClose;
62
+ const stream = yield* open(() => call(withSignal(options, signal)));
63
+ // The server may answer before the upload is done; its status ends the upload, and
64
+ // closeAndRecv() reports the outcome.
65
+ yield* Effect.raceFirst(upload(stream, requests), settled(stream.status));
66
+ return yield* Effect.tryPromise({
67
+ try: () => stream.closeAndRecv(),
68
+ catch: toTrevRpcError,
69
+ });
70
+ }),
71
+ );
72
+ }
73
+
74
+ function liftBidirectionalStreaming(call) {
75
+ return (requests, options) =>
76
+ Stream.unwrap(
77
+ Effect.gen(function* () {
78
+ const signal = yield* abortOnClose;
79
+ const stream = yield* open(() => call(withSignal(options, signal)));
80
+ const sending = upload(stream, requests).pipe(
81
+ // closeSend() resolves when the server already ended the call; any other failure is
82
+ // the response's to report.
83
+ Effect.andThen(Effect.promise(() => stream.closeSend().catch(ignore))),
84
+ );
85
+ // The responses decide when the stream ends: their end stops an unfinished upload,
86
+ // while a failed request stream fails the whole stream.
87
+ return Stream.merge(
88
+ Stream.fromAsyncIterable(stream, toTrevRpcError),
89
+ Stream.drain(Stream.fromEffect(sending)),
90
+ { haltStrategy: "left" },
91
+ );
92
+ }),
93
+ );
94
+ }
95
+
96
+ /**
97
+ * A signal that aborts when the scope closes, cancelling a call that has not finished: on
98
+ * interruption, on failure, and after the call ended, when aborting has no effect.
99
+ */
100
+ const abortOnClose = Effect.map(
101
+ Effect.acquireRelease(
102
+ Effect.sync(() => new AbortController()),
103
+ (controller) => Effect.sync(() => controller.abort()),
104
+ ),
105
+ (controller) => controller.signal,
106
+ );
107
+
108
+ /** Starts a call, whose promise settles once the call is open. */
109
+ function open(start) {
110
+ return Effect.tryPromise({ try: start, catch: toTrevRpcError });
111
+ }
112
+
113
+ /** The call options with `signal` added to the caller's own, either of which cancels the call. */
114
+ function withSignal(options, signal) {
115
+ const own = options?.signal;
116
+ return { ...options, signal: own ? AbortSignal.any([own, signal]) : signal };
117
+ }
118
+
119
+ /**
120
+ * Sends each request of `requests`, waiting for the transport to accept it. Stops without an error
121
+ * at the first rejected send, which means the call has ended; the call reports its outcome. A
122
+ * failing request stream fails with its error, and the caller's scope then cancels the call.
123
+ */
124
+ function upload(stream, requests) {
125
+ return Stream.runForEachWhile(requests, (request) =>
126
+ Effect.promise(() => stream.send(request).then(accepted, rejected)),
127
+ );
128
+ }
129
+
130
+ /** Succeeds once `promise` settles, whatever its outcome. */
131
+ function settled(promise) {
132
+ return Effect.promise(() => promise.then(ignore, ignore));
133
+ }
134
+
135
+ const accepted = () => true;
136
+ const rejected = () => false;
137
+ const ignore = () => {};