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
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
hasDeploymentChanged,
normalizeDeploymentId,
} from "./version";
describe("deployment version comparison", () => {
it("normalizes identifiers before comparing them", () => {
expect(normalizeDeploymentId(" release-2 ")).toBe("release-2");
expect(normalizeDeploymentId(" ")).toBeNull();
expect(hasDeploymentChanged("release-1", " release-2 ")).toBe(true);
});
it("does not announce the initial or unchanged deployment", () => {
expect(hasDeploymentChanged(null, "release-2")).toBe(false);
expect(hasDeploymentChanged("release-2", "release-2")).toBe(false);
expect(hasDeploymentChanged("release-2", "")).toBe(false);
});
});
+15
View File
@@ -0,0 +1,15 @@
export function normalizeDeploymentId(
value: string | null | undefined,
): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
export function hasDeploymentChanged(
current: string | null | undefined,
next: string | null | undefined,
): boolean {
const currentId = normalizeDeploymentId(current);
const nextId = normalizeDeploymentId(next);
return currentId !== null && nextId !== null && currentId !== nextId;
}
+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);
},