feat : notify new update income
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import "server-only";
|
||||
|
||||
import type {
|
||||
DeploymentStatus,
|
||||
DeploymentStatusEvent,
|
||||
} from "./status";
|
||||
import { parseDeploymentStatusEvent } from "./status";
|
||||
import {
|
||||
getRedisClient,
|
||||
redisEventChannel,
|
||||
redisEventPrefix,
|
||||
} from "@/lib/redis/client";
|
||||
|
||||
export const DEPLOYMENT_EVENT_TOPIC = "deployments";
|
||||
|
||||
const statusKey = () => `${redisEventPrefix()}:deployments:latest`;
|
||||
const STATUS_TTL_SECONDS: Record<DeploymentStatus, number> = {
|
||||
deploying: 60 * 60,
|
||||
ready: 10 * 60,
|
||||
failed: 10 * 60,
|
||||
};
|
||||
|
||||
const publishScript = `
|
||||
if ARGV[2] ~= "deploying" then
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if not current then return 0 end
|
||||
local ok, decoded = pcall(cjson.decode, current)
|
||||
if not ok or decoded.deploymentId ~= ARGV[3] then return 0 end
|
||||
end
|
||||
redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[4])
|
||||
redis.call("PUBLISH", KEYS[2], ARGV[1])
|
||||
return 1
|
||||
`;
|
||||
|
||||
export async function getLatestDeploymentStatus(): Promise<DeploymentStatusEvent | null> {
|
||||
const redis = await getRedisClient();
|
||||
const value = await redis.get(statusKey());
|
||||
return value ? parseDeploymentStatusEvent(value) : null;
|
||||
}
|
||||
|
||||
export async function publishDeploymentStatus(
|
||||
deploymentId: string,
|
||||
status: DeploymentStatus,
|
||||
): Promise<{ accepted: boolean; event: DeploymentStatusEvent }> {
|
||||
const event: DeploymentStatusEvent = {
|
||||
deploymentId,
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const payload = JSON.stringify(event);
|
||||
const redis = await getRedisClient();
|
||||
const accepted = await redis.eval(
|
||||
publishScript,
|
||||
2,
|
||||
statusKey(),
|
||||
redisEventChannel(DEPLOYMENT_EVENT_TOPIC),
|
||||
payload,
|
||||
status,
|
||||
deploymentId,
|
||||
STATUS_TTL_SECONDS[status],
|
||||
);
|
||||
return { accepted: accepted === 1, event };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isDeploymentId,
|
||||
isDeploymentStatus,
|
||||
parseDeploymentStatusEvent,
|
||||
} from "./status";
|
||||
|
||||
const deploymentId = "a".repeat(40);
|
||||
|
||||
describe("deployment lifecycle status", () => {
|
||||
it("accepts supported statuses and full commit identifiers", () => {
|
||||
expect(isDeploymentId(deploymentId)).toBe(true);
|
||||
expect(isDeploymentId("abc123")).toBe(false);
|
||||
expect(isDeploymentStatus("deploying")).toBe(true);
|
||||
expect(isDeploymentStatus("ready")).toBe(true);
|
||||
expect(isDeploymentStatus("failed")).toBe(true);
|
||||
expect(isDeploymentStatus("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses a valid lifecycle event", () => {
|
||||
expect(parseDeploymentStatusEvent(JSON.stringify({
|
||||
deploymentId,
|
||||
status: "deploying",
|
||||
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||
}))).toEqual({
|
||||
deploymentId,
|
||||
status: "deploying",
|
||||
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed or incomplete events", () => {
|
||||
expect(parseDeploymentStatusEvent("not-json")).toBeNull();
|
||||
expect(parseDeploymentStatusEvent(JSON.stringify({
|
||||
deploymentId,
|
||||
status: "unknown",
|
||||
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||
}))).toBeNull();
|
||||
expect(parseDeploymentStatusEvent(JSON.stringify({
|
||||
deploymentId,
|
||||
status: "ready",
|
||||
updatedAt: "not-a-date",
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export const DEPLOYMENT_STATUSES = ["deploying", "ready", "failed"] as const;
|
||||
|
||||
export type DeploymentStatus = (typeof DEPLOYMENT_STATUSES)[number];
|
||||
|
||||
export interface DeploymentStatusEvent {
|
||||
deploymentId: string;
|
||||
status: DeploymentStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const DEPLOYMENT_ID_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
|
||||
export function isDeploymentId(value: unknown): value is string {
|
||||
return typeof value === "string" && DEPLOYMENT_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function isDeploymentStatus(value: unknown): value is DeploymentStatus {
|
||||
return DEPLOYMENT_STATUSES.some((status) => status === value);
|
||||
}
|
||||
|
||||
export function parseDeploymentStatusEvent(
|
||||
value: string,
|
||||
): DeploymentStatusEvent | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, unknown>;
|
||||
if (
|
||||
!isDeploymentId(parsed.deploymentId) ||
|
||||
!isDeploymentStatus(parsed.status) ||
|
||||
typeof parsed.updatedAt !== "string" ||
|
||||
!Number.isFinite(Date.parse(parsed.updatedAt))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
deploymentId: parsed.deploymentId,
|
||||
status: parsed.status,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+65
-11
@@ -3,8 +3,12 @@ import { createEventStream, eventStreamHeaders, type EventStreamChannel } from "
|
||||
import { connectRedisClient, createRedisClient, redisEventChannel } from "@/lib/redis/client";
|
||||
|
||||
type Subscriber = { client: ReturnType<typeof createRedisClient>; ready: Promise<void> };
|
||||
type StreamListener = {
|
||||
stream: EventStreamChannel;
|
||||
receive(value: string): void;
|
||||
};
|
||||
let subscriber: Subscriber | undefined;
|
||||
const listeners = new Map<string, Set<EventStreamChannel>>();
|
||||
const listeners = new Map<string, Set<StreamListener>>();
|
||||
// Serialize subscribe/unsubscribe so a last-reader disconnect cannot race a join.
|
||||
let operations = Promise.resolve();
|
||||
function ordered<T>(task: () => Promise<T>): Promise<T> {
|
||||
@@ -19,15 +23,15 @@ function getSubscriber(): Subscriber {
|
||||
const state: Subscriber = { client, ready: Promise.resolve() };
|
||||
subscriber = state;
|
||||
client.on("message", (channel, value) => {
|
||||
const event = parseInvalidationEvent(value);
|
||||
if (!event || redisEventChannel(expectedTopic(event)) !== channel) return;
|
||||
for (const stream of listeners.get(channel) ?? []) stream.send(event);
|
||||
for (const listener of listeners.get(channel) ?? []) listener.receive(value);
|
||||
});
|
||||
client.on("close", () => {
|
||||
if (subscriber !== state) return;
|
||||
subscriber = undefined;
|
||||
// Force EventSource reconnect/refetch after a gap in the Pub/Sub delivery.
|
||||
for (const streams of listeners.values()) for (const stream of [...streams]) stream.close();
|
||||
for (const streams of listeners.values()) {
|
||||
for (const listener of [...streams]) listener.stream.close();
|
||||
}
|
||||
listeners.clear();
|
||||
client.disconnect();
|
||||
});
|
||||
@@ -39,7 +43,18 @@ function getSubscriber(): Subscriber {
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function createRedisEventResponse(topic: string, signal: AbortSignal): Promise<Response> {
|
||||
interface RedisStreamOptions {
|
||||
initialEvents?:
|
||||
| ReadonlyArray<{ eventName: string; data: string }>
|
||||
| (() => Promise<ReadonlyArray<{ eventName: string; data: string }>>);
|
||||
receive(stream: EventStreamChannel, value: string): void;
|
||||
}
|
||||
|
||||
async function createRedisStreamResponse(
|
||||
topic: string,
|
||||
signal: AbortSignal,
|
||||
options: RedisStreamOptions,
|
||||
): Promise<Response> {
|
||||
const channel = redisEventChannel(topic);
|
||||
const state = getSubscriber();
|
||||
await state.ready;
|
||||
@@ -50,9 +65,9 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
||||
await state.client.subscribe(channel);
|
||||
listeners.set(channel, new Set());
|
||||
}
|
||||
let eventStream: EventStreamChannel | undefined;
|
||||
let listener: StreamListener | undefined;
|
||||
const release = () => {
|
||||
if (eventStream) listeners.get(channel)?.delete(eventStream);
|
||||
if (listener) listeners.get(channel)?.delete(listener);
|
||||
void ordered(async () => {
|
||||
if (subscriber === state && listeners.get(channel)?.size === 0) {
|
||||
listeners.delete(channel);
|
||||
@@ -62,10 +77,21 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
||||
};
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
eventStream = createEventStream({ signal, onClose: release });
|
||||
listeners.get(channel)?.add(eventStream);
|
||||
const eventStream = createEventStream({ signal, onClose: release });
|
||||
listener = {
|
||||
stream: eventStream,
|
||||
receive: (value) => options.receive(eventStream, value),
|
||||
};
|
||||
listeners.get(channel)?.add(listener);
|
||||
const initialEvents = typeof options.initialEvents === "function"
|
||||
? await options.initialEvents()
|
||||
: options.initialEvents ?? [];
|
||||
for (const event of initialEvents) {
|
||||
eventStream.sendNamed(event.eventName, event.data);
|
||||
}
|
||||
return eventStream;
|
||||
} catch (cause) {
|
||||
listener?.stream.close();
|
||||
release();
|
||||
throw cause;
|
||||
}
|
||||
@@ -73,10 +99,38 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
||||
return new Response(stream.stream, { headers: eventStreamHeaders() });
|
||||
}
|
||||
|
||||
export async function createRedisEventResponse(
|
||||
topic: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Response> {
|
||||
return createRedisStreamResponse(topic, signal, {
|
||||
receive(stream, value) {
|
||||
const event = parseInvalidationEvent(value);
|
||||
if (event && expectedTopic(event) === topic) stream.send(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createRedisNamedEventResponse(
|
||||
topic: string,
|
||||
eventName: string,
|
||||
signal: AbortSignal,
|
||||
initialEvents: RedisStreamOptions["initialEvents"] = [],
|
||||
): Promise<Response> {
|
||||
return createRedisStreamResponse(topic, signal, {
|
||||
initialEvents,
|
||||
receive(stream, value) {
|
||||
stream.sendNamed(eventName, value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function closeRedisEventStreams() {
|
||||
const state = subscriber;
|
||||
subscriber = undefined;
|
||||
for (const streams of listeners.values()) for (const stream of [...streams]) stream.close();
|
||||
for (const streams of listeners.values()) {
|
||||
for (const listener of [...streams]) listener.stream.close();
|
||||
}
|
||||
listeners.clear();
|
||||
state?.client.disconnect();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user