@trevrpc/trevrpc-js 0.2.3 → 1.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.
Files changed (47) hide show
  1. package/README.md +127 -46
  2. package/package.json +12 -45
  3. package/src/bytes.js +10 -0
  4. package/src/call.js +520 -0
  5. package/src/channel.js +258 -288
  6. package/src/client.js +543 -1254
  7. package/src/datagram.js +336 -0
  8. package/src/framing.js +151 -548
  9. package/src/generator.js +66 -109
  10. package/src/index.d.ts +235 -463
  11. package/src/index.js +4 -3
  12. package/src/qmux/frames.js +262 -0
  13. package/src/qmux/params.js +104 -0
  14. package/src/qmux/session.js +739 -0
  15. package/src/race.js +90 -0
  16. package/src/status.js +24 -70
  17. package/src/varint.js +73 -0
  18. package/src/wire.js +21 -89
  19. package/examples/greeter/README.md +0 -35
  20. package/examples/greeter/client.js +0 -172
  21. package/examples/greeter/greeter.node.trevrpc.d.ts +0 -53
  22. package/examples/greeter/greeter.node.trevrpc.js +0 -9
  23. package/examples/greeter/greeter.proto +0 -18
  24. package/examples/greeter/greeter.trevrpc.d.ts +0 -55
  25. package/examples/greeter/greeter.trevrpc.js +0 -103
  26. package/examples/greeter/index.html +0 -140
  27. package/examples/greeter/static-server.js +0 -168
  28. package/src/advanced.d.ts +0 -29
  29. package/src/advanced.js +0 -1
  30. package/src/browser-channel.js +0 -49
  31. package/src/connect.js +0 -6
  32. package/src/connect.node.js +0 -6
  33. package/src/index.node.d.ts +0 -10
  34. package/src/index.node.js +0 -3
  35. package/src/native-loader.js +0 -118
  36. package/src/node-advanced.d.ts +0 -27
  37. package/src/node-advanced.js +0 -1
  38. package/src/node-generated.d.ts +0 -24
  39. package/src/node-generated.js +0 -126
  40. package/src/node-index.d.ts +0 -1
  41. package/src/node-index.js +0 -7
  42. package/src/node.d.ts +0 -298
  43. package/src/node.js +0 -1456
  44. package/src/runtime.js +0 -66
  45. package/src/server-response.js +0 -49
  46. package/src/timer.js +0 -29
  47. package/src/webtransport.js +0 -639
package/src/call.js ADDED
@@ -0,0 +1,520 @@
1
+ import { datagramEndpoint, messageFrameSize } from "./datagram.js";
2
+ import {
3
+ DefaultMaxFrameSize,
4
+ FrameReader,
5
+ encodeMessageFrame,
6
+ encodeRequestFrame,
7
+ } from "./framing.js";
8
+ import { normalizeMetadata, validateMetadata } from "./metadata.js";
9
+ import {
10
+ Code,
11
+ TrevRpcError,
12
+ cancelled,
13
+ deadlineExceeded,
14
+ failedPrecondition,
15
+ invalidArgument,
16
+ statusError,
17
+ statusFromTransportError,
18
+ } from "./status.js";
19
+
20
+ /** Longest delay setTimeout can represent; it fires immediately beyond this. */
21
+ const MaxTimerDelayMs = 2_147_483_647;
22
+ const MaxUint64 = 0xffffffffffffffffn;
23
+
24
+ /** Returns the default call options. */
25
+ export function defaultCallOptions() {
26
+ return {
27
+ timeoutMs: undefined,
28
+ maxFrameSize: DefaultMaxFrameSize,
29
+ metadata: {},
30
+ signal: undefined,
31
+ };
32
+ }
33
+
34
+ /** Merges override options over resolved base options; metadata merges per key. */
35
+ export function mergeCallOptions(base, override = {}) {
36
+ const merged = { ...base };
37
+ for (const [key, value] of Object.entries(override)) {
38
+ if (value !== undefined && key !== "metadata") {
39
+ merged[key] = value;
40
+ }
41
+ }
42
+ if (override.metadata != null) {
43
+ merged.metadata = Object.assign(
44
+ Object.create(null),
45
+ base.metadata,
46
+ normalizeMetadata(override.metadata),
47
+ );
48
+ validateMetadata(merged.metadata);
49
+ }
50
+ return merged;
51
+ }
52
+
53
+ /**
54
+ * Cancellation and deadline for one call. The signal aborts with a TrevRpcError reason: the
55
+ * failure that ended the call, the caller's own status error, DeadlineExceeded, or Cancelled
56
+ * wrapping any other abort reason.
57
+ */
58
+ class CallScope {
59
+ #controller = new AbortController();
60
+ #parent;
61
+ #timer;
62
+
63
+ /** timeoutMs must be validated by the caller. */
64
+ constructor({ signal, timeoutMs }) {
65
+ if (signal?.aborted) {
66
+ this.abort(signal.reason);
67
+ return;
68
+ }
69
+ if (signal != null) {
70
+ this.#parent = { signal, listener: () => this.abort(signal.reason) };
71
+ signal.addEventListener("abort", this.#parent.listener, { once: true });
72
+ }
73
+ if (timeoutMs != null && timeoutMs <= MaxTimerDelayMs) {
74
+ this.#timer = setTimeout(
75
+ () => this.abort(deadlineExceeded("RPC deadline exceeded")),
76
+ timeoutMs,
77
+ );
78
+ }
79
+ }
80
+
81
+ get signal() {
82
+ return this.#controller.signal;
83
+ }
84
+
85
+ /** Aborts the call; only the first reason counts. Returns the effective reason. */
86
+ abort(reason) {
87
+ if (!this.signal.aborted) {
88
+ this.settle();
89
+ this.#controller.abort(
90
+ reason instanceof TrevRpcError ? reason : cancelled("RPC cancelled", reason),
91
+ );
92
+ }
93
+ return this.signal.reason;
94
+ }
95
+
96
+ /** Stops the deadline and detaches from the caller's signal once the outcome is known. */
97
+ settle() {
98
+ clearTimeout(this.#timer);
99
+ this.#parent?.signal.removeEventListener("abort", this.#parent.listener);
100
+ this.#parent = undefined;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * One RPC over one bidirectional stream: the request writer, the response frame reader, and the
106
+ * call's cancellation scope. Transport failures surface as TrevRpcError, and once the call is
107
+ * aborted every operation fails with the abort reason.
108
+ */
109
+ export class Call {
110
+ #scope;
111
+ #writer;
112
+ #cancelReader;
113
+ #frames;
114
+ #maxFrameSize;
115
+ #sendClosed = false;
116
+ /** Set once the close of the request side completed. */
117
+ #closeCompleted = false;
118
+ /**
119
+ * The error every request-side operation fails with once the upload was ended, or undefined
120
+ * while it is open.
121
+ */
122
+ #uploadEnded;
123
+ /** Rejectors of the request-side operations waiting for the transport. */
124
+ #pendingUploads = new Set();
125
+ /** The transport error that made the request side fail on its own, or undefined. */
126
+ #uploadFailure;
127
+ /** The call's datagram flow, or undefined if its messages all travel on the stream. */
128
+ #flow;
129
+ /** Whether request messages are sent as datagrams when they fit. */
130
+ #sendDatagrams = false;
131
+ /** Request messages of a call that uses datagrams, queued for the flush loop, in order. */
132
+ #unsent = [];
133
+ /** Settles when the flush loop has sent everything queued so far. */
134
+ #sendTail = Promise.resolve();
135
+ #flushing = false;
136
+
137
+ /**
138
+ * Opens a stream on the transport and sends the REQUEST frame. The request carries the
139
+ * method path and kind; metadata and the wire timeout come from the options. The request
140
+ * message of a unary or server-streaming call goes out in the same write.
141
+ *
142
+ * `datagrams` is `{ send, receive }` for a method that marks a direction unreliable: the call
143
+ * gets a datagram flow if the session that carries it has datagrams, which is only known once
144
+ * its stream is open, so the REQUEST is encoded after that. `send` makes it send request
145
+ * messages as datagrams, `receive` makes it accept response datagrams.
146
+ */
147
+ static async open(transport, request, message, options, datagrams) {
148
+ const fields = { ...request, metadata: options.metadata, ...timeoutField(options) };
149
+ const header =
150
+ datagrams === undefined ? encodeRequestFrame(fields, message, options.maxFrameSize) : null;
151
+ const scope = new CallScope(options);
152
+ let call;
153
+ try {
154
+ const session = datagrams === undefined ? undefined : {};
155
+ const stream = await openStream(transport, scope.signal, session);
156
+ call = new Call(stream, scope, options.maxFrameSize);
157
+ await call.#write(header ?? call.#requestWithFlow(fields, message, session, datagrams));
158
+ return call;
159
+ } catch (error) {
160
+ if (call !== undefined) {
161
+ call.#releaseFlow();
162
+ }
163
+ throw scope.abort(failureStatus(error));
164
+ }
165
+ }
166
+
167
+ /** Opens the call's flow on its session, if it has datagrams, and encodes the REQUEST. */
168
+ #requestWithFlow(fields, message, session, { send, receive }) {
169
+ this.#flow = datagramEndpoint(session.datagrams)?.openFlow({ receive });
170
+ this.#sendDatagrams = this.#flow !== undefined && send;
171
+ return encodeRequestFrame(
172
+ this.#flow === undefined ? fields : { ...fields, datagramFlow: this.#flow.id },
173
+ message,
174
+ this.#maxFrameSize,
175
+ );
176
+ }
177
+
178
+ constructor(stream, scope, maxFrameSize) {
179
+ const reader = stream.readable.getReader();
180
+ this.#scope = scope;
181
+ this.#writer = stream.writable.getWriter();
182
+ this.#cancelReader = (reason) => reader.cancel(reason);
183
+ this.#frames = new FrameReader(reader);
184
+ this.#maxFrameSize = maxFrameSize;
185
+ // The transport errors the request side as soon as the server stops reading it.
186
+ this.#writer.closed.catch((error) => {
187
+ if (this.#uploadEnded === undefined) {
188
+ this.#uploadFailure ??= statusFromTransportError(error);
189
+ }
190
+ });
191
+ scope.signal.addEventListener("abort", () => this.#teardown(), { once: true });
192
+ if (scope.signal.aborted) {
193
+ this.#teardown();
194
+ }
195
+ }
196
+
197
+ /** The call's abort signal. */
198
+ get signal() {
199
+ return this.#scope.signal;
200
+ }
201
+
202
+ /**
203
+ * The transport error that made the request side fail on its own, for instance because the
204
+ * server stopped reading it; undefined while the request side works or was ended by the call.
205
+ */
206
+ get uploadFailure() {
207
+ return this.#uploadFailure;
208
+ }
209
+
210
+ /** Whether the request side can still be written: not ended, not closed. */
211
+ get uploadOpen() {
212
+ return this.#uploadEnded === undefined && !this.#closeCompleted;
213
+ }
214
+
215
+ /** The largest frame the call accepts and sends, in bytes. */
216
+ get maxFrameSize() {
217
+ return this.#maxFrameSize;
218
+ }
219
+
220
+ /** The call's datagram flow, or undefined if its messages all travel on the stream. */
221
+ get flow() {
222
+ return this.#flow;
223
+ }
224
+
225
+ /**
226
+ * Sends one streaming request message; writes are ordered and apply backpressure. Fails with
227
+ * the error that ended the upload, if any, even while the transport itself is still blocked.
228
+ * On a call that sends datagrams the message goes out as one if it fits, and on the stream
229
+ * otherwise.
230
+ */
231
+ sendMessage(body) {
232
+ if (this.#uploadEnded !== undefined) {
233
+ return Promise.reject(this.#uploadEnded);
234
+ }
235
+ if (this.#sendClosed) {
236
+ return Promise.reject(failedPrecondition("request stream is closed"));
237
+ }
238
+ if (this.#sendDatagrams) {
239
+ return this.#upload(
240
+ new Promise((resolve, reject) => {
241
+ this.#unsent.push({ body, resolve, reject });
242
+ if (!this.#flushing) {
243
+ this.#flushing = true;
244
+ // Messages sent in the same turn are packed together; nothing waits for a timer.
245
+ this.#sendTail = Promise.resolve().then(() => this.#flush());
246
+ }
247
+ }),
248
+ );
249
+ }
250
+ return this.#write(encodeMessageFrame(body, this.#maxFrameSize));
251
+ }
252
+
253
+ /**
254
+ * Sends the queued request messages in order: as many as fit into one datagram go together, a
255
+ * message that cannot be a datagram goes on the stream, and so does every message of a datagram
256
+ * that failed to send. Messages queued while a datagram waits for send-buffer space join the
257
+ * next one.
258
+ */
259
+ async #flush() {
260
+ let inFlight = [];
261
+ try {
262
+ while (this.#unsent.length > 0) {
263
+ if (this.#uploadEnded !== undefined) {
264
+ throw this.#uploadEnded;
265
+ }
266
+ inFlight = this.#takeBatch();
267
+ if (inFlight.length === 0 || !(await this.#flow.send(inFlight.map(bodyOf), this.signal))) {
268
+ // Not a datagram: a head message that never fits, or a datagram that failed to send.
269
+ inFlight = inFlight.length > 0 ? inFlight : [this.#unsent.shift()];
270
+ for (const { body } of inFlight) {
271
+ await this.#writer.write(encodeMessageFrame(body, this.#maxFrameSize));
272
+ }
273
+ }
274
+ for (const item of inFlight) {
275
+ item.resolve();
276
+ }
277
+ inFlight = [];
278
+ }
279
+ } catch (error) {
280
+ for (const item of [...inFlight, ...this.#unsent.splice(0)]) {
281
+ item.reject(error);
282
+ }
283
+ } finally {
284
+ this.#flushing = false;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Removes the longest run of queued messages from the front that fits one datagram, at least
290
+ * one if the head fits alone; empty if the head can only go on the stream.
291
+ */
292
+ #takeBatch() {
293
+ let room = this.#flow.frameRoom;
294
+ let count = 0;
295
+ while (count < this.#unsent.length) {
296
+ const { body } = this.#unsent[count];
297
+ const size = messageFrameSize(body);
298
+ if (body.byteLength > this.#maxFrameSize || size > room) {
299
+ break;
300
+ }
301
+ room -= size;
302
+ count += 1;
303
+ }
304
+ return this.#unsent.splice(0, count);
305
+ }
306
+
307
+ /** Finishes the request stream gracefully. */
308
+ closeSend() {
309
+ if (this.#sendClosed) {
310
+ return Promise.resolve();
311
+ }
312
+ this.#sendClosed = true;
313
+ // Datagram sends in flight go first, so the stream does not end before them.
314
+ const closing = this.#sendDatagrams
315
+ ? this.#sendTail.then(() => this.#writer.close())
316
+ : this.#writer.close();
317
+ return this.#upload(closing).then(() => {
318
+ this.#closeCompleted = true;
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Reads the next known response frame as `{ type, payload }`, or null if the stream ended
324
+ * cleanly. Unknown frames are skipped.
325
+ */
326
+ readFrame() {
327
+ return this.#guard(this.#frames.readFrame(this.#maxFrameSize));
328
+ }
329
+
330
+ /**
331
+ * Ends the call after its final status was received. The request side stops: pending and later
332
+ * sends fail with `uploadError`, and an unfinished upload is reset. The rest of the response
333
+ * stream is drained in the background: unknown frames are skipped, and a known frame after
334
+ * STATUS cancels the stream without changing the outcome.
335
+ */
336
+ finish(uploadError) {
337
+ this.#scope.settle();
338
+ this.#releaseFlow();
339
+ this.#endUpload(uploadError, cancelled("RPC completed"));
340
+ this.#frames.expectEnd(this.#maxFrameSize).catch(() => this.#cancelReader().catch(() => {}));
341
+ }
342
+
343
+ /** Cancels the call on behalf of the caller; any reason becomes a Cancelled status. */
344
+ cancel(reason) {
345
+ this.#scope.abort(reason);
346
+ }
347
+
348
+ /** Fails the call, tearing down both stream directions. Returns the effective error. */
349
+ fail(error) {
350
+ return this.#scope.abort(failureStatus(error));
351
+ }
352
+
353
+ #write(bytes) {
354
+ return this.#upload(this.#writer.write(bytes));
355
+ }
356
+
357
+ /**
358
+ * Waits for a write or close of the request side. Ending the upload settles it at once: a
359
+ * transport blocked on flow control does not act on an abort while the write is in flight.
360
+ */
361
+ #upload(operation) {
362
+ return new Promise((resolve, reject) => {
363
+ this.#pendingUploads.add(reject);
364
+ operation.then(
365
+ (value) => {
366
+ this.#pendingUploads.delete(reject);
367
+ if (this.signal.aborted) {
368
+ reject(this.signal.reason);
369
+ } else {
370
+ resolve(value);
371
+ }
372
+ },
373
+ (error) => {
374
+ this.#pendingUploads.delete(reject);
375
+ reject(this.#uploadError(error));
376
+ },
377
+ );
378
+ });
379
+ }
380
+
381
+ /** Maps a failed request-side operation to the call's error. */
382
+ #uploadError(error) {
383
+ if (this.signal.aborted) {
384
+ return this.signal.reason;
385
+ }
386
+ if (error === this.#uploadEnded) {
387
+ return error;
388
+ }
389
+ this.#uploadFailure ??= statusFromTransportError(error);
390
+ return this.#uploadFailure;
391
+ }
392
+
393
+ /** Stops the request side with `error` for everything waiting on it; only the first call counts. */
394
+ #endUpload(error, abortReason = error) {
395
+ if (this.#uploadEnded !== undefined) {
396
+ return;
397
+ }
398
+ this.#uploadEnded = error;
399
+ this.#sendClosed = true;
400
+ this.#writer.abort(abortReason).catch(() => {});
401
+ const pending = [...this.#pendingUploads];
402
+ this.#pendingUploads.clear();
403
+ for (const reject of pending) {
404
+ reject(error);
405
+ }
406
+ }
407
+
408
+ async #guard(operation) {
409
+ let result;
410
+ try {
411
+ result = await operation;
412
+ } catch (error) {
413
+ throw this.signal.aborted ? this.signal.reason : statusFromTransportError(error);
414
+ }
415
+ // Cancelling the reader completes pending reads as end-of-stream instead of rejecting them.
416
+ throwIfAborted(this.signal);
417
+ return result;
418
+ }
419
+
420
+ /** Ends the datagram flow, if any; every way a call ends gets here. */
421
+ #releaseFlow() {
422
+ this.#flow?.release();
423
+ }
424
+
425
+ #teardown() {
426
+ this.#releaseFlow();
427
+ const reason = this.signal.reason;
428
+ this.#endUpload(reason);
429
+ this.#cancelReader(reason).catch(() => {});
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Opens a bidirectional stream. A call that may use datagrams passes `session`, which receives the
435
+ * datagram object of the session that opened the stream, so the flow belongs to the connection
436
+ * that carries the call and not to one a reconnect swapped in later.
437
+ */
438
+ function openStream(transport, signal, session) {
439
+ return new Promise((resolve, reject) => {
440
+ if (signal.aborted) {
441
+ reject(signal.reason);
442
+ return;
443
+ }
444
+ const onAbort = () => reject(signal.reason);
445
+ signal.addEventListener("abort", onAbort, { once: true });
446
+
447
+ let opening;
448
+ try {
449
+ opening = Promise.resolve(startStream(transport, session));
450
+ } catch (error) {
451
+ opening = Promise.reject(error);
452
+ }
453
+ opening.then(
454
+ (stream) => {
455
+ signal.removeEventListener("abort", onAbort);
456
+ if (signal.aborted) {
457
+ // The caller gave up while the stream was opening; do not leak it.
458
+ stream.writable.abort(signal.reason).catch(() => {});
459
+ stream.readable.cancel(signal.reason).catch(() => {});
460
+ } else {
461
+ resolve(stream);
462
+ }
463
+ },
464
+ (error) => {
465
+ signal.removeEventListener("abort", onAbort);
466
+ reject(statusFromTransportError(error));
467
+ },
468
+ );
469
+ });
470
+ }
471
+
472
+ function bodyOf(item) {
473
+ return item.body;
474
+ }
475
+
476
+ function startStream(transport, session) {
477
+ if (session === undefined) {
478
+ return transport.createBidirectionalStream();
479
+ }
480
+ if (typeof transport.createBidirectionalStreamWithDatagrams === "function") {
481
+ return transport.createBidirectionalStreamWithDatagrams().then(({ stream, datagrams }) => {
482
+ session.datagrams = datagrams;
483
+ return stream;
484
+ });
485
+ }
486
+ // A WebTransport session used directly: its datagrams belong to the stream opened right now.
487
+ session.datagrams = transport.datagrams;
488
+ return transport.createBidirectionalStream();
489
+ }
490
+
491
+ function throwIfAborted(signal) {
492
+ if (signal.aborted) {
493
+ throw signal.reason;
494
+ }
495
+ }
496
+
497
+ /** Status errors describe themselves; anything else reaching the call is an internal bug. */
498
+ function failureStatus(error) {
499
+ return error instanceof TrevRpcError
500
+ ? error
501
+ : statusError(Code.Internal, error?.message ?? String(error), {}, error);
502
+ }
503
+
504
+ /**
505
+ * Validates a call timeout and returns the request field for it. Zero means unset on the wire, so
506
+ * a zero timeout is sent as 1ns.
507
+ */
508
+ function timeoutField({ timeoutMs }) {
509
+ if (timeoutMs == null) {
510
+ return {};
511
+ }
512
+ if (!(timeoutMs >= 0 && Number.isFinite(timeoutMs))) {
513
+ throw invalidArgument("RPC timeout must be a non-negative finite number of milliseconds");
514
+ }
515
+ const nanos = BigInt(Math.ceil(timeoutMs)) * 1_000_000n;
516
+ if (nanos > MaxUint64) {
517
+ throw invalidArgument("RPC timeout does not fit in 64-bit nanoseconds");
518
+ }
519
+ return { timeoutNanos: String(nanos === 0n ? 1n : nanos) };
520
+ }