@trpc/client 11.0.0-alpha-tmp-issues-5851-take-two.496 → 11.0.0-alpha-tmp-subscription-connection-state.485

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.
@@ -12,6 +12,7 @@ import {
12
12
  import { TRPCClientError } from '../TRPCClientError';
13
13
  import { getTransformer, type TransformerOptions } from '../unstable-internals';
14
14
  import { getUrl } from './internals/httpUtils';
15
+ import type { CallbackOrValue } from './internals/urlWithConnectionParams';
15
16
  import {
16
17
  resultOf,
17
18
  type UrlOptionsWithConnectionParams,
@@ -35,9 +36,9 @@ async function urlWithConnectionParams(
35
36
 
36
37
  type HTTPSubscriptionLinkOptions<TRoot extends AnyClientTypes> = {
37
38
  /**
38
- * EventSource options
39
+ * EventSource options or a callback that returns them
39
40
  */
40
- eventSourceOptions?: EventSourceInit;
41
+ eventSourceOptions?: CallbackOrValue<EventSourceInit>;
41
42
  } & TransformerOptions<TRoot> &
42
43
  UrlOptionsWithConnectionParams;
43
44
 
@@ -50,6 +51,7 @@ export function unstable_httpSubscriptionLink<
50
51
  opts: HTTPSubscriptionLinkOptions<inferClientTypes<TInferrable>>,
51
52
  ): TRPCLink<TInferrable> {
52
53
  const transformer = getTransformer(opts.transformer);
54
+
53
55
  return () => {
54
56
  return ({ op }) => {
55
57
  return observable((observer) => {
@@ -72,12 +74,21 @@ export function unstable_httpSubscriptionLink<
72
74
  signal: null,
73
75
  });
74
76
 
77
+ const eventSourceOptions = await resultOf(opts.eventSourceOptions);
75
78
  /* istanbul ignore if -- @preserve */
76
79
  if (unsubscribed) {
77
80
  // already unsubscribed - rare race condition
78
81
  return;
79
82
  }
80
- eventSource = new EventSource(url, opts.eventSourceOptions);
83
+ eventSource = new EventSource(url, eventSourceOptions);
84
+ observer.next({
85
+ result: {
86
+ type: 'state',
87
+ state: 'connecting',
88
+ data: null,
89
+ },
90
+ });
91
+
81
92
  const onStarted = () => {
82
93
  observer.next({
83
94
  result: {
@@ -93,9 +104,39 @@ export function unstable_httpSubscriptionLink<
93
104
  };
94
105
  // console.log('starting', new Date());
95
106
  eventSource.addEventListener('open', onStarted);
107
+
108
+ eventSource.addEventListener('open', () => {
109
+ observer.next({
110
+ result: {
111
+ type: 'state',
112
+ state: 'pending',
113
+ },
114
+ });
115
+ });
116
+
117
+ eventSource.addEventListener('error', (event) => {
118
+ // sseStreamConsumer handles this already
119
+ if (eventSource?.readyState === EventSource.CLOSED) {
120
+ return;
121
+ }
122
+
123
+ const error =
124
+ event instanceof ErrorEvent
125
+ ? TRPCClientError.from(event.error)
126
+ : TRPCClientError.from(new Error(`Unknown EventSource error`));
127
+
128
+ observer.next({
129
+ result: {
130
+ type: 'state',
131
+ state: 'connecting',
132
+ data: error,
133
+ },
134
+ });
135
+ });
136
+
96
137
  const iterable = sseStreamConsumer<Partial<SSEMessage>>({
97
138
  from: eventSource,
98
- deserialize: transformer.input.deserialize,
139
+ deserialize: transformer.output.deserialize,
99
140
  });
100
141
 
101
142
  for await (const chunk of iterable) {
@@ -114,12 +155,36 @@ export function unstable_httpSubscriptionLink<
114
155
  },
115
156
  });
116
157
  observer.complete();
158
+
159
+ observer.next({
160
+ result: {
161
+ type: 'state',
162
+ state: 'idle',
163
+ },
164
+ });
117
165
  }).catch((error) => {
118
- observer.error(TRPCClientError.from(error));
166
+ const trpcError = TRPCClientError.from(error);
167
+
168
+ observer.error(trpcError);
169
+ observer.next({
170
+ result: {
171
+ type: 'state',
172
+ state: 'error',
173
+ data: TRPCClientError.from(trpcError),
174
+ },
175
+ });
119
176
  });
120
177
 
121
178
  return () => {
122
179
  observer.complete();
180
+
181
+ observer.next({
182
+ result: {
183
+ type: 'state',
184
+ state: 'idle',
185
+ },
186
+ });
187
+
123
188
  eventSource?.close();
124
189
  unsubscribed = true;
125
190
  };
@@ -154,13 +154,47 @@ export const jsonHttpRequester: Requester = (opts) => {
154
154
  });
155
155
  };
156
156
 
157
+ /**
158
+ * Polyfill for DOMException with AbortError name
159
+ */
160
+ class AbortError extends Error {
161
+ constructor() {
162
+ const name = 'AbortError';
163
+ super(name);
164
+ this.name = name;
165
+ this.message = name;
166
+ }
167
+ }
168
+
157
169
  export type HTTPRequestOptions = ContentOptions &
158
170
  HTTPBaseRequestOptions & {
159
171
  headers: () => HTTPHeaders | Promise<HTTPHeaders>;
160
172
  };
161
173
 
174
+ /**
175
+ * Polyfill for `signal.throwIfAborted()`
176
+ *
177
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted
178
+ */
179
+ const throwIfAborted = (signal: Maybe<AbortSignal>) => {
180
+ if (!signal?.aborted) {
181
+ return;
182
+ }
183
+ // If available, use the native implementation
184
+ signal.throwIfAborted?.();
185
+
186
+ // If we have `DOMException`, use it
187
+ if (typeof DOMException !== 'undefined') {
188
+ throw new DOMException('AbortError', 'AbortError');
189
+ }
190
+
191
+ // Otherwise, use our own implementation
192
+ throw new AbortError();
193
+ };
194
+
162
195
  export async function fetchHTTPResponse(opts: HTTPRequestOptions) {
163
- opts.signal?.throwIfAborted();
196
+ throwIfAborted(opts.signal);
197
+
164
198
  const url = opts.getUrl(opts);
165
199
  const body = opts.getBody(opts);
166
200
  const { type } = opts;
@@ -10,7 +10,7 @@ export const resultOf = <T>(value: T | (() => T)): T => {
10
10
  /**
11
11
  * A value that can be wrapped in callback
12
12
  */
13
- type CallbackOrValue<T> = T | (() => T | Promise<T>);
13
+ export type CallbackOrValue<T> = T | (() => T | Promise<T>);
14
14
 
15
15
  export interface UrlOptionsWithConnectionParams {
16
16
  /**
@@ -55,11 +55,54 @@ export interface TRPCClientRuntime {
55
55
  // nothing here anymore
56
56
  }
57
57
 
58
+ export type ConnectionState = 'idle' | 'connecting' | 'pending' | 'error';
59
+
60
+ export interface ConnectionStateMessageBase {
61
+ type: 'state';
62
+ }
63
+
64
+ export interface IdleStateMessage extends ConnectionStateMessageBase {
65
+ state: 'idle';
66
+ }
67
+
68
+ export interface ConnectingStateMessage<TError>
69
+ extends ConnectionStateMessageBase {
70
+ state: 'connecting';
71
+ data: TError | null;
72
+ }
73
+
74
+ export interface PendingStateMessage extends ConnectionStateMessageBase {
75
+ state: 'pending';
76
+ }
77
+
78
+ export interface ErrorStateMessage<TError> extends ConnectionStateMessageBase {
79
+ state: 'error';
80
+ data: TError;
81
+ }
82
+
83
+ export type TRPCConnectionStateMessage<TError> =
84
+ | IdleStateMessage
85
+ | ConnectingStateMessage<TError>
86
+ | PendingStateMessage
87
+ | ErrorStateMessage<TError>;
88
+
89
+ export const isConnectionStateMessage = <TError>(
90
+ msg: unknown,
91
+ ): msg is TRPCConnectionStateMessage<TError> => {
92
+ return (
93
+ typeof msg === 'object' &&
94
+ msg !== null &&
95
+ 'type' in msg &&
96
+ msg.type === 'state'
97
+ );
98
+ };
99
+
58
100
  /**
59
101
  * @internal
60
102
  */
61
- export interface OperationResultEnvelope<TOutput> {
103
+ export interface OperationResultEnvelope<TOutput, TError = unknown> {
62
104
  result:
105
+ | TRPCConnectionStateMessage<TError>
63
106
  | TRPCResultMessage<TOutput>['result']
64
107
  | TRPCSuccessResponse<TOutput>['result'];
65
108
  context?: OperationContext;
@@ -71,7 +114,10 @@ export interface OperationResultEnvelope<TOutput> {
71
114
  export type OperationResultObservable<
72
115
  TInferrable extends InferrableClientTypes,
73
116
  TOutput,
74
- > = Observable<OperationResultEnvelope<TOutput>, TRPCClientError<TInferrable>>;
117
+ > = Observable<
118
+ OperationResultEnvelope<TOutput, TRPCClientError<TInferrable>>,
119
+ TRPCClientError<TInferrable>
120
+ >;
75
121
 
76
122
  /**
77
123
  * @internal
@@ -20,14 +20,18 @@ import {
20
20
  resultOf,
21
21
  type UrlOptionsWithConnectionParams,
22
22
  } from './internals/urlWithConnectionParams';
23
- import type { Operation, TRPCLink } from './types';
23
+ import {
24
+ isConnectionStateMessage,
25
+ type Operation,
26
+ type TRPCConnectionStateMessage,
27
+ type TRPCLink,
28
+ } from './types';
24
29
 
25
30
  const run = <TResult>(fn: () => TResult): TResult => fn();
26
31
 
27
- type WSCallbackResult<TRouter extends AnyRouter, TOutput> = TRPCResponseMessage<
28
- TOutput,
29
- inferRouterError<TRouter>
30
- >;
32
+ type WSCallbackResult<TRouter extends AnyRouter, TOutput> =
33
+ | TRPCResponseMessage<TOutput, inferRouterError<TRouter>>
34
+ | { result: TRPCConnectionStateMessage<inferRouterError<TRouter>> };
31
35
 
32
36
  type WSCallbackObserver<TRouter extends AnyRouter, TOutput> = Observer<
33
37
  WSCallbackResult<TRouter, TOutput>,
@@ -121,7 +125,7 @@ export function createWSClient(opts: WebSocketClientOptions) {
121
125
  undefined;
122
126
  let activeConnection: null | Connection = lazyOpts.enabled
123
127
  ? null
124
- : createConnection();
128
+ : createConnection(null);
125
129
 
126
130
  type Connection = {
127
131
  id: number;
@@ -145,7 +149,7 @@ export function createWSClient(opts: WebSocketClientOptions) {
145
149
  */
146
150
  function dispatch() {
147
151
  if (!activeConnection) {
148
- activeConnection = createConnection();
152
+ activeConnection = createConnection(null);
149
153
  return;
150
154
  }
151
155
  // using a timeout to batch messages
@@ -171,14 +175,14 @@ export function createWSClient(opts: WebSocketClientOptions) {
171
175
  startLazyDisconnectTimer();
172
176
  });
173
177
  }
174
- function tryReconnect(conn: Connection) {
178
+ function tryReconnect(conn: Connection, cause: Error | null) {
175
179
  if (!!connectTimer) {
176
180
  return;
177
181
  }
178
182
 
179
183
  conn.state = 'connecting';
180
184
  const timeout = retryDelayFn(connectAttempt++);
181
- reconnectInMs(timeout);
185
+ reconnectInMs(timeout, cause);
182
186
  }
183
187
  function hasPendingRequests(conn?: Connection) {
184
188
  const requests = Object.values(pendingRequests);
@@ -188,20 +192,20 @@ export function createWSClient(opts: WebSocketClientOptions) {
188
192
  return requests.some((req) => req.connection === conn);
189
193
  }
190
194
 
191
- function reconnect() {
195
+ function reconnect(cause: Error | null) {
192
196
  if (lazyOpts.enabled && !hasPendingRequests()) {
193
197
  // Skip reconnecting if there are pending requests and we're in lazy mode
194
198
  return;
195
199
  }
196
200
  const oldConnection = activeConnection;
197
- activeConnection = createConnection();
201
+ activeConnection = createConnection(cause);
198
202
  oldConnection && closeIfNoPending(oldConnection);
199
203
  }
200
- function reconnectInMs(ms: number) {
204
+ function reconnectInMs(ms: number, cause: Error | null) {
201
205
  if (connectTimer) {
202
206
  return;
203
207
  }
204
- connectTimer = setTimeout(reconnect, ms);
208
+ connectTimer = setTimeout(reconnect, ms, cause);
205
209
  }
206
210
 
207
211
  function closeIfNoPending(conn: Connection) {
@@ -235,18 +239,30 @@ export function createWSClient(opts: WebSocketClientOptions) {
235
239
  }, lazyOpts.closeMs);
236
240
  };
237
241
 
238
- function createConnection(): Connection {
242
+ function createConnection(cause: Error | null): Connection {
239
243
  const self: Connection = {
240
244
  id: ++connectionIndex,
241
245
  state: 'connecting',
242
246
  } as Connection;
243
247
 
248
+ for (const req of Object.values(pendingRequests)) {
249
+ if (req.type === 'subscription') {
250
+ req.callbacks.next?.({
251
+ result: {
252
+ type: 'state',
253
+ state: 'connecting',
254
+ data: cause ?? null,
255
+ },
256
+ });
257
+ }
258
+ }
259
+
244
260
  clearTimeout(lazyDisconnectTimer);
245
261
 
246
- const onError = () => {
262
+ const onError = (cause: Error | null) => {
247
263
  self.state = 'closed';
248
264
  if (self === activeConnection) {
249
- tryReconnect(self);
265
+ tryReconnect(self, cause);
250
266
  }
251
267
  };
252
268
  run(async () => {
@@ -281,6 +297,17 @@ export function createWSClient(opts: WebSocketClientOptions) {
281
297
  connectAttempt = 0;
282
298
  self.state = 'open';
283
299
 
300
+ for (const req of Object.values(pendingRequests)) {
301
+ if (req.type === 'subscription') {
302
+ req.callbacks.next?.({
303
+ result: {
304
+ type: 'state',
305
+ state: 'pending',
306
+ },
307
+ });
308
+ }
309
+ }
310
+
284
311
  onOpen?.();
285
312
  dispatch();
286
313
  }).catch((cause) => {
@@ -289,17 +316,23 @@ export function createWSClient(opts: WebSocketClientOptions) {
289
316
  3000,
290
317
  cause,
291
318
  );
292
- onError();
319
+ onError(cause);
293
320
  });
294
321
  });
295
- ws.addEventListener('error', onError);
322
+ ws.addEventListener('error', (event) => {
323
+ if (event instanceof ErrorEvent) {
324
+ onError(event.error);
325
+ } else {
326
+ onError(new Error('Unknown WebSocket error'));
327
+ }
328
+ });
296
329
  const handleIncomingRequest = (req: TRPCClientIncomingRequest) => {
297
330
  if (self !== activeConnection) {
298
331
  return;
299
332
  }
300
333
 
301
334
  if (req.method === 'reconnect') {
302
- reconnect();
335
+ reconnect(new Error('Server requested reconnect'));
303
336
  // notify subscribers
304
337
  for (const pendingReq of Object.values(pendingRequests)) {
305
338
  if (pendingReq.type === 'subscription') {
@@ -347,7 +380,7 @@ export function createWSClient(opts: WebSocketClientOptions) {
347
380
  }
348
381
  });
349
382
 
350
- ws.addEventListener('close', ({ code }) => {
383
+ ws.addEventListener('close', ({ code, reason }) => {
351
384
  if (self.state === 'open') {
352
385
  onClose?.({ code });
353
386
  }
@@ -355,7 +388,7 @@ export function createWSClient(opts: WebSocketClientOptions) {
355
388
 
356
389
  if (activeConnection === self) {
357
390
  // connection might have been replaced already
358
- tryReconnect(self);
391
+ tryReconnect(self, new Error(reason));
359
392
  }
360
393
 
361
394
  for (const [key, req] of Object.entries(pendingRequests)) {
@@ -490,20 +523,67 @@ export function wsLink<TRouter extends AnyRouter>(
490
523
  {
491
524
  error(err) {
492
525
  observer.error(err as TRPCClientError<any>);
526
+
527
+ observer.next({
528
+ result: {
529
+ type: 'state',
530
+ state: 'error',
531
+ data: err,
532
+ },
533
+ context: context,
534
+ });
535
+
493
536
  unsub();
494
537
  },
495
538
  complete() {
496
539
  observer.complete();
540
+
541
+ observer.next({
542
+ result: {
543
+ type: 'state',
544
+ state: 'idle',
545
+ },
546
+ context: context,
547
+ });
497
548
  },
498
549
  next(message) {
550
+ if (
551
+ 'result' in message &&
552
+ isConnectionStateMessage<TRPCClientError<TRouter>>(
553
+ message.result,
554
+ )
555
+ ) {
556
+ message.result;
557
+
558
+ observer.next({
559
+ result: message.result,
560
+ context: context,
561
+ });
562
+
563
+ return;
564
+ }
565
+
566
+ if (!('id' in message)) return;
567
+
499
568
  const transformed = transformResult(message, transformer.output);
500
569
 
501
570
  if (!transformed.ok) {
571
+ const error = TRPCClientError.from(transformed.error);
572
+
573
+ observer.next({
574
+ result: {
575
+ type: 'state',
576
+ state: 'error',
577
+ data: error,
578
+ },
579
+ context: context,
580
+ });
502
581
  observer.error(TRPCClientError.from(transformed.error));
503
582
  return;
504
583
  }
505
584
  observer.next({
506
585
  result: transformed.result,
586
+ context: context,
507
587
  });
508
588
 
509
589
  if (op.type !== 'subscription') {
@@ -517,6 +597,13 @@ export function wsLink<TRouter extends AnyRouter>(
517
597
  );
518
598
  return () => {
519
599
  unsub();
600
+ observer.next({
601
+ result: {
602
+ type: 'state',
603
+ state: 'idle',
604
+ },
605
+ context: context,
606
+ });
520
607
  };
521
608
  });
522
609
  };