@trevrpc/effect 0.0.0-stage → 1.5.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 wraps a generated client so that its calls are effects and streams.
4
+
5
+ ```sh
6
+ npm install @trevrpc/effect @trevrpc/trevrpc-js 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 { GreeterClient, GreeterService } from "./gen/greeter/v1/greeter.trevrpc.js";
15
+
16
+ const channel = createChannel("https://example.com/trevrpc");
17
+ const greeter = createEffectClient(GreeterService, new GreeterClient(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, client)` takes the generated descriptor and client, and returns a client with the same method names:
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,54 @@
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.5.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
+ "@trevrpc/trevrpc-js": "file:..",
43
+ "effect": "^4.1.2",
44
+ "typescript": "^7.0.2"
45
+ },
46
+ "peerDependencies": {
47
+ "@trevrpc/trevrpc-js": "^1.5.0",
48
+ "effect": "^4.1.2"
49
+ },
50
+ "engines": {
51
+ "node": ">=24"
52
+ },
53
+ "packageManager": "npm@12.2.1"
54
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type {
2
+ BidirectionalStreamingCall,
3
+ CallOptions,
4
+ ClientStreamingCall,
5
+ ResponseStream,
6
+ RpcServiceDescriptor,
7
+ TrevRpcError,
8
+ } from "@trevrpc/trevrpc-js";
9
+ import type { Effect, Stream } from "effect";
10
+
11
+ /**
12
+ * The Effect form of a generated client method:
13
+ *
14
+ * - unary: `(request, options?) => Effect<Response, TrevRpcError>`
15
+ * - server streaming: `(request, options?) => Stream<Response, TrevRpcError>`
16
+ * - client streaming: `(requests, options?) => Effect<Response, TrevRpcError | E, R>`
17
+ * - bidirectional streaming: `(requests, options?) => Stream<Response, TrevRpcError | E, R>`
18
+ *
19
+ * where `requests` is a `Stream<Request, E, R>`. `never` for anything else.
20
+ */
21
+ export type EffectMethod<Method> = Method extends (
22
+ options?: CallOptions,
23
+ ) => Promise<BidirectionalStreamingCall<infer Request, infer Response>>
24
+ ? <E = never, R = never>(
25
+ requests: Stream.Stream<Request, E, R>,
26
+ options?: CallOptions,
27
+ ) => Stream.Stream<Response, TrevRpcError | E, R>
28
+ : Method extends (
29
+ options?: CallOptions,
30
+ ) => Promise<ClientStreamingCall<infer Request, infer Response>>
31
+ ? <E = never, R = never>(
32
+ requests: Stream.Stream<Request, E, R>,
33
+ options?: CallOptions,
34
+ ) => Effect.Effect<Response, TrevRpcError | E, R>
35
+ : Method extends (
36
+ request: infer Request,
37
+ options?: CallOptions,
38
+ ) => Promise<ResponseStream<infer Response>>
39
+ ? (request: Request, options?: CallOptions) => Stream.Stream<Response, TrevRpcError>
40
+ : Method extends (request: infer Request, options?: CallOptions) => Promise<infer Response>
41
+ ? (request: Request, options?: CallOptions) => Effect.Effect<Response, TrevRpcError>
42
+ : never;
43
+
44
+ /** A generated client with its RPC methods in Effect form; other members are left out. */
45
+ export type EffectClient<Client> = {
46
+ [Name in keyof Client as EffectMethod<Client[Name]> extends never ? never : Name]: EffectMethod<
47
+ Client[Name]
48
+ >;
49
+ };
50
+
51
+ /**
52
+ * Wraps a generated client's methods in Effect. `service` is the client's generated descriptor,
53
+ * such as `GreeterService` for a `GreeterClient`.
54
+ *
55
+ * Interrupting an effect or stream cancels its call, which the server sees as `Code.Cancelled`.
56
+ * A `signal` in the call options cancels it too. Client-streaming and bidirectional calls pull
57
+ * their requests from the stream as the transport accepts them. When the server ends the call
58
+ * first, the rest of the stream is not pulled, and the server's outcome is the result. A request
59
+ * stream that fails cancels the call, and the effect or stream fails with the request stream's
60
+ * error.
61
+ *
62
+ * Calls fail with a `TrevRpcError`; anything else a call rejects with becomes one with
63
+ * `Code.Unknown`, and with the original as `cause`.
64
+ *
65
+ * @throws TypeError if `client` lacks a method of `service`.
66
+ */
67
+ export function createEffectClient<Client extends object>(
68
+ service: RpcServiceDescriptor,
69
+ client: Client,
70
+ ): EffectClient<Client>;
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,146 @@
1
+ import { Code, TrevRpcError } 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
+ * Wraps a generated client's methods in Effect: unary calls return an `Effect`, server-streaming
15
+ * calls a `Stream`, and client-streaming and bidirectional calls take their requests as a
16
+ * `Stream`. Interrupting the effect or stream cancels the call.
17
+ */
18
+ export function createEffectClient(service, client) {
19
+ const result = {};
20
+ for (const [name, method] of Object.entries(service.methods)) {
21
+ if (typeof client[name] !== "function") {
22
+ throw new TypeError(`client has no ${name} method for ${service.fullName}/${method.name}`);
23
+ }
24
+ const call = client[name].bind(client);
25
+ switch (method.kind) {
26
+ case "unary":
27
+ result[name] = liftUnary(call);
28
+ break;
29
+ case "serverStreaming":
30
+ result[name] = liftServerStreaming(call);
31
+ break;
32
+ case "clientStreaming":
33
+ result[name] = liftClientStreaming(call);
34
+ break;
35
+ case "bidirectionalStreaming":
36
+ result[name] = liftBidirectionalStreaming(call);
37
+ break;
38
+ default:
39
+ throw new TypeError(
40
+ `unknown kind ${String(method.kind)} of ${service.fullName}/${method.name}`,
41
+ );
42
+ }
43
+ }
44
+ return result;
45
+ }
46
+
47
+ function liftUnary(call) {
48
+ return (request, options) =>
49
+ Effect.tryPromise({
50
+ try: (signal) => call(request, withSignal(options, signal)),
51
+ catch: toTrevRpcError,
52
+ });
53
+ }
54
+
55
+ function liftServerStreaming(call) {
56
+ return (request, options) =>
57
+ Stream.unwrap(
58
+ Effect.gen(function* () {
59
+ const signal = yield* abortOnClose;
60
+ const responses = yield* open(() => call(request, withSignal(options, signal)));
61
+ return Stream.fromAsyncIterable(responses, toTrevRpcError);
62
+ }),
63
+ );
64
+ }
65
+
66
+ function liftClientStreaming(call) {
67
+ return (requests, options) =>
68
+ Effect.scoped(
69
+ Effect.gen(function* () {
70
+ const signal = yield* abortOnClose;
71
+ const stream = yield* open(() => call(withSignal(options, signal)));
72
+ // The server may answer before the upload is done; its status ends the upload, and
73
+ // closeAndRecv() reports the outcome.
74
+ yield* Effect.raceFirst(upload(stream, requests), settled(stream.status));
75
+ return yield* Effect.tryPromise({
76
+ try: () => stream.closeAndRecv(),
77
+ catch: toTrevRpcError,
78
+ });
79
+ }),
80
+ );
81
+ }
82
+
83
+ function liftBidirectionalStreaming(call) {
84
+ return (requests, options) =>
85
+ Stream.unwrap(
86
+ Effect.gen(function* () {
87
+ const signal = yield* abortOnClose;
88
+ const stream = yield* open(() => call(withSignal(options, signal)));
89
+ const sending = upload(stream, requests).pipe(
90
+ // closeSend() resolves when the server already ended the call; any other failure is
91
+ // the response's to report.
92
+ Effect.andThen(Effect.promise(() => stream.closeSend().catch(ignore))),
93
+ );
94
+ // The responses decide when the stream ends: their end stops an unfinished upload,
95
+ // while a failed request stream fails the whole stream.
96
+ return Stream.merge(
97
+ Stream.fromAsyncIterable(stream, toTrevRpcError),
98
+ Stream.drain(Stream.fromEffect(sending)),
99
+ { haltStrategy: "left" },
100
+ );
101
+ }),
102
+ );
103
+ }
104
+
105
+ /**
106
+ * A signal that aborts when the scope closes, cancelling a call that has not finished: on
107
+ * interruption, on failure, and after the call ended, when aborting has no effect.
108
+ */
109
+ const abortOnClose = Effect.map(
110
+ Effect.acquireRelease(
111
+ Effect.sync(() => new AbortController()),
112
+ (controller) => Effect.sync(() => controller.abort()),
113
+ ),
114
+ (controller) => controller.signal,
115
+ );
116
+
117
+ /** Starts a call, whose promise settles once the call is open. */
118
+ function open(start) {
119
+ return Effect.tryPromise({ try: start, catch: toTrevRpcError });
120
+ }
121
+
122
+ /** The call options with `signal` added to the caller's own, either of which cancels the call. */
123
+ function withSignal(options, signal) {
124
+ const own = options?.signal;
125
+ return { ...options, signal: own ? AbortSignal.any([own, signal]) : signal };
126
+ }
127
+
128
+ /**
129
+ * Sends each request of `requests`, waiting for the transport to accept it. Stops without an error
130
+ * at the first rejected send, which means the call has ended; the call reports its outcome. A
131
+ * failing request stream fails with its error, and the caller's scope then cancels the call.
132
+ */
133
+ function upload(stream, requests) {
134
+ return Stream.runForEachWhile(requests, (request) =>
135
+ Effect.promise(() => stream.send(request).then(accepted, rejected)),
136
+ );
137
+ }
138
+
139
+ /** Succeeds once `promise` settles, whatever its outcome. */
140
+ function settled(promise) {
141
+ return Effect.promise(() => promise.then(ignore, ignore));
142
+ }
143
+
144
+ const accepted = () => true;
145
+ const rejected = () => false;
146
+ const ignore = () => {};