feat(api): add fleet snapshots and durable event stream

This commit is contained in:
chick
2026-07-17 14:16:46 +08:00
parent 1cc4a995ee
commit b4ae28c8f0
12 changed files with 1777 additions and 1 deletions
@@ -0,0 +1,189 @@
import http from 'node:http';
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import { EventJournal, type EventEnvelope } from '../../application/events/event-journal.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerEventRoutes } from './event-routes.js';
const databases: Database.Database[] = [];
const apps: ReturnType<typeof buildApp>[] = [];
const at = '2026-07-17T12:34:56.789Z';
const event = (id: string): EventEnvelope => ({
kind: 'job',
id,
occurredAt: at,
requestId: `request-${id}`,
jobId: 'job-1',
});
function fixture(authenticate: ((request: unknown) => boolean) | null = () => true) {
const database = new Database(':memory:');
migrateDatabase(database);
databases.push(database);
const journal = new EventJournal(database);
const app = buildApp({
registerRoutes: (scope) =>
registerEventRoutes(scope, {
journal,
...(authenticate === null ? {} : { authenticate }),
}),
});
apps.push(app);
return { app, journal };
}
afterEach(async () => {
await Promise.all(apps.splice(0).map(async (app) => app.close()));
for (const database of databases.splice(0)) database.close();
});
describe('events HTTP route', () => {
it('requires the route-local authentication dependency and does not silently expose events', async () => {
for (const authenticate of [null, () => false]) {
const { app } = fixture(authenticate);
const response = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(response.statusCode).toBe(401);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toBeTruthy();
expect(response.json()).toMatchObject({
type: 'about:blank',
title: 'Unauthorized',
status: 401,
code: 'UNAUTHORIZED',
requestId: response.headers['x-request-id'],
});
}
});
it.each(['', '\0bad'])('rejects a malformed Last-Event-ID value (%j)', async (lastEventId) => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: { 'last-event-id': lastEventId },
});
expect(response.statusCode).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
expect(response.json().requestId).toBe(response.headers['x-request-id']);
});
it('returns the frozen conflict Problem Details for an unavailable cursor', async () => {
const { app, journal } = fixture();
journal.append(event('known'));
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: { 'last-event-id': 'expired-or-unknown' },
});
expect(response.statusCode).toBe(409);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toBeTruthy();
expect(response.json()).toEqual({
type: 'about:blank',
title: 'Conflict',
status: 409,
code: 'EVENT_POSITION_UNAVAILABLE',
detail: 'The requested event position is no longer available.',
requestId: response.headers['x-request-id'],
});
});
it('replays strictly after the cursor, then streams live events without an invented event field', async () => {
const { app, journal } = fixture();
journal.append(event('one'));
journal.append(event('two'));
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const chunks: string[] = [];
let response!: http.IncomingMessage;
await new Promise<void>((resolve, reject) => {
const request = http.get(
`${address}/api/v1/events`,
{ headers: { 'Last-Event-ID': 'one' } },
(incoming) => {
response = incoming;
incoming.setEncoding('utf8');
incoming.on('data', (chunk: string) => {
chunks.push(chunk);
if (chunks.join('').includes('id: three\n')) resolve();
});
incoming.on('error', reject);
journal.append(event('three'));
},
);
request.on('error', reject);
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.headers['cache-control']).toBe('no-cache');
expect(response.headers['x-request-id']).toBeTruthy();
const body = chunks.join('');
expect(body).toBe(
`id: two\ndata: ${JSON.stringify(event('two'))}\n\n` +
`id: three\ndata: ${JSON.stringify(event('three'))}\n\n`,
);
expect(body).not.toContain('\nevent:');
response.destroy();
});
it('subscribes before replay so an event arriving during the read is neither lost nor duplicated', async () => {
const { app, journal } = fixture();
journal.append(event('one'));
const originalRead = journal.readAfter.bind(journal);
vi.spyOn(journal, 'readAfter').mockImplementation((cursor) => {
journal.append(event('during-read'));
return originalRead(cursor);
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const body = await new Promise<string>((resolve, reject) => {
http
.get(`${address}/api/v1/events`, { headers: { 'Last-Event-ID': 'one' } }, (response) => {
response.setEncoding('utf8');
response.once('data', (chunk: string) => {
response.destroy();
resolve(chunk);
});
response.on('error', reject);
})
.on('error', reject);
});
const frame = `id: during-read\ndata: ${JSON.stringify(event('during-read'))}\n\n`;
expect(body).toBe(frame);
});
it('unsubscribes when the client disconnects', async () => {
const { app, journal } = fixture();
const unsubscribe = vi.fn();
const originalSubscribe = journal.subscribe.bind(journal);
vi.spyOn(journal, 'subscribe').mockImplementation((subscriber) => {
const originalUnsubscribe = originalSubscribe(subscriber);
return () => {
originalUnsubscribe();
unsubscribe();
};
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
await new Promise<void>((resolve, reject) => {
http
.get(`${address}/api/v1/events`, (response) => {
response.destroy();
resolve();
})
.on('error', reject);
});
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce());
});
});
+162
View File
@@ -0,0 +1,162 @@
import type { ServerResponse } from 'node:http';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
EventJournal,
formatServerSentEvent,
type JournalEvent,
} from '../../application/events/event-journal.js';
const FORBIDDEN_CURSOR_CHARACTERS = /[\r\n\0]/u;
export interface EventRoutesOptions {
readonly journal: EventJournal;
/**
* Route-local until the control plane supplies its cookie authentication hook.
* Deliberately fail closed when omitted rather than exposing the event stream.
*/
readonly authenticate?: (request: FastifyRequest) => boolean;
}
const problem = (
request: FastifyRequest,
status: 400 | 401 | 409,
code: string,
detail: string,
): Record<string, unknown> => ({
type: 'about:blank',
title: status === 400 ? 'Bad Request' : status === 401 ? 'Unauthorized' : 'Conflict',
status,
code,
detail,
requestId: request.id,
});
function cursor(request: FastifyRequest): string | undefined {
const value = request.headers['last-event-id'];
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.length === 0 || FORBIDDEN_CURSOR_CHARACTERS.test(value)) {
throw new TypeError('Last-Event-ID must be a non-empty event identifier');
}
return value;
}
function writeEvent(response: ServerResponse, event: JournalEvent, cleanup: () => void): void {
if (response.destroyed || response.writableEnded) {
cleanup();
return;
}
try {
response.write(formatServerSentEvent(event.envelope), (error) => {
if (error) cleanup();
});
} catch {
cleanup();
}
}
export function registerEventRoutes(app: FastifyInstance, options: EventRoutesOptions): void {
app.get('/api/v1/events', async (request, reply) => {
if (!options.authenticate?.(request)) {
return reply
.code(401)
.type('application/problem+json')
.send(
problem(
request,
401,
'UNAUTHORIZED',
'Authentication is required to access the event stream.',
),
);
}
let lastEventId: string | undefined;
try {
lastEventId = cursor(request);
} catch {
return reply
.code(400)
.type('application/problem+json')
.send(
problem(
request,
400,
'VALIDATION_FAILED',
'Last-Event-ID must be a non-empty valid event identifier.',
),
);
}
// Subscribe first. Events appended while the durable replay query runs are buffered;
// sequence de-duplication below closes the replay/subscription race.
const buffered: JournalEvent[] = [];
const stream: { response?: ServerResponse } = {};
let live = false;
let cleaned = false;
let unsubscribe = () => {};
const cleanup = () => {
if (cleaned) return;
cleaned = true;
unsubscribe();
const response = stream.response;
if (response && !response.destroyed && !response.writableEnded) response.destroy();
};
unsubscribe = options.journal.subscribe((event) => {
const response = stream.response;
if (!live || !response) {
buffered.push(event);
return;
}
writeEvent(response, event, cleanup);
});
let replay;
try {
replay = options.journal.readAfter(lastEventId);
} catch (error) {
cleanup();
throw error;
}
if (replay.status === 'unavailable') {
cleanup();
return reply
.code(409)
.type('application/problem+json')
.send(
problem(
request,
409,
'EVENT_POSITION_UNAVAILABLE',
'The requested event position is no longer available.',
),
);
}
reply.hijack();
const response = reply.raw;
stream.response = response;
response.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Request-Id': request.id,
});
response.flushHeaders();
response.on('close', cleanup);
response.on('error', cleanup);
const replayedSequences = new Set<number>();
for (const event of replay.events) {
replayedSequences.add(event.sequence);
writeEvent(response, event, cleanup);
}
for (const event of buffered) {
if (!replayedSequences.has(event.sequence)) writeEvent(response, event, cleanup);
}
buffered.length = 0;
live = true;
return reply;
});
}