starpc 0.49.17 → 0.49.20

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 (73) hide show
  1. package/README.md +1 -3
  2. package/cmd/protoc-gen-es-starpc/typescript.ts +11 -6
  3. package/dist/cmd/protoc-gen-es-starpc/typescript.js +6 -5
  4. package/dist/echo/echo.pb.d.ts +1 -1
  5. package/dist/echo/echo.pb.js +3 -3
  6. package/dist/integration/cross-language/ts-client.js +84 -11
  7. package/dist/integration/cross-language/ts-server.js +84 -4
  8. package/dist/mock/mock.pb.d.ts +1 -1
  9. package/dist/mock/mock.pb.js +3 -3
  10. package/dist/rpcstream/rpcstream.pb.d.ts +1 -1
  11. package/dist/rpcstream/rpcstream.pb.js +14 -8
  12. package/dist/srpc/call-receipt.d.ts +17 -0
  13. package/dist/srpc/call-receipt.js +105 -0
  14. package/dist/srpc/call-receipt.test.d.ts +1 -0
  15. package/dist/srpc/call-receipt.test.js +374 -0
  16. package/dist/srpc/client.d.ts +3 -1
  17. package/dist/srpc/client.js +20 -0
  18. package/dist/srpc/common-rpc.d.ts +13 -1
  19. package/dist/srpc/common-rpc.js +86 -6
  20. package/dist/srpc/handler.d.ts +2 -1
  21. package/dist/srpc/index.d.ts +4 -0
  22. package/dist/srpc/index.js +2 -0
  23. package/dist/srpc/invoker.d.ts +2 -1
  24. package/dist/srpc/invoker.js +2 -2
  25. package/dist/srpc/rpcproto.pb.d.ts +1 -1
  26. package/dist/srpc/rpcproto.pb.js +7 -7
  27. package/dist/srpc/server-invocation.d.ts +17 -0
  28. package/dist/srpc/server-invocation.js +37 -0
  29. package/dist/srpc/server-rpc.js +3 -1
  30. package/echo/echo.pb.go +1 -1
  31. package/echo/echo.pb.ts +6 -5
  32. package/echo/echo_srpc.pb.cpp +1 -1
  33. package/echo/echo_srpc.pb.go +1 -1
  34. package/echo/echo_srpc.pb.hpp +1 -1
  35. package/echo/echo_srpc.pb.rs +1 -1
  36. package/integration/cross-language/go-client/main.go +137 -5
  37. package/integration/cross-language/go-server/main.go +146 -3
  38. package/integration/cross-language/run.bash +117 -13
  39. package/integration/cross-language/ts-client.ts +94 -13
  40. package/integration/cross-language/ts-server.ts +94 -6
  41. package/mock/mock.pb.go +1 -1
  42. package/mock/mock.pb.ts +6 -5
  43. package/mock/mock_srpc.pb.cpp +1 -1
  44. package/mock/mock_srpc.pb.go +1 -1
  45. package/mock/mock_srpc.pb.hpp +1 -1
  46. package/mock/mock_srpc.pb.rs +1 -1
  47. package/package.json +1 -1
  48. package/srpc/call-receipt-e2e_test.go +111 -0
  49. package/srpc/call-receipt.go +112 -0
  50. package/srpc/call-receipt.test.ts +438 -0
  51. package/srpc/call-receipt.ts +130 -0
  52. package/srpc/call-receipt_test.go +536 -0
  53. package/srpc/client-rpc.go +1 -8
  54. package/srpc/client.ts +29 -2
  55. package/srpc/common-rpc.go +134 -29
  56. package/srpc/common-rpc.ts +98 -8
  57. package/srpc/common-rpc_test.go +52 -0
  58. package/srpc/handler.ts +2 -0
  59. package/srpc/index.ts +4 -0
  60. package/srpc/invoker.ts +10 -5
  61. package/srpc/msg-stream.go +8 -0
  62. package/srpc/muxed-conn.go +8 -3
  63. package/srpc/muxed-conn_test.go +79 -0
  64. package/srpc/rpcproto.pb.go +1 -1
  65. package/srpc/rpcproto.pb.ts +28 -27
  66. package/srpc/server-invocation.go +58 -0
  67. package/srpc/server-invocation.ts +82 -0
  68. package/srpc/server-rpc-invoke.go +7 -0
  69. package/srpc/server-rpc-invoke_goscript.go +12 -0
  70. package/srpc/server-rpc-invoke_goscript_test.go +27 -0
  71. package/srpc/server-rpc.go +9 -2
  72. package/srpc/server-rpc.ts +6 -2
  73. package/srpc/stream.go +20 -0
@@ -0,0 +1,112 @@
1
+ package srpc
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "sync/atomic"
7
+
8
+ "github.com/pkg/errors"
9
+ )
10
+
11
+ // ExecCallReceipt executes a unary call over cc.NewStream, reads the single
12
+ // response into out, and returns a held receipt. The caller must dispose it
13
+ // with Commit, Abort, or Close.
14
+ func ExecCallReceipt(
15
+ ctx context.Context,
16
+ cc Client,
17
+ service, method string,
18
+ in, out Message,
19
+ ) (*CallReceipt, error) {
20
+ strm, err := cc.NewStream(ctx, service, method, in)
21
+ if err != nil {
22
+ return nil, err
23
+ }
24
+ if err := strm.MsgRecv(out); err != nil {
25
+ _ = strm.Close()
26
+ return nil, err
27
+ }
28
+ return &CallReceipt{strm: strm}, nil
29
+ }
30
+
31
+ // CallReceipt holds a unary call until the server finalizes it.
32
+ type CallReceipt struct {
33
+ strm Stream
34
+ disposed atomic.Bool
35
+ }
36
+
37
+ // Context returns the context of the held call.
38
+ func (r *CallReceipt) Context() context.Context {
39
+ return r.strm.Context()
40
+ }
41
+
42
+ // Commit sends request completion and waits for the server completion
43
+ // acknowledgment before releasing the stream.
44
+ func (r *CallReceipt) Commit() error {
45
+ if r.disposed.Swap(true) {
46
+ return nil
47
+ }
48
+
49
+ if r.strm.Context().Err() != nil {
50
+ _ = r.strm.Close()
51
+ return context.Canceled
52
+ }
53
+ if err := r.strm.CloseSend(); err != nil {
54
+ _ = r.strm.Close()
55
+ return err
56
+ }
57
+
58
+ var done receiptDone
59
+ err := r.strm.MsgRecv(&done)
60
+ _ = r.strm.Close()
61
+ if err == io.EOF {
62
+ receipt, ok := r.strm.(receiptTerminalStream)
63
+ if ok {
64
+ terminal, terminalOK := receipt.receiptTerminalKind()
65
+ if terminalOK && terminal == TerminalCommitted {
66
+ return nil
67
+ }
68
+ }
69
+ return errors.New("missing trailing completion acknowledgment")
70
+ }
71
+ if err == nil {
72
+ return errors.New("unexpected trailing response data")
73
+ }
74
+ return err
75
+ }
76
+
77
+ // Abort sends request cancellation and releases the stream.
78
+ func (r *CallReceipt) Abort() error {
79
+ if r.disposed.Swap(true) {
80
+ return nil
81
+ }
82
+ return r.strm.Close()
83
+ }
84
+
85
+ // Close aborts the held call unless it has already reached a terminal.
86
+ func (r *CallReceipt) Close() error {
87
+ return r.Abort()
88
+ }
89
+
90
+ // receiptDone is a no-op message used to read the trailing completion packet.
91
+ type receiptDone struct{}
92
+
93
+ func (*receiptDone) SizeVT() int {
94
+ return 0
95
+ }
96
+
97
+ func (*receiptDone) MarshalToSizedBufferVT([]byte) (int, error) {
98
+ return 0, nil
99
+ }
100
+
101
+ func (*receiptDone) MarshalVT() ([]byte, error) {
102
+ return nil, nil
103
+ }
104
+
105
+ func (*receiptDone) UnmarshalVT([]byte) error {
106
+ return nil
107
+ }
108
+
109
+ func (*receiptDone) Reset() {}
110
+
111
+ // _ is a type assertion.
112
+ var _ Message = (*receiptDone)(nil)
@@ -0,0 +1,438 @@
1
+ /// <reference lib="es2024.promise" />
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { pushable } from 'it-pushable'
5
+ import { EchoMsg } from '../echo/echo.pb.js'
6
+ import type { Echoer } from '../echo/echo_srpc.pb.js'
7
+
8
+ import { Client } from './client.js'
9
+ import { ClientRPC } from './client-rpc.js'
10
+ import { Server } from './server.js'
11
+ import { CallReceipt } from './call-receipt.js'
12
+ import { Packet } from './rpcproto.pb.js'
13
+ import { ServerRPC } from './server-rpc.js'
14
+ import { ServerInvocation, type TerminalKind } from './server-invocation.js'
15
+
16
+ const response = new Uint8Array([1, 2, 3])
17
+
18
+ describe('generated service compatibility', () => {
19
+ it('accepts plain abort signals and terminal-capable handlers', async () => {
20
+ const server: Pick<Echoer, 'Echo'> = {
21
+ Echo: async (request, invocation?: ServerInvocation) => {
22
+ void invocation
23
+ return request
24
+ },
25
+ }
26
+ const request = EchoMsg.create({ body: 'compatibility' })
27
+ const signal = new AbortController().signal
28
+ await expect(server.Echo(request, signal)).resolves.toEqual(request)
29
+ })
30
+ })
31
+
32
+ describe('held unary receipt', () => {
33
+ it('waits for a server completion acknowledgment', async () => {
34
+ const sent: Packet[] = []
35
+ const terminal = Promise.withResolvers<void>()
36
+ const client = new Client(async () => ({
37
+ source: responseSource(terminal.promise),
38
+ sink: async (source) => {
39
+ for await (const data of source) {
40
+ const packet = Packet.fromBinary(data)
41
+ sent.push(packet)
42
+ if (packet.body?.case === 'callData' && packet.body.value.complete) {
43
+ terminal.resolve()
44
+ }
45
+ }
46
+ },
47
+ }))
48
+
49
+ const held = await client.requestWithReceipt(
50
+ 'test.Service',
51
+ 'Unary',
52
+ response,
53
+ )
54
+ const commit = held.receipt.commit()
55
+ expect(held.receipt.settled).toBe(true)
56
+ await expect(commit).resolves.toBeUndefined()
57
+
58
+ const terminalPackets = sent.filter((packet) => {
59
+ const body = packet.body
60
+ return (
61
+ body?.case === 'callCancel' ||
62
+ (body?.case === 'callData' && body.value.complete)
63
+ )
64
+ })
65
+ expect(terminalPackets).toHaveLength(1)
66
+ expect(terminalPackets[0]?.body?.case).toBe('callData')
67
+ await expect(held.receipt.done).resolves.toBeUndefined()
68
+ })
69
+
70
+ it('commits through a real TypeScript server', async () => {
71
+ const terminal = Promise.withResolvers<string>()
72
+ const server = new Server(async () => {
73
+ return async (_source, dataSink, invocation) => {
74
+ await dataSink(
75
+ (async function* () {
76
+ yield response
77
+ if (!invocation) {
78
+ throw new Error('missing invocation')
79
+ }
80
+ terminal.resolve(
81
+ await invocation.waitTerminal(new AbortController().signal),
82
+ )
83
+ })(),
84
+ )
85
+ }
86
+ })
87
+ const client = new Client(async () => {
88
+ const clientToServer = pushable<Uint8Array>({ objectMode: true })
89
+ const serverToClient = pushable<Uint8Array>({ objectMode: true })
90
+ server.handlePacketStream({
91
+ source: clientToServer,
92
+ sink: async (source) => {
93
+ for await (const packet of source) {
94
+ serverToClient.push(packet)
95
+ }
96
+ serverToClient.end()
97
+ },
98
+ })
99
+ return {
100
+ source: serverToClient,
101
+ sink: async (source) => {
102
+ for await (const packet of source) {
103
+ clientToServer.push(packet)
104
+ }
105
+ clientToServer.end()
106
+ },
107
+ }
108
+ })
109
+
110
+ const held = await client.requestWithReceipt(
111
+ 'test.Service',
112
+ 'Unary',
113
+ response,
114
+ )
115
+ await expect(held.receipt.commit()).resolves.toBeUndefined()
116
+ await expect(terminal.promise).resolves.toBe('committed')
117
+ })
118
+
119
+ it('rejects commit after a bare remote close', async () => {
120
+ const call = new ClientRPC('test.Service', 'Unary')
121
+ const iterator = call.rpcDataSource[Symbol.asyncIterator]()
122
+ await call.handleCallData({
123
+ data: response,
124
+ dataIsZero: false,
125
+ complete: false,
126
+ error: '',
127
+ })
128
+ const first = await iterator.next()
129
+ expect(first.done).toBe(false)
130
+ const receipt = new CallReceipt(call, iterator)
131
+ await call.close()
132
+ await expect(receipt.commit()).rejects.toThrow()
133
+ await expect(receipt.done).rejects.toThrow('receipt closed before commit')
134
+ })
135
+
136
+ it('aborts a held receipt with one cancel packet', async () => {
137
+ const sent: Packet[] = []
138
+ const terminal = Promise.withResolvers<void>()
139
+ const cancelSeen = Promise.withResolvers<void>()
140
+ const client = new Client(async () => ({
141
+ source: responseSource(terminal.promise),
142
+ sink: async (source) => {
143
+ for await (const data of source) {
144
+ const packet = Packet.fromBinary(data)
145
+ sent.push(packet)
146
+ if (packet.body?.case === 'callCancel') {
147
+ cancelSeen.resolve()
148
+ }
149
+ }
150
+ },
151
+ }))
152
+
153
+ const held = await client.requestWithReceipt(
154
+ 'test.Service',
155
+ 'Unary',
156
+ response,
157
+ )
158
+ await held.receipt.abort()
159
+ await cancelSeen.promise
160
+ const terminalPackets = sent.filter(
161
+ (packet) => packet.body?.case === 'callCancel',
162
+ )
163
+ expect(terminalPackets).toHaveLength(1)
164
+ await expect(held.receipt.done).rejects.toThrow()
165
+ })
166
+
167
+ it('allows exactly one concurrent commit or abort terminal', async () => {
168
+ const sent: Packet[] = []
169
+ const terminal = Promise.withResolvers<void>()
170
+ const client = new Client(async () => ({
171
+ source: responseSource(terminal.promise),
172
+ sink: async (source) => {
173
+ for await (const data of source) {
174
+ const packet = Packet.fromBinary(data)
175
+ sent.push(packet)
176
+ if (packet.body?.case === 'callData' && packet.body.value.complete) {
177
+ terminal.resolve()
178
+ }
179
+ }
180
+ },
181
+ }))
182
+
183
+ const held = await client.requestWithReceipt(
184
+ 'test.Service',
185
+ 'Unary',
186
+ response,
187
+ )
188
+ const results = await Promise.allSettled([
189
+ held.receipt.commit(),
190
+ held.receipt.abort(),
191
+ ])
192
+ const terminalPackets = sent.filter((packet) => {
193
+ const body = packet.body
194
+ return (
195
+ body?.case === 'callCancel' ||
196
+ (body?.case === 'callData' && body.value.complete)
197
+ )
198
+ })
199
+ expect(terminalPackets).toHaveLength(1)
200
+ expect(results).toHaveLength(2)
201
+ })
202
+
203
+ it('rejects a trailing response datum instead of committing', async () => {
204
+ const terminal = Promise.withResolvers<void>()
205
+ const client = new Client(async () => ({
206
+ source: (async function* () {
207
+ yield Packet.toBinary({
208
+ body: {
209
+ case: 'callData',
210
+ value: {
211
+ data: response,
212
+ dataIsZero: false,
213
+ complete: false,
214
+ error: '',
215
+ },
216
+ },
217
+ })
218
+ await terminal.promise
219
+ yield Packet.toBinary({
220
+ body: {
221
+ case: 'callData',
222
+ value: {
223
+ data: new Uint8Array([9]),
224
+ dataIsZero: false,
225
+ complete: false,
226
+ error: '',
227
+ },
228
+ },
229
+ })
230
+ })(),
231
+ sink: async (source) => {
232
+ for await (const data of source) {
233
+ const packet = Packet.fromBinary(data)
234
+ if (packet.body?.case === 'callData' && packet.body.value.complete) {
235
+ terminal.resolve()
236
+ }
237
+ }
238
+ },
239
+ }))
240
+
241
+ const held = await client.requestWithReceipt(
242
+ 'test.Service',
243
+ 'Unary',
244
+ response,
245
+ )
246
+ await expect(held.receipt.commit()).rejects.toThrow(
247
+ 'unexpected trailing response data',
248
+ )
249
+ })
250
+
251
+ it('rejects done on a remote terminal error', async () => {
252
+ const client = new Client(async () => ({
253
+ source: (async function* () {
254
+ yield Packet.toBinary({
255
+ body: {
256
+ case: 'callData',
257
+ value: {
258
+ data: response,
259
+ dataIsZero: false,
260
+ complete: false,
261
+ error: '',
262
+ },
263
+ },
264
+ })
265
+ yield Packet.toBinary({
266
+ body: {
267
+ case: 'callData',
268
+ value: {
269
+ data: new Uint8Array(),
270
+ dataIsZero: false,
271
+ complete: true,
272
+ error: 'remote failure',
273
+ },
274
+ },
275
+ })
276
+ })(),
277
+ sink: async (source) => {
278
+ for await (const _data of source) {
279
+ void _data
280
+ }
281
+ },
282
+ }))
283
+
284
+ const held = await client.requestWithReceipt(
285
+ 'test.Service',
286
+ 'Unary',
287
+ response,
288
+ )
289
+ await expect(held.receipt.done).rejects.toThrow('remote failure')
290
+ })
291
+ })
292
+
293
+ describe('server invocation terminal', () => {
294
+ it.each([
295
+ ['explicit completion', 'committed'],
296
+ ['cancel', 'canceled'],
297
+ ['transport loss', 'transportLost'],
298
+ ['remote error packet', 'transportLost'],
299
+ ['bare close', 'closed'],
300
+ ['remote error packet with completion', 'transportLost'],
301
+ ] as const)('%s is classified distinctly', async (_name, expected) => {
302
+ const captured = Promise.withResolvers<ServerInvocation>()
303
+ const owner = new AbortController()
304
+ const observed = Promise.withResolvers<TerminalKind>()
305
+ const rpc = new ServerRPC(async () => {
306
+ return async (_source, _sink, invocation) => {
307
+ if (!invocation) {
308
+ throw new Error('missing invocation')
309
+ }
310
+ captured.resolve(invocation)
311
+ observed.resolve(await invocation.waitTerminal(owner.signal))
312
+ }
313
+ })
314
+ await rpc.handleCallStart({
315
+ rpcService: 'test.Service',
316
+ rpcMethod: 'Unary',
317
+ data: new Uint8Array(),
318
+ dataIsZero: true,
319
+ })
320
+ const invocation = await captured.promise
321
+ if (expected === 'committed') {
322
+ await rpc.handleCallData({ complete: true })
323
+ } else if (expected === 'canceled') {
324
+ await rpc.handleCallCancel()
325
+ } else if (expected === 'transportLost') {
326
+ if (
327
+ _name === 'remote error packet' ||
328
+ _name === 'remote error packet with completion'
329
+ ) {
330
+ await rpc.handleCallData({
331
+ complete: _name === 'remote error packet with completion',
332
+ error: 'remote failure',
333
+ })
334
+ } else {
335
+ await rpc.close(new Error('transport loss'))
336
+ }
337
+ } else {
338
+ await rpc.close()
339
+ }
340
+ await expect(observed.promise).resolves.toBe(expected)
341
+ owner.abort()
342
+ if (_name === 'remote error packet with completion') {
343
+ const rpcState: object = rpc
344
+ if (!('remoteCompleted' in rpcState)) {
345
+ throw new Error('remote completion discriminator is missing')
346
+ }
347
+ expect(rpcState.remoteCompleted).toBe(false)
348
+ }
349
+ if (expected === 'closed' || expected === 'transportLost') {
350
+ expect(invocation.signal.aborted).toBe(true)
351
+ }
352
+ })
353
+
354
+ it.each(['transport loss', 'cancel'] as const)(
355
+ 'keeps completion after %s',
356
+ async (followUp) => {
357
+ const captured = Promise.withResolvers<ServerInvocation>()
358
+ const owner = new AbortController()
359
+ const rpc = new ServerRPC(async () => {
360
+ return async (_source, _sink, invocation) => {
361
+ if (!invocation) {
362
+ throw new Error('missing invocation')
363
+ }
364
+ captured.resolve(invocation)
365
+ await invocation.waitTerminal(owner.signal)
366
+ }
367
+ })
368
+ await rpc.handleCallStart({
369
+ rpcService: 'test.Service',
370
+ rpcMethod: 'Unary',
371
+ data: new Uint8Array(),
372
+ dataIsZero: true,
373
+ })
374
+ const invocation = await captured.promise
375
+ await rpc.handleCallData({ complete: true })
376
+ if (followUp === 'transport loss') {
377
+ await rpc.close(new Error('transport loss'))
378
+ } else {
379
+ await rpc.handleCallCancel()
380
+ }
381
+ await expect(invocation.waitTerminal(owner.signal)).resolves.toBe(
382
+ 'committed',
383
+ )
384
+ owner.abort()
385
+ },
386
+ )
387
+
388
+ it('returns abandoned only for owner cancellation without a remote terminal', async () => {
389
+ const captured = Promise.withResolvers<ServerInvocation>()
390
+ const owner = new AbortController()
391
+ const rpc = new ServerRPC(async () => {
392
+ return async (_source, _sink, invocation) => {
393
+ if (!invocation) {
394
+ throw new Error('missing invocation')
395
+ }
396
+ captured.resolve(invocation)
397
+ await invocation.waitTerminal(owner.signal)
398
+ }
399
+ })
400
+ await rpc.handleCallStart({
401
+ rpcService: 'test.Service',
402
+ rpcMethod: 'Unary',
403
+ data: new Uint8Array(),
404
+ dataIsZero: true,
405
+ })
406
+ const invocation = await captured.promise
407
+ owner.abort()
408
+ await expect(invocation.waitTerminal(owner.signal)).resolves.toBe(
409
+ 'abandoned',
410
+ )
411
+ })
412
+ })
413
+
414
+ async function* responseSource(terminal: Promise<void>) {
415
+ yield Packet.toBinary({
416
+ body: {
417
+ case: 'callData',
418
+ value: {
419
+ data: response,
420
+ dataIsZero: false,
421
+ complete: false,
422
+ error: '',
423
+ },
424
+ },
425
+ })
426
+ await terminal
427
+ yield Packet.toBinary({
428
+ body: {
429
+ case: 'callData',
430
+ value: {
431
+ data: new Uint8Array(),
432
+ dataIsZero: false,
433
+ complete: true,
434
+ error: '',
435
+ },
436
+ },
437
+ })
438
+ }
@@ -0,0 +1,130 @@
1
+ /// <reference lib="es2024.promise" />
2
+ import { ERR_RPC_ABORT } from './errors.js'
3
+ import { ClientRPC } from './client-rpc.js'
4
+
5
+ // ReceiptRpc exposes held unary calls without widening ProtoRpc.
6
+ export interface ReceiptRpc {
7
+ requestWithReceipt(
8
+ service: string,
9
+ method: string,
10
+ data: Uint8Array,
11
+ abortSignal?: AbortSignal,
12
+ ): Promise<HeldCall>
13
+ }
14
+
15
+ // HeldCall contains the first response and its held terminal receipt.
16
+ export interface HeldCall {
17
+ readonly response: Uint8Array
18
+ readonly receipt: CallReceipt
19
+ }
20
+
21
+ // CallReceipt holds a unary call until it is committed or aborted.
22
+ export class CallReceipt {
23
+ #call: ClientRPC
24
+ #iterator: AsyncIterator<Uint8Array>
25
+ #terminalPromise: Promise<IteratorResult<Uint8Array>>
26
+ #terminal?: 'committed' | 'aborted'
27
+ #done: Promise<void>
28
+ #requestCommitted = false
29
+ #resolveDone!: () => void
30
+ #rejectDone!: (reason?: unknown) => void
31
+
32
+ public constructor(call: ClientRPC, iterator: AsyncIterator<Uint8Array>) {
33
+ this.#call = call
34
+ this.#iterator = iterator
35
+ const { promise, resolve, reject } = Promise.withResolvers<void>()
36
+ this.#done = promise
37
+ this.#resolveDone = resolve
38
+ this.#rejectDone = reject
39
+ this.#done.catch(() => undefined)
40
+ this.#terminalPromise = this.#observeTerminal()
41
+ this.#terminalPromise.catch(() => undefined)
42
+ }
43
+
44
+ async #observeTerminal(): Promise<IteratorResult<Uint8Array>> {
45
+ try {
46
+ const result = await this.#iterator.next()
47
+ if (!result.done) {
48
+ throw new Error('unexpected trailing response data')
49
+ }
50
+ const terminal = this.#call.getTerminalKind()
51
+ if (
52
+ terminal !== 'committed' ||
53
+ !this.#requestCommitted ||
54
+ this.#terminal !== 'committed'
55
+ ) {
56
+ throw new Error('receipt closed before commit')
57
+ }
58
+ return result
59
+ } catch (err) {
60
+ this.#rejectDone(err)
61
+ const error =
62
+ err instanceof Error ? err : new Error('receipt terminal failed')
63
+ try {
64
+ await this.#call.close(error)
65
+ } catch {
66
+ // The primary terminal error is already recorded on the receipt.
67
+ }
68
+ throw err
69
+ }
70
+ }
71
+
72
+ // done resolves after committed close and rejects on terminal failure.
73
+ public get done(): Promise<void> {
74
+ return this.#done
75
+ }
76
+
77
+ // settled reports whether a terminal transition has been claimed.
78
+ public get settled(): boolean {
79
+ return this.#terminal !== undefined
80
+ }
81
+
82
+ // commit sends request completion and waits for server finalization.
83
+ public async commit(): Promise<void> {
84
+ if (this.#terminal === 'aborted') {
85
+ throw new Error(ERR_RPC_ABORT)
86
+ }
87
+ if (this.#terminal === 'committed') {
88
+ return this.#done
89
+ }
90
+ this.#terminal = 'committed'
91
+ this.#requestCommitted = true
92
+ try {
93
+ await this.#call.writeCallData(undefined, true)
94
+ await this.#terminalPromise
95
+ await this.#call.close()
96
+ this.#resolveDone()
97
+ } catch (err) {
98
+ await this.#call.close(
99
+ err instanceof Error ? err : new Error('receipt commit failed'),
100
+ )
101
+ this.#rejectDone(err)
102
+ throw err
103
+ }
104
+ }
105
+
106
+ // abort sends request cancellation and never rejects.
107
+ public async abort(reason?: Error): Promise<void> {
108
+ if (this.#terminal !== undefined) {
109
+ return
110
+ }
111
+ this.#terminal = 'aborted'
112
+ let terminalError: unknown = reason
113
+ try {
114
+ await this.#call.writeCallCancel(true)
115
+ } catch (err) {
116
+ terminalError ??= err
117
+ }
118
+ try {
119
+ await this.#call.close()
120
+ } catch (err) {
121
+ terminalError ??= err
122
+ }
123
+ this.#rejectDone(terminalError ?? new Error(ERR_RPC_ABORT))
124
+ }
125
+
126
+ // asyncDispose aborts a receipt that has not reached a terminal.
127
+ public async [Symbol.asyncDispose](): Promise<void> {
128
+ await this.abort()
129
+ }
130
+ }