Share upstream event stream hub
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { parseSseEventEnvelope } from './protocol.js';
|
||||
|
||||
export const DEFAULT_UPSTREAM_STALL_TIMEOUT_MS = 20_000;
|
||||
export const DEFAULT_UPSTREAM_RECONNECT_DELAY_MS = 250;
|
||||
|
||||
function waitForReconnectDelay(ms, signal) {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, Math.max(0, ms));
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers) {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { ...headers };
|
||||
}
|
||||
|
||||
export function createUpstreamSseReader({
|
||||
buildUrl,
|
||||
getHeaders = () => ({}),
|
||||
fetchImpl = fetch,
|
||||
parseBlock = parseSseEventEnvelope,
|
||||
initialLastEventId = '',
|
||||
signal,
|
||||
stallTimeoutMs = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
|
||||
reconnectDelayMs = DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
|
||||
onEvent,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
}) {
|
||||
let running = null;
|
||||
let stopped = false;
|
||||
let activeController = null;
|
||||
let lastEventId = typeof initialLastEventId === 'string' ? initialLastEventId : '';
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (activeController && !activeController.signal.aborted) {
|
||||
activeController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener('abort', stop, { once: true });
|
||||
|
||||
const start = () => {
|
||||
if (running) {
|
||||
return running;
|
||||
}
|
||||
|
||||
stopped = false;
|
||||
running = (async () => {
|
||||
while (!stopped && !signal?.aborted) {
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
const abortActive = () => controller.abort();
|
||||
signal?.addEventListener('abort', abortActive, { once: true });
|
||||
|
||||
let abortReason = null;
|
||||
let stallTimer = null;
|
||||
const clearStallTimer = () => {
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
}
|
||||
};
|
||||
const resetStallTimer = () => {
|
||||
clearStallTimer();
|
||||
if (stallTimeoutMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
stallTimer = setTimeout(() => {
|
||||
abortReason = 'upstream_stalled';
|
||||
controller.abort();
|
||||
}, stallTimeoutMs);
|
||||
};
|
||||
|
||||
try {
|
||||
const url = buildUrl();
|
||||
const headers = {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...normalizeHeaders(getHeaders()),
|
||||
};
|
||||
if (lastEventId) {
|
||||
headers['Last-Event-ID'] = lastEventId;
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url.toString(), {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response?.ok || !response.body) {
|
||||
onError?.({
|
||||
type: 'upstream_unavailable',
|
||||
status: response?.status ?? 0,
|
||||
response,
|
||||
});
|
||||
await waitForReconnectDelay(reconnectDelayMs, signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
onConnect?.({ response, lastEventId });
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
resetStallTimer();
|
||||
|
||||
while (!stopped && !signal?.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
resetStallTimer();
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||
|
||||
let separatorIndex = buffer.indexOf('\n\n');
|
||||
while (separatorIndex !== -1 && !stopped && !signal?.aborted) {
|
||||
const block = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
const envelope = parseBlock(block);
|
||||
if (envelope?.payload) {
|
||||
if (typeof envelope.eventId === 'string' && envelope.eventId.length > 0) {
|
||||
lastEventId = envelope.eventId;
|
||||
}
|
||||
onEvent?.({
|
||||
block,
|
||||
envelope,
|
||||
payload: envelope.payload,
|
||||
eventId: envelope.eventId,
|
||||
directory: envelope.directory,
|
||||
});
|
||||
}
|
||||
separatorIndex = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!stopped && !signal?.aborted && buffer.trim().length > 0) {
|
||||
const block = buffer.trim();
|
||||
const envelope = parseBlock(block);
|
||||
if (envelope?.payload) {
|
||||
if (typeof envelope.eventId === 'string' && envelope.eventId.length > 0) {
|
||||
lastEventId = envelope.eventId;
|
||||
}
|
||||
onEvent?.({
|
||||
block,
|
||||
envelope,
|
||||
payload: envelope.payload,
|
||||
eventId: envelope.eventId,
|
||||
directory: envelope.directory,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!stopped && !signal?.aborted && abortReason !== 'upstream_stalled') {
|
||||
onError?.({
|
||||
type: 'stream_error',
|
||||
error,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearStallTimer();
|
||||
signal?.removeEventListener('abort', abortActive);
|
||||
if (activeController === controller) {
|
||||
activeController = null;
|
||||
}
|
||||
onDisconnect?.({ reason: abortReason ?? (stopped || signal?.aborted ? 'stopped' : 'closed') });
|
||||
}
|
||||
|
||||
if (!stopped && !signal?.aborted) {
|
||||
await waitForReconnectDelay(reconnectDelayMs, signal);
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
running = null;
|
||||
});
|
||||
|
||||
return running;
|
||||
};
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
getLastEventId() {
|
||||
return lastEventId;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user