Skip to content

Commit 84745e1

Browse files
committed
fix: harden SSE implementation
1 parent be3f958 commit 84745e1

1 file changed

Lines changed: 60 additions & 42 deletions

File tree

src/index.ts

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const PROJECT_EVENT_TYPES = ["created", "updated", "deleted"] as const;
22

3-
export interface ProjectEvent {
3+
interface ProjectEvent {
44
type: (typeof PROJECT_EVENT_TYPES)[number];
55
configId: string;
66
}
@@ -122,7 +122,7 @@ class ReplaneRemoteStorage implements ReplaneStorage {
122122
this.closeController.signal,
123123
options.signal,
124124
]);
125-
const events = fetchSse({
125+
const rawEvents = fetchSse({
126126
fetchFn: options.fetchFn,
127127
headers: {
128128
Authorization: this.getAuthHeader(options),
@@ -133,7 +133,8 @@ class ReplaneRemoteStorage implements ReplaneStorage {
133133
url: this.getApiEndpoint("/v1/events", options),
134134
});
135135

136-
for await (const event of events) {
136+
for await (const rawEvent of rawEvents) {
137+
const event = JSON.parse(rawEvent);
137138
if (
138139
typeof event === "object" &&
139140
event !== null &&
@@ -315,7 +316,7 @@ export interface ReplaneLogger {
315316
error(...args: any[]): void;
316317
}
317318

318-
export interface GetConfigOptions<T> extends Partial<ReplaneClientOptions> {}
319+
export interface GetConfigOptions extends Partial<ReplaneClientOptions> {}
319320

320321
export interface ConfigValueWatcher<T> {
321322
/** Current config value (or fallback if not found). */
@@ -328,12 +329,12 @@ export interface ReplaneClient {
328329
/** Fetch a config value by name. */
329330
getConfigValue<T = unknown>(
330331
configName: string,
331-
options?: GetConfigOptions<T>
332+
options?: GetConfigOptions
332333
): Promise<T | undefined>;
333334
/** Watch a config value by name. */
334335
watchConfigValue<T = unknown>(
335336
configName: string,
336-
options?: GetConfigOptions<T>
337+
options?: GetConfigOptions
337338
): Promise<ConfigValueWatcher<T>>;
338339
/** Close the client and clean up resources. */
339340
close(): void;
@@ -399,7 +400,7 @@ function _createReplaneClient(
399400

400401
async function getConfigValue<T = unknown>(
401402
configName: string,
402-
inputOptions: GetConfigOptions<T> = {}
403+
inputOptions: GetConfigOptions = {}
403404
): Promise<T> {
404405
return await storage.getConfigValue<T>({
405406
configName,
@@ -414,7 +415,7 @@ function _createReplaneClient(
414415

415416
async function watchConfigValue<T = unknown>(
416417
configName: string,
417-
originalOptions: GetConfigOptions<T> = {}
418+
originalOptions: GetConfigOptions = {}
418419
): Promise<ConfigValueWatcher<T>> {
419420
const options = combineOptions(sdkOptions, originalOptions);
420421
let currentWatcherValue: T = await storage.getConfigValue<T>({
@@ -518,65 +519,82 @@ function combineOptions(
518519
};
519520
}
520521

522+
const SSE_DATA_PREFIX = "data:";
523+
521524
async function* fetchSse(params: {
522525
fetchFn: typeof fetch;
523526
url: string;
524-
headers: Record<string, string>;
525-
method: string;
526-
signal: AbortSignal;
527+
headers?: Record<string, string>;
528+
method?: string;
529+
signal?: AbortSignal;
527530
}) {
528531
const abortController = new AbortController();
532+
const signal = params.signal
533+
? combineAbortSignals([params.signal, abortController.signal])
534+
: abortController.signal;
529535

530-
const signal = combineAbortSignals([params.signal, abortController.signal]);
531-
532-
const response = await fetch(params.url, {
533-
method: params.method,
534-
headers: params.headers,
536+
const res = await params.fetchFn(params.url, {
537+
method: params.method ?? "GET",
538+
headers: { Accept: "text/event-stream", ...(params.headers ?? {}) },
535539
signal,
536540
});
537541

538-
if (response.status !== 200) {
539-
throw new Error("Failed to fetch SSE endpoint: " + response.statusText);
540-
}
542+
await ensureSuccessfulResponse(res, `SSE ${params.url}`);
541543

542-
await ensureSuccessfulResponse(response, `Fetch SSE ${params.url}`);
543-
544-
if (response.body === null) {
544+
if (!res.body) {
545545
throw new ReplaneError({
546546
message: `Failed to fetch SSE ${params.url}: body is empty`,
547547
code: ReplaneErrorCode.Unknown,
548548
});
549549
}
550550

551-
const decodedResponse = new TextDecoderStream();
552-
await response.body.pipeTo(decodedResponse.writable, {
553-
signal,
554-
});
551+
const decoded = res.body.pipeThrough(new TextDecoderStream());
552+
const reader = decoded.getReader();
555553

556-
let leftover: string = "";
554+
let buffer = "";
557555

558556
try {
559-
for await (const responsePart of decodedResponse.readable) {
560-
leftover += responsePart;
561-
562-
let messages = leftover.split("\n\n");
563-
leftover = messages.at(-1) ?? "";
564-
565-
for (const message of messages.slice(0, -1)) {
566-
if (!message.startsWith(SSE_DATA_MESSAGE_PREFIX)) continue;
557+
while (true) {
558+
const { value, done } = await reader.read();
559+
if (done) break;
560+
buffer += value!;
561+
562+
// Split on blank line; handle both \n\n and \r\n\r\n
563+
const frames = buffer.split(/\r?\n\r?\n/);
564+
buffer = frames.pop() ?? "";
565+
566+
for (const frame of frames) {
567+
// Parse lines inside a single SSE event frame
568+
let dataLines: string[] = [];
569+
570+
for (const rawLine of frame.split(/\r?\n/)) {
571+
if (!rawLine) continue;
572+
if (rawLine.startsWith(":")) continue; // comment/keepalive
573+
574+
if (rawLine.startsWith(SSE_DATA_PREFIX)) {
575+
// Keep leading space after "data:" if present per spec
576+
const line = rawLine
577+
.slice(SSE_DATA_PREFIX.length)
578+
.replace(/^\s/, "");
579+
dataLines.push(line);
580+
}
581+
// Optionally handle event:, id:, retry: here if you need them
582+
}
567583

568-
yield JSON.parse(
569-
message.slice(SSE_DATA_MESSAGE_PREFIX.length)
570-
) as unknown;
584+
if (dataLines.length) {
585+
const payload = dataLines.join("\n");
586+
yield payload;
587+
}
571588
}
572589
}
573590
} finally {
574591
abortController.abort();
592+
try {
593+
await reader.cancel();
594+
} catch {}
575595
}
576596
}
577597

578-
const SSE_DATA_MESSAGE_PREFIX = "data: ";
579-
580598
async function ensureSuccessfulResponse(response: Response, message: string) {
581599
if (response.status === 404) {
582600
throw new ReplaneError({
@@ -726,13 +744,13 @@ class Subject<T> implements Observable<T>, Observer<T> {
726744
}
727745
}
728746

729-
export interface DebouncerOptions {
747+
interface DebouncerOptions {
730748
name: string;
731749
task: () => Promise<void>;
732750
onError: (err: unknown) => void;
733751
}
734752

735-
export class Debouncer {
753+
class Debouncer {
736754
private stopped = false;
737755
private running = false;
738756
private rescheduleRequested = false;

0 commit comments

Comments
 (0)