Skip to content

Commit d4fdd42

Browse files
committed
feat: add inactivity timeout
1 parent 6e2ca14 commit d4fdd42

3 files changed

Lines changed: 211 additions & 33 deletions

File tree

src/index.ts

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -296,31 +296,58 @@ class ReplaneRemoteStorage implements ReplaneStorage {
296296
private async *startReplicationStreamImpl(
297297
options: StartReplicationStreamReplaneStorageOptions
298298
): AsyncIterable<ReplicationStreamRecord> {
299-
const rawEvents = fetchSse({
300-
fetchFn: options.fetchFn,
301-
headers: {
302-
Authorization: this.getAuthHeader(options),
303-
"Content-Type": "application/json",
304-
},
305-
body: JSON.stringify(options.getBody()),
306-
timeoutMs: options.requestTimeoutMs,
307-
method: "POST",
308-
signal: options.signal,
309-
url: this.getApiEndpoint(`/sdk/v1/replication/stream`, options),
310-
onConnect: options.onConnect,
311-
});
299+
// Create an abort controller for inactivity timeout
300+
const inactivityAbortController = new AbortController();
301+
const { signal: combinedSignal, cleanUpSignals } = options.signal
302+
? combineAbortSignals([options.signal, inactivityAbortController.signal])
303+
: { signal: inactivityAbortController.signal, cleanUpSignals: () => {} };
304+
305+
let inactivityTimer: ReturnType<typeof setTimeout> | null = null;
306+
307+
const resetInactivityTimer = () => {
308+
if (inactivityTimer) clearTimeout(inactivityTimer);
309+
inactivityTimer = setTimeout(() => {
310+
inactivityAbortController.abort();
311+
}, options.inactivityTimeoutMs);
312+
};
313+
314+
try {
315+
const rawEvents = fetchSse({
316+
fetchFn: options.fetchFn,
317+
headers: {
318+
Authorization: this.getAuthHeader(options),
319+
"Content-Type": "application/json",
320+
},
321+
body: JSON.stringify(options.getBody()),
322+
timeoutMs: options.requestTimeoutMs,
323+
method: "POST",
324+
signal: combinedSignal,
325+
url: this.getApiEndpoint(`/sdk/v1/replication/stream`, options),
326+
onConnect: () => {
327+
resetInactivityTimer();
328+
options.onConnect?.();
329+
},
330+
});
331+
332+
for await (const sseEvent of rawEvents) {
333+
resetInactivityTimer();
334+
335+
if (sseEvent.type === "ping") continue;
312336

313-
for await (const rawEvent of rawEvents) {
314-
const event = JSON.parse(rawEvent);
315-
if (
316-
typeof event === "object" &&
317-
event !== null &&
318-
"type" in event &&
319-
typeof event.type === "string" &&
320-
(SUPPORTED_REPLICATION_STREAM_RECORD_TYPES as unknown as string[]).includes(event.type)
321-
) {
322-
yield event as ReplicationStreamRecord;
337+
const event = JSON.parse(sseEvent.payload);
338+
if (
339+
typeof event === "object" &&
340+
event !== null &&
341+
"type" in event &&
342+
typeof event.type === "string" &&
343+
(SUPPORTED_REPLICATION_STREAM_RECORD_TYPES as unknown as string[]).includes(event.type)
344+
) {
345+
yield event as ReplicationStreamRecord;
346+
}
323347
}
348+
} finally {
349+
if (inactivityTimer) clearTimeout(inactivityTimer);
350+
cleanUpSignals();
324351
}
325352
}
326353

@@ -374,6 +401,12 @@ export interface ReplaneClientOptions<T extends Configs> {
374401
* @default 200
375402
*/
376403
retryDelayMs?: number;
404+
/**
405+
* Timeout in ms for SSE connection inactivity.
406+
* If no events (including pings) are received within this time, the connection will be re-established.
407+
* @default 60000
408+
*/
409+
inactivityTimeoutMs?: number;
377410
/**
378411
* Optional logger (defaults to console).
379412
*/
@@ -428,6 +461,7 @@ interface ReplaneFinalOptions {
428461
fetchFn: typeof fetch;
429462
requestTimeoutMs: number;
430463
initializationTimeoutMs: number;
464+
inactivityTimeoutMs: number;
431465
sdkKey: string;
432466
logger: ReplaneLogger;
433467
retryDelayMs: number;
@@ -725,6 +759,7 @@ function toFinalOptions<T extends Configs>(defaults: ReplaneClientOptions<T>): R
725759
globalThis.fetch.bind(globalThis),
726760
requestTimeoutMs: defaults.requestTimeoutMs ?? 2000,
727761
initializationTimeoutMs: defaults.initializationTimeoutMs ?? 5000,
762+
inactivityTimeoutMs: defaults.inactivityTimeoutMs ?? 60_000,
728763
logger: defaults.logger ?? console,
729764
retryDelayMs: defaults.retryDelayMs ?? 200,
730765
context: {
@@ -775,6 +810,8 @@ async function fetchWithTimeout(
775810

776811
const SSE_DATA_PREFIX = "data:";
777812

813+
type SseEvent = { type: "ping" } | { type: "data"; payload: string };
814+
778815
async function* fetchSse(params: {
779816
fetchFn: typeof fetch;
780817
url: string;
@@ -784,7 +821,7 @@ async function* fetchSse(params: {
784821
method?: string;
785822
signal?: AbortSignal;
786823
onConnect?: () => void;
787-
}) {
824+
}): AsyncGenerator<SseEvent> {
788825
const abortController = new AbortController();
789826
const { signal, cleanUpSignals } = params.signal
790827
? combineAbortSignals([params.signal, abortController.signal])
@@ -841,10 +878,15 @@ async function* fetchSse(params: {
841878
for (const frame of frames) {
842879
// Parse lines inside a single SSE event frame
843880
const dataLines: string[] = [];
881+
let isPing = false;
844882

845883
for (const rawLine of frame.split(/\r?\n/)) {
846884
if (!rawLine) continue;
847-
if (rawLine.startsWith(":")) continue; // comment/keepalive
885+
if (rawLine.startsWith(":")) {
886+
// comment/keepalive - treat as ping
887+
isPing = true;
888+
continue;
889+
}
848890

849891
if (rawLine.startsWith(SSE_DATA_PREFIX)) {
850892
// Keep leading space after "data:" if present per spec
@@ -855,7 +897,9 @@ async function* fetchSse(params: {
855897

856898
if (dataLines.length) {
857899
const payload = dataLines.join("\n");
858-
yield payload;
900+
yield { type: "data", payload };
901+
} else if (isPing) {
902+
yield { type: "ping" };
859903
}
860904
}
861905
}

tests/index.spec.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,6 +1981,126 @@ describe("createReplaneClient", () => {
19811981
expect(client.get("config1")).toBe("initial");
19821982
});
19831983
});
1984+
1985+
describe("inactivity timeout", () => {
1986+
async function waitFor(
1987+
condition: () => boolean,
1988+
timeoutMs: number = 1000,
1989+
intervalMs: number = 10
1990+
): Promise<void> {
1991+
const start = Date.now();
1992+
while (!condition()) {
1993+
if (Date.now() - start > timeoutMs) {
1994+
throw new Error("waitFor timed out");
1995+
}
1996+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
1997+
}
1998+
}
1999+
2000+
it("should reconnect when no events are received within inactivity timeout", async () => {
2001+
const inactivityTimeoutMs = 50;
2002+
clientPromise = createClient({ inactivityTimeoutMs });
2003+
2004+
// First connection
2005+
const connection1 = await mockServer.acceptConnection();
2006+
await connection1.push({
2007+
type: "init",
2008+
configs: [{ name: "config1", overrides: [], version: 1, value: "initial" }],
2009+
});
2010+
2011+
const client = await clientPromise;
2012+
await sync();
2013+
expect(client.get("config1")).toBe("initial");
2014+
2015+
// Wait for connection to be aborted due to inactivity
2016+
await waitFor(() => connection1.aborted, inactivityTimeoutMs + 100);
2017+
expect(connection1.aborted).toBe(true);
2018+
2019+
// Client should reconnect - accept the new connection
2020+
const connection2 = await mockServer.acceptConnection();
2021+
await connection2.push({
2022+
type: "init",
2023+
configs: [{ name: "config1", overrides: [], version: 2, value: "reconnected" }],
2024+
});
2025+
await sync();
2026+
2027+
expect(client.get("config1")).toBe("reconnected");
2028+
2029+
client.close();
2030+
});
2031+
2032+
it("should reset inactivity timer when ping is received", async () => {
2033+
const inactivityTimeoutMs = 100;
2034+
clientPromise = createClient({ inactivityTimeoutMs });
2035+
2036+
const connection = await mockServer.acceptConnection();
2037+
await connection.push({
2038+
type: "init",
2039+
configs: [{ name: "config1", overrides: [], version: 1, value: "initial" }],
2040+
});
2041+
2042+
const client = await clientPromise;
2043+
await sync();
2044+
expect(client.get("config1")).toBe("initial");
2045+
2046+
// Wait for some time, but less than inactivity timeout
2047+
await new Promise((resolve) => setTimeout(resolve, inactivityTimeoutMs / 2));
2048+
expect(connection.aborted).toBe(false);
2049+
2050+
// Send a ping to reset the timer
2051+
await connection.ping();
2052+
await sync();
2053+
2054+
// Wait again, but less than inactivity timeout from the ping
2055+
await new Promise((resolve) => setTimeout(resolve, inactivityTimeoutMs / 2));
2056+
expect(connection.aborted).toBe(false);
2057+
2058+
// Now wait past the inactivity timeout from the ping
2059+
await waitFor(() => connection.aborted, inactivityTimeoutMs + 50);
2060+
expect(connection.aborted).toBe(true);
2061+
2062+
client.close();
2063+
});
2064+
2065+
it("should reset inactivity timer when data event is received", async () => {
2066+
const inactivityTimeoutMs = 100;
2067+
clientPromise = createClient({ inactivityTimeoutMs });
2068+
2069+
const connection = await mockServer.acceptConnection();
2070+
await connection.push({
2071+
type: "init",
2072+
configs: [{ name: "config1", overrides: [], version: 1, value: "initial" }],
2073+
});
2074+
2075+
const client = await clientPromise;
2076+
await sync();
2077+
2078+
// Wait for some time, but less than inactivity timeout
2079+
await new Promise((resolve) => setTimeout(resolve, inactivityTimeoutMs / 2));
2080+
expect(connection.aborted).toBe(false);
2081+
2082+
// Send a config change to reset the timer
2083+
await connection.push({
2084+
type: "config_change",
2085+
name: "config1",
2086+
overrides: [],
2087+
version: 2,
2088+
value: "updated",
2089+
});
2090+
await sync();
2091+
expect(client.get("config1")).toBe("updated");
2092+
2093+
// Wait again, but less than inactivity timeout from the event
2094+
await new Promise((resolve) => setTimeout(resolve, inactivityTimeoutMs / 2));
2095+
expect(connection.aborted).toBe(false);
2096+
2097+
// Now wait past the inactivity timeout from the event
2098+
await waitFor(() => connection.aborted, inactivityTimeoutMs + 50);
2099+
expect(connection.aborted).toBe(true);
2100+
2101+
client.close();
2102+
});
2103+
});
19842104
});
19852105

19862106
describe("createInMemoryReplaneClient", () => {

tests/utils.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface ReplaneServerMockHandler {
2020
startReplicationStream: (
2121
body: StartReplicationStreamBody,
2222
signal?: AbortSignal
23-
) => AsyncIterable<ReplicationStreamRecord>;
23+
) => AsyncIterable<MockConnectionEvent>;
2424
}
2525

2626
export function createReplaneServerMock(handler: ReplaneServerMockHandler) {
@@ -59,8 +59,11 @@ export function createReplaneServerMock(handler: ReplaneServerMockHandler) {
5959

6060
for await (const event of replicationStream) {
6161
if (signal?.aborted) break;
62-
controller.enqueue({ type: "data", data: JSON.stringify(event) });
63-
controller.enqueue({ type: "ping" });
62+
if (event.type === "ping") {
63+
controller.enqueue({ type: "ping" });
64+
} else {
65+
controller.enqueue({ type: "data", data: JSON.stringify(event.record) });
66+
}
6467
}
6568

6669
if (!signal?.aborted) {
@@ -122,7 +125,9 @@ export class MockReplaneServerController {
122125
const connection = new MockReplaneServerConnection(body, signal);
123126
self.knownConnections.add(connection);
124127
await self.connections.push(connection);
125-
yield* connection.events;
128+
for await (const event of connection.events) {
129+
yield event;
130+
}
126131
},
127132
});
128133
}
@@ -140,8 +145,12 @@ export class MockReplaneServerController {
140145
}
141146
}
142147

148+
type MockConnectionEvent =
149+
| { type: "data"; record: ReplicationStreamRecord }
150+
| { type: "ping" };
151+
143152
export class MockReplaneServerConnection {
144-
private readonly _events = new Channel<ReplicationStreamRecord>();
153+
private readonly _events = new Channel<MockConnectionEvent>();
145154
private _closed = false;
146155

147156
constructor(
@@ -171,7 +180,7 @@ export class MockReplaneServerConnection {
171180
return this._closed;
172181
}
173182

174-
get events(): AsyncIterable<ReplicationStreamRecord> {
183+
get events(): AsyncIterable<MockConnectionEvent> {
175184
return this._events;
176185
}
177186

@@ -181,7 +190,12 @@ export class MockReplaneServerConnection {
181190

182191
async push(event: ReplicationStreamRecord) {
183192
if (this._closed) return;
184-
await this._events.push(event);
193+
await this._events.push({ type: "data", record: event });
194+
}
195+
196+
async ping() {
197+
if (this._closed) return;
198+
await this._events.push({ type: "ping" });
185199
}
186200

187201
async throw(error: Error) {

0 commit comments

Comments
 (0)