feat(deploy): notify clients of new releases
CI / Verify (push) Successful in 1m8s
CI / Build immutable images and deploy (push) Has been skipped

This commit is contained in:
2026-08-29 07:13:04 +00:00 Unverified
parent 6fe6d8fd95
commit be095ab236
10 changed files with 162 additions and 1 deletions
+12
View File
@@ -38,10 +38,22 @@ describe("SSE stream", () => {
expect(decoder.decode((await reader.read()).value)).toBe(
'event: invalidation\ndata: {"type":"page.updated","id":"opaque","version":7}\n\n',
);
channel.sendNamed("deployment", "release-2");
expect(decoder.decode((await reader.read()).value)).toBe(
"event: deployment\ndata: release-2\n\n",
);
await vi.advanceTimersByTimeAsync(50);
expect(decoder.decode((await reader.read()).value)).toMatch(/^: heartbeat /u);
await vi.advanceTimersByTimeAsync(49);
expect((await reader.read()).done).toBe(true);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("rejects event names that could inject another SSE field", () => {
const channel = createEventStream();
expect(() => channel.sendNamed("deployment\ndata", "release-2")).toThrow(
"SSE event names",
);
channel.close();
});
});
+18
View File
@@ -5,6 +5,20 @@ export const SSE_MAX_LIFETIME_MS = 30 * 60 * 1_000;
const encoder = new TextEncoder();
function namedEvent(eventName: string, data: string): string {
if (!/^[a-z0-9_-]+$/iu.test(eventName)) {
throw new Error(
"SSE event names may contain only letters, numbers, underscores, and hyphens.",
);
}
const lines = data
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => `data: ${line}`)
.join("\n");
return `event: ${eventName}\n${lines}\n\n`;
}
export interface EventStreamOptions {
signal?: AbortSignal;
heartbeatMs?: number;
@@ -15,6 +29,7 @@ export interface EventStreamOptions {
export interface EventStreamChannel {
stream: ReadableStream<Uint8Array>;
send(event: InvalidationEvent): boolean;
sendNamed(eventName: string, data: string): boolean;
close(): void;
}
@@ -87,6 +102,9 @@ export function createEventStream(
send(event) {
return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`);
},
sendNamed(eventName, data) {
return enqueue(namedEvent(eventName, data));
},
close() {
close(true);
},