feat(api): add fleet snapshots and durable event stream
This commit is contained in:
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user