Skip to content
This repository was archived by the owner on Jul 5, 2024. It is now read-only.

Commit aa2cbad

Browse files
authored
feat: handle reconnects when response is interrupted (#77)
- add option to resume interrupted streams - we assume that any reconnect will return a result with the same shape ## Notes - it's a bit mind-bending try to to follow exactly what happens, but the test seems to pass - we'll likely use SSE for subscriptions in tRPC and SSE _or_ JSON streams for query/mutation results - for subscriptions we'll have `reconnect:true` in our client
1 parent 831d7c4 commit aa2cbad

File tree

4 files changed

+166
-23
lines changed

4 files changed

+166
-23
lines changed

examples/async/src/app/sse-infinite/StreamedTimeSSE.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function StreamedTimeSSE() {
1111
useEffect(() => {
1212
const abortSignal = new AbortController();
1313
createEventSource<ResponseShape>("/sse-infinite", {
14+
reconnect: true,
1415
signal: abortSignal.signal,
1516
})
1617
.then(async (shape) => {

src/async/deserializeAsync.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
12
/* eslint-disable @typescript-eslint/no-non-null-assertion */
23

34
import { TsonError } from "../errors.js";
@@ -31,10 +32,20 @@ type AnyTsonTransformerSerializeDeserialize =
3132
| TsonTransformerSerializeDeserialize<any, any>;
3233

3334
export interface TsonParseAsyncOptions {
35+
/**
36+
* Event handler for when the stream reconnects
37+
* You can use this to do extra actions to ensure no messages were lost
38+
*/
39+
onReconnect?: () => void;
3440
/**
3541
* On stream error
3642
*/
3743
onStreamError?: (err: TsonStreamInterruptedError) => void;
44+
/**
45+
* Allow reconnecting to the stream if it's interrupted
46+
* @default false
47+
*/
48+
reconnect?: boolean;
3849
}
3950

4051
type TsonParseAsync = <TValue>(
@@ -62,10 +73,11 @@ function createTsonDeserializer(opts: TsonAsyncOptions) {
6273
iterable: TsonDeserializeIterable,
6374
parseOptions: TsonParseAsyncOptions,
6475
) => {
65-
const cache = new Map<
76+
const controllers = new Map<
6677
TsonAsyncIndex,
6778
ReadableStreamDefaultController<unknown>
6879
>();
80+
const cache = new Map<TsonAsyncIndex, unknown>();
6981
const iterator = iterable[Symbol.asyncIterator]();
7082

7183
const walker: WalkerFactory = (nonce) => {
@@ -83,22 +95,34 @@ function createTsonDeserializer(opts: TsonAsyncOptions) {
8395

8496
const idx = serializedValue as TsonAsyncIndex;
8597

98+
if (cache.has(idx)) {
99+
// We already have this async value in the cache - so this is probably a reconnect
100+
assert(
101+
parseOptions.reconnect,
102+
"Duplicate index found but reconnect is off",
103+
);
104+
return cache.get(idx);
105+
}
106+
86107
const [readable, controller] = createReadableStream();
87108

88109
// the `start` method is called "immediately when the object is constructed"
89110
// [MDN](http://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/ReadableStream)
90111
// so we're guaranteed that the controller is set in the cache
91112
assert(controller, "Controller not set - this is a bug");
92113

93-
cache.set(idx, controller);
114+
controllers.set(idx, controller);
94115

95-
return transformer.deserialize({
116+
const result = transformer.deserialize({
96117
close() {
97118
controller.close();
98-
cache.delete(idx);
119+
controllers.delete(idx);
99120
},
100121
reader: readable.getReader(),
101122
});
123+
124+
cache.set(idx, result);
125+
return result;
102126
}
103127

104128
return mapOrReturn(value, walk);
@@ -117,16 +141,33 @@ function createTsonDeserializer(opts: TsonAsyncOptions) {
117141

118142
const { value } = nextValue;
119143

144+
if (!Array.isArray(value)) {
145+
// we got the beginning of a new stream - probably because a reconnect
146+
// we assume this new stream will have the same shape and restart the walker with the nonce
147+
148+
parseOptions.onReconnect?.();
149+
150+
assert(
151+
parseOptions.reconnect,
152+
"Stream got beginning of results but reconnecting is not enabled",
153+
);
154+
155+
await getStreamedValues(walker(value.nonce));
156+
return;
157+
}
158+
120159
const [index, result] = value as TsonAsyncValueTuple;
121160

122-
const controller = cache.get(index);
161+
const controller = controllers.get(index);
123162

124163
const walkedResult = walk(result);
125164

126-
assert(controller, `No stream found for index ${index}`);
165+
if (!parseOptions.reconnect) {
166+
assert(controller, `No stream found for index ${index}`);
167+
}
127168

128169
// resolving deferred
129-
controller.enqueue(walkedResult);
170+
controller?.enqueue(walkedResult);
130171
}
131172
}
132173

@@ -152,7 +193,7 @@ function createTsonDeserializer(opts: TsonAsyncOptions) {
152193
const err = new TsonStreamInterruptedError(cause);
153194

154195
// enqueue the error to all the streams
155-
for (const controller of cache.values()) {
196+
for (const controller of controllers.values()) {
156197
controller.enqueue(err);
157198
}
158199

src/async/sse.test.ts

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
22
import { EventSourcePolyfill, NativeEventSource } from "event-source-polyfill";
3-
import { expect, test } from "vitest";
3+
import { expect, test, vi } from "vitest";
44
(global as any).EventSource = NativeEventSource || EventSourcePolyfill;
55

6-
import { TsonAsyncOptions, tsonAsyncIterable, tsonPromise } from "../index.js";
6+
import {
7+
TsonAsyncOptions,
8+
tsonAsyncIterable,
9+
tsonBigint,
10+
tsonPromise,
11+
} from "../index.js";
712
import { createTestServer, sleep } from "../internals/testUtils.js";
813
import { createTsonAsync } from "./createTsonAsync.js";
914

@@ -13,15 +18,12 @@ test("SSE response test", async () => {
1318
let i = 0;
1419
while (true) {
1520
yield i++;
16-
await sleep(100);
21+
await sleep(10);
1722
}
1823
}
1924

2025
return {
21-
foo: "bar",
2226
iterable: generator(),
23-
promise: Promise.resolve(42),
24-
rejectedPromise: Promise.reject(new Error("rejected promise")),
2527
};
2628
}
2729

@@ -73,14 +75,14 @@ test("SSE response test", async () => {
7375
});
7476

7577
expect(messages).toMatchInlineSnapshot(`
76-
[
77-
"{\\"json\\":{\\"foo\\":\\"bar\\",\\"iterable\\":[\\"AsyncIterable\\",0,\\"__tson\\"],\\"promise\\":[\\"Promise\\",1,\\"__tson\\"],\\"rejectedPromise\\":[\\"Promise\\",2,\\"__tson\\"]},\\"nonce\\":\\"__tson\\"}",
78-
"[0,[0,0]]",
79-
"[1,[0,42]]",
80-
"[2,[1,{}]]",
81-
"[0,[0,1]]",
82-
]
83-
`);
78+
[
79+
"{\\"json\\":{\\"iterable\\":[\\"AsyncIterable\\",0,\\"__tson\\"]},\\"nonce\\":\\"__tson\\"}",
80+
"[0,[0,0]]",
81+
"[0,[0,1]]",
82+
"[0,[0,2]]",
83+
"[0,[0,3]]",
84+
]
85+
`);
8486
}
8587

8688
{
@@ -110,3 +112,99 @@ test("SSE response test", async () => {
110112
`);
111113
}
112114
});
115+
116+
test("handle reconnects - iterator wrapped in Promise", async () => {
117+
let i = 0;
118+
119+
let kill = false;
120+
function createMockObj() {
121+
async function* generator() {
122+
while (true) {
123+
await sleep(10);
124+
yield BigInt(i);
125+
i++;
126+
127+
if (i % 5 === 0) {
128+
kill = true;
129+
}
130+
131+
if (i > 11) {
132+
// done
133+
return;
134+
}
135+
}
136+
}
137+
138+
return {
139+
iterable: Promise.resolve(generator()),
140+
};
141+
}
142+
143+
type MockObj = ReturnType<typeof createMockObj>;
144+
145+
// ------------- server -------------------
146+
const opts = {
147+
nonce: () => "__tson" + i, // add index to nonce to make sure it's not cached
148+
types: [tsonPromise, tsonAsyncIterable, tsonBigint],
149+
} satisfies TsonAsyncOptions;
150+
151+
const server = await createTestServer({
152+
handleRequest: async (_req, res) => {
153+
const tson = createTsonAsync(opts);
154+
155+
const obj = createMockObj();
156+
const response = tson.toSSEResponse(obj);
157+
158+
for (const [key, value] of response.headers) {
159+
res.setHeader(key, value);
160+
}
161+
162+
for await (const value of response.body as any) {
163+
res.write(value);
164+
if (kill) {
165+
// interrupt the stream
166+
res.end();
167+
kill = false;
168+
return;
169+
}
170+
}
171+
172+
res.end();
173+
},
174+
});
175+
176+
// ------------- client -------------------
177+
const tson = createTsonAsync(opts);
178+
179+
// e2e
180+
const ac = new AbortController();
181+
const onReconnect = vi.fn();
182+
const shape = await tson.createEventSource<MockObj>(server.url, {
183+
onReconnect,
184+
reconnect: true,
185+
signal: ac.signal,
186+
});
187+
188+
const messages: bigint[] = [];
189+
190+
for await (const value of await shape.iterable) {
191+
messages.push(value);
192+
}
193+
194+
expect(messages).toMatchInlineSnapshot(`
195+
[
196+
0n,
197+
1n,
198+
2n,
199+
3n,
200+
4n,
201+
6n,
202+
7n,
203+
8n,
204+
9n,
205+
11n,
206+
]
207+
`);
208+
209+
expect(onReconnect).toHaveBeenCalledTimes(2);
210+
});

vitest.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export default defineConfig({
1010
reporter: ["html", "lcov"],
1111
},
1212
exclude: ["lib", "node_modules", "examples", "benchmark"],
13-
setupFiles: ["console-fail-test/setup"],
13+
setupFiles: [
14+
// this is useful to comment out sometimes
15+
"console-fail-test/setup",
16+
],
1417
},
1518
});

0 commit comments

Comments
 (0)