feat(web): add durable control-plane event updates
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multi-simadmin/contracts": "workspace:*",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useControlPlaneEvents } from './events/use-control-plane-events.js';
|
||||
import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js';
|
||||
|
||||
import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js';
|
||||
@@ -57,6 +61,7 @@ export interface AppShellProps {
|
||||
instanceDataSource?: InstanceDataSource;
|
||||
capabilities?: InstanceCapabilityMap;
|
||||
capabilityDataSource?: CapabilityDataSource;
|
||||
eventStreamClient?: EventStreamClient;
|
||||
}
|
||||
|
||||
const MODULE_LABELS = INSTANCE_MODULE_LABELS;
|
||||
@@ -123,6 +128,7 @@ function Page({
|
||||
instanceDataSource,
|
||||
capabilities,
|
||||
capabilityDataSource,
|
||||
fleetRefreshSignal,
|
||||
}: {
|
||||
route: ResolvedRoute;
|
||||
fleetDataSource: FleetDataSource | undefined;
|
||||
@@ -131,12 +137,14 @@ function Page({
|
||||
instanceDataSource: InstanceDataSource | undefined;
|
||||
capabilities: InstanceCapabilityMap | undefined;
|
||||
capabilityDataSource: CapabilityDataSource | undefined;
|
||||
fleetRefreshSignal: number;
|
||||
}): ReactNode {
|
||||
if (route.kind === 'fleet')
|
||||
return (
|
||||
<FleetPage
|
||||
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
||||
{...(fleetData ? { initialData: fleetData } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
/>
|
||||
);
|
||||
if (route.kind === 'instance-new')
|
||||
@@ -201,9 +209,17 @@ export function AppShell({
|
||||
instanceDataSource,
|
||||
capabilities,
|
||||
capabilityDataSource,
|
||||
eventStreamClient,
|
||||
}: AppShellProps) {
|
||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
||||
const resolved = resolveRoute(pathname);
|
||||
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
||||
const routeInstanceId = route.params?.instanceId;
|
||||
const refresh = useControlPlaneEvents(
|
||||
routeInstanceId,
|
||||
eventStreamClient ?? defaultEventStreamClient,
|
||||
);
|
||||
const currentSection = section(route);
|
||||
return (
|
||||
<div className="app-shell" data-route={route.kind}>
|
||||
@@ -239,12 +255,13 @@ export function AppShell({
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Page
|
||||
route={route}
|
||||
fleetDataSource={fleetDataSource ?? createFleetApiDataSource()}
|
||||
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
|
||||
fleetData={fleetData}
|
||||
instance={instance}
|
||||
instanceDataSource={instanceDataSource}
|
||||
capabilities={capabilities}
|
||||
capabilityDataSource={capabilityDataSource}
|
||||
fleetRefreshSignal={refresh.fleet}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createEventInvalidationController } from './event-invalidation-controller.js';
|
||||
|
||||
const base = { id: 'event', occurredAt: '2026-07-17T10:00:00.000Z', requestId: 'request' } as const;
|
||||
|
||||
describe('event invalidation controller', () => {
|
||||
it('coalesces bursts into the correct global invalidation domains', () => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const emit = vi.fn();
|
||||
const controller = createEventInvalidationController(emit, {
|
||||
schedule: (callback) => callbacks.push(callback),
|
||||
});
|
||||
controller.push({ ...base, kind: 'job', jobId: 'job-1' });
|
||||
controller.push({ ...base, kind: 'item', jobId: 'job-1', itemId: 'item-1' });
|
||||
controller.push({ ...base, kind: 'audit', auditEventId: 'audit-1' });
|
||||
expect(callbacks).toHaveLength(1);
|
||||
callbacks[0]?.();
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fleet: false, jobs: true, audit: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('invalidates Fleet for every instance event but detail only for the latest route owner', () => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const emit = vi.fn();
|
||||
const controller = createEventInvalidationController(emit, {
|
||||
schedule: (callback) => callbacks.push(callback),
|
||||
});
|
||||
controller.setActiveInstance('old');
|
||||
controller.push({ ...base, kind: 'instance', instanceId: 'old' });
|
||||
controller.setActiveInstance('new');
|
||||
controller.push({ ...base, kind: 'instance', instanceId: 'new' });
|
||||
callbacks[0]?.();
|
||||
const batch = emit.mock.calls[0]?.[0];
|
||||
expect(batch.fleet).toBe(true);
|
||||
expect([...batch.instanceIds]).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('cancels pending work and ignores events after disposal', () => {
|
||||
const callback = vi.fn();
|
||||
const cancel = vi.fn();
|
||||
const emit = vi.fn();
|
||||
const controller = createEventInvalidationController(emit, {
|
||||
schedule: () => callback,
|
||||
cancel,
|
||||
});
|
||||
controller.push({ ...base, kind: 'audit', auditEventId: 'audit-1' });
|
||||
controller.dispose();
|
||||
controller.push({ ...base, kind: 'job', jobId: 'job-1' });
|
||||
callback();
|
||||
expect(cancel).toHaveBeenCalledWith(callback);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { EventEnvelope } from '@multi-simadmin/contracts';
|
||||
|
||||
export interface InvalidationBatch {
|
||||
readonly fleet: boolean;
|
||||
readonly instanceIds: ReadonlySet<string>;
|
||||
readonly jobs: boolean;
|
||||
readonly audit: boolean;
|
||||
}
|
||||
|
||||
export interface EventInvalidationController {
|
||||
push(event: EventEnvelope): void;
|
||||
setActiveInstance(instanceId: string | undefined): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface EventInvalidationOptions {
|
||||
readonly delayMs?: number;
|
||||
readonly schedule?: (callback: () => void, delayMs: number) => unknown;
|
||||
readonly cancel?: (handle: unknown) => void;
|
||||
}
|
||||
|
||||
export function createEventInvalidationController(
|
||||
emit: (batch: InvalidationBatch) => void,
|
||||
options: EventInvalidationOptions = {},
|
||||
): EventInvalidationController {
|
||||
const schedule = options.schedule ?? ((callback, delay) => setTimeout(callback, delay));
|
||||
const cancel =
|
||||
options.cancel ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
let activeInstanceId: string | undefined;
|
||||
let handle: unknown;
|
||||
let disposed = false;
|
||||
let fleet = false;
|
||||
let jobs = false;
|
||||
let audit = false;
|
||||
const instanceIds = new Set<string>();
|
||||
|
||||
const flush = (): void => {
|
||||
handle = undefined;
|
||||
if (disposed) return;
|
||||
const batch: InvalidationBatch = {
|
||||
fleet,
|
||||
instanceIds: new Set(instanceIds),
|
||||
jobs,
|
||||
audit,
|
||||
};
|
||||
fleet = false;
|
||||
jobs = false;
|
||||
audit = false;
|
||||
instanceIds.clear();
|
||||
emit(batch);
|
||||
};
|
||||
const arm = (): void => {
|
||||
if (handle === undefined) handle = schedule(flush, Math.max(0, options.delayMs ?? 50));
|
||||
};
|
||||
|
||||
return {
|
||||
push(event) {
|
||||
if (disposed) return;
|
||||
if (event.kind === 'instance') {
|
||||
fleet = true;
|
||||
if (event.instanceId === activeInstanceId) instanceIds.add(event.instanceId);
|
||||
} else if (event.kind === 'job' || event.kind === 'item' || event.kind === 'attempt')
|
||||
jobs = true;
|
||||
else if (event.kind === 'audit') audit = true;
|
||||
arm();
|
||||
},
|
||||
setActiveInstance(instanceId) {
|
||||
activeInstanceId = instanceId;
|
||||
for (const id of instanceIds) if (id !== instanceId) instanceIds.delete(id);
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
if (handle !== undefined) cancel(handle);
|
||||
handle = undefined;
|
||||
instanceIds.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createEventStreamClient,
|
||||
createSseParser,
|
||||
parseEventEnvelope,
|
||||
type EventStreamState,
|
||||
} from './event-stream-client.js';
|
||||
|
||||
const instanceEvent = {
|
||||
kind: 'instance',
|
||||
id: 'event-1',
|
||||
occurredAt: '2026-07-17T10:00:00.000Z',
|
||||
requestId: 'request-1',
|
||||
instanceId: 'instance-1',
|
||||
} as const;
|
||||
|
||||
function streamResponse(chunks: readonly string[], status = 200): Response {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status, headers: { 'content-type': 'text/event-stream; charset=utf-8' } },
|
||||
);
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
return { promise: new Promise<void>((done) => (resolve = done)), resolve };
|
||||
}
|
||||
|
||||
describe('strict EventEnvelope parser', () => {
|
||||
it('accepts every frozen discriminated-union shape', () => {
|
||||
expect(parseEventEnvelope(instanceEvent)).toEqual(instanceEvent);
|
||||
expect(
|
||||
parseEventEnvelope({ ...instanceEvent, occurredAt: '2026-07-17T10:00:00Z' }),
|
||||
).toMatchObject({
|
||||
occurredAt: '2026-07-17T10:00:00Z',
|
||||
});
|
||||
expect(
|
||||
parseEventEnvelope({ ...instanceEvent, occurredAt: '2026-07-17T18:00:00+08:00' }),
|
||||
).toMatchObject({
|
||||
occurredAt: '2026-07-17T18:00:00+08:00',
|
||||
});
|
||||
const base = {
|
||||
kind: instanceEvent.kind,
|
||||
id: instanceEvent.id,
|
||||
occurredAt: instanceEvent.occurredAt,
|
||||
requestId: instanceEvent.requestId,
|
||||
};
|
||||
expect(
|
||||
parseEventEnvelope({ ...base, kind: 'item', jobId: 'job-1', itemId: 'item-1' }),
|
||||
).toMatchObject({ kind: 'item', jobId: 'job-1', itemId: 'item-1' });
|
||||
expect(
|
||||
parseEventEnvelope({ ...base, kind: 'attempt', jobId: 'job-1', attemptId: 'try-1' }),
|
||||
).toMatchObject({ kind: 'attempt', attemptId: 'try-1' });
|
||||
expect(parseEventEnvelope({ ...base, kind: 'job', jobId: 'job-1' })).toMatchObject({
|
||||
kind: 'job',
|
||||
});
|
||||
expect(parseEventEnvelope({ ...base, kind: 'audit', auditEventId: 'audit-1' })).toMatchObject({
|
||||
kind: 'audit',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{ ...instanceEvent, kind: 'unknown' },
|
||||
{ ...instanceEvent, id: '' },
|
||||
{ ...instanceEvent, occurredAt: 'yesterday' },
|
||||
{ ...instanceEvent, instanceId: 1 },
|
||||
{ ...instanceEvent, secret: 'must-not-pass' },
|
||||
{ ...instanceEvent, kind: 'job' },
|
||||
{ ...instanceEvent, kind: 'instance', jobId: 'wrong-union-member' },
|
||||
])('rejects invalid or non-exact envelopes: %j', (value) => {
|
||||
expect(() => parseEventEnvelope(value)).toThrow('Invalid event envelope.');
|
||||
});
|
||||
|
||||
it.each(['2026-02-30T10:00:00Z', '2026-04-31T10:00:00Z', '2026-01-01T24:00:00Z'])(
|
||||
'rejects normalized invalid RFC3339 timestamp %s',
|
||||
(occurredAt) => {
|
||||
expect(() => parseEventEnvelope({ ...instanceEvent, occurredAt })).toThrow(
|
||||
'Invalid event envelope.',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('SSE framing', () => {
|
||||
it('handles arbitrary chunk boundaries, CRLF, comments, and multiline data', () => {
|
||||
const records: Array<{ id?: string; data: string }> = [];
|
||||
const parser = createSseParser((record) => records.push(record));
|
||||
parser.push(': keepalive\r');
|
||||
parser.push('\nid: cursor-1\r\nda');
|
||||
parser.push('ta: {"one":\r\ndata: 2}\r\n\r');
|
||||
parser.push('\n');
|
||||
parser.finish();
|
||||
expect(records).toEqual([{ id: 'cursor-1', data: '{"one":\n2}' }]);
|
||||
});
|
||||
|
||||
it('dispatches a final unterminated event and ignores id-only events and NUL ids', () => {
|
||||
const records: Array<{ id?: string; data: string }> = [];
|
||||
const parser = createSseParser((record) => records.push(record));
|
||||
parser.push('id: ignored\n\nid: bad\0id\ndata: final');
|
||||
parser.finish();
|
||||
expect(records).toEqual([{ data: 'final' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch event stream client', () => {
|
||||
it('uses protected same-origin fetch, retains cursor, and reconnects with Last-Event-ID', async () => {
|
||||
const gate = deferred();
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
streamResponse([
|
||||
`id: cursor-7\ndata: ${JSON.stringify(instanceEvent).slice(0, 20)}`,
|
||||
`${JSON.stringify(instanceEvent).slice(20)}\n\n`,
|
||||
]),
|
||||
)
|
||||
.mockImplementationOnce(async (_input, init) => {
|
||||
await gate.promise;
|
||||
init?.signal?.throwIfAborted();
|
||||
return streamResponse([]);
|
||||
});
|
||||
const sleep = vi.fn(async () => undefined);
|
||||
const events: unknown[] = [];
|
||||
const states: EventStreamState[] = [];
|
||||
const unsubscribe = createEventStreamClient({ fetch: fetcher, sleep }).subscribe(
|
||||
(event) => events.push(event),
|
||||
(state) => states.push(state),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
||||
expect(events).toEqual([instanceEvent]);
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/events');
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
expect(fetcher.mock.calls[1]?.[1]?.headers).toEqual({
|
||||
Accept: 'text/event-stream',
|
||||
'Last-Event-ID': 'cursor-7',
|
||||
});
|
||||
expect(states).toContain('open');
|
||||
expect(states).toContain('reconnecting');
|
||||
unsubscribe();
|
||||
gate.resolve();
|
||||
});
|
||||
|
||||
it('applies bounded exponential reconnect delay and never exposes response bodies', async () => {
|
||||
const gate = deferred();
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response('password=top-secret', { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response('another-secret', { status: 503 }))
|
||||
.mockImplementationOnce(async (_input, init) => {
|
||||
await gate.promise;
|
||||
init?.signal?.throwIfAborted();
|
||||
return streamResponse([]);
|
||||
});
|
||||
const delays: number[] = [];
|
||||
const errors: string[] = [];
|
||||
const client = createEventStreamClient({
|
||||
fetch: fetcher,
|
||||
initialRetryMs: 10,
|
||||
maxRetryMs: 20,
|
||||
sleep: async (ms) => void delays.push(ms),
|
||||
});
|
||||
const unsubscribe = client.subscribe(
|
||||
() => undefined,
|
||||
(_state, error) => error && errors.push(error.message),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(3));
|
||||
expect(delays.slice(0, 2)).toEqual([10, 20]);
|
||||
expect(errors.join(' ')).not.toMatch(/secret|password/i);
|
||||
expect(errors[0]).toBe('The event stream connection failed.');
|
||||
unsubscribe();
|
||||
gate.resolve();
|
||||
});
|
||||
|
||||
it('clears an expired cursor and reconnects without Last-Event-ID', async () => {
|
||||
const gate = deferred();
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
streamResponse([`id: cursor-old\ndata: ${JSON.stringify(instanceEvent)}\n\n`]),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 409 }))
|
||||
.mockImplementationOnce(async (_input, init) => {
|
||||
await gate.promise;
|
||||
init?.signal?.throwIfAborted();
|
||||
return streamResponse([]);
|
||||
});
|
||||
const states: EventStreamState[] = [];
|
||||
const unsubscribe = createEventStreamClient({
|
||||
fetch: fetcher,
|
||||
sleep: async () => undefined,
|
||||
}).subscribe(
|
||||
() => undefined,
|
||||
(state) => states.push(state),
|
||||
);
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(3));
|
||||
expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ 'Last-Event-ID': 'cursor-old' });
|
||||
expect(fetcher.mock.calls[2]?.[1]?.headers).toEqual({ Accept: 'text/event-stream' });
|
||||
expect(states).toContain('resetting');
|
||||
unsubscribe();
|
||||
gate.resolve();
|
||||
});
|
||||
|
||||
it('rejects cross-origin endpoints before fetching and aborts on unsubscribe', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
const errors: Error[] = [];
|
||||
const unsubscribe = createEventStreamClient({
|
||||
url: 'https://attacker.example/events',
|
||||
fetch: fetcher,
|
||||
}).subscribe(
|
||||
() => undefined,
|
||||
(_state, error) => error && errors.push(error),
|
||||
);
|
||||
await vi.waitFor(() => expect(errors).toHaveLength(1));
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { EventEnvelope, EventKind } from '@multi-simadmin/contracts';
|
||||
|
||||
export type EventStreamState = 'connecting' | 'open' | 'reconnecting' | 'resetting' | 'closed';
|
||||
export type EventStreamStateListener = (state: EventStreamState, error?: Error) => void;
|
||||
export type EventStreamListener = (event: EventEnvelope) => void;
|
||||
|
||||
export interface SseRecord {
|
||||
readonly id?: string;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
export interface SseParser {
|
||||
push(chunk: string): void;
|
||||
finish(): void;
|
||||
}
|
||||
|
||||
export interface EventStreamClient {
|
||||
subscribe(onEvent: EventStreamListener, onState?: EventStreamStateListener): () => void;
|
||||
}
|
||||
|
||||
export interface EventStreamClientOptions {
|
||||
readonly url?: string;
|
||||
readonly fetch?: typeof globalThis.fetch;
|
||||
readonly initialRetryMs?: number;
|
||||
readonly maxRetryMs?: number;
|
||||
readonly sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
||||
readonly onPositionReset?: () => void;
|
||||
}
|
||||
|
||||
const KINDS: readonly EventKind[] = ['instance', 'job', 'item', 'attempt', 'audit'];
|
||||
const COMMON_KEYS = ['kind', 'id', 'occurredAt', 'requestId'] as const;
|
||||
const KIND_KEYS: Readonly<Record<EventKind, readonly string[]>> = {
|
||||
instance: ['instanceId'],
|
||||
job: ['jobId'],
|
||||
item: ['jobId', 'itemId'],
|
||||
attempt: ['jobId', 'attemptId'],
|
||||
audit: ['auditEventId'],
|
||||
};
|
||||
const CONNECTION_ERROR = 'The event stream connection failed.';
|
||||
const INVALID_EVENT_ERROR = 'Invalid event envelope.';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function isIsoTimestamp(value: unknown): value is string {
|
||||
if (!isNonEmptyString(value)) return false;
|
||||
const match =
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/u.exec(value);
|
||||
if (!match) return false;
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
const parts = [year, month, day, hour, minute, second].map(Number);
|
||||
if (parts.some((part) => !Number.isInteger(part))) return false;
|
||||
const [y, m, d, h, min, s] = parts as [number, number, number, number, number, number];
|
||||
if (m < 1 || m > 12 || d < 1 || h > 23 || min > 59 || s > 59) return false;
|
||||
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
if (d > daysInMonth) return false;
|
||||
return Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
/** Validates the exact, frozen EventEnvelope union without retaining unknown fields. */
|
||||
export function parseEventEnvelope(value: unknown): EventEnvelope {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.kind !== 'string' ||
|
||||
!KINDS.includes(value.kind as EventKind)
|
||||
)
|
||||
throw new Error(INVALID_EVENT_ERROR);
|
||||
|
||||
const kind = value.kind as EventKind;
|
||||
const kindKeys = KIND_KEYS[kind];
|
||||
if (kindKeys === undefined) throw new Error(INVALID_EVENT_ERROR);
|
||||
const expected = [...COMMON_KEYS, ...kindKeys];
|
||||
const actual = Object.keys(value);
|
||||
if (actual.length !== expected.length || actual.some((key) => !expected.includes(key)))
|
||||
throw new Error(INVALID_EVENT_ERROR);
|
||||
if (
|
||||
!isNonEmptyString(value.id) ||
|
||||
!isIsoTimestamp(value.occurredAt) ||
|
||||
!isNonEmptyString(value.requestId) ||
|
||||
kindKeys.some((key) => !isNonEmptyString(value[key]))
|
||||
)
|
||||
throw new Error(INVALID_EVENT_ERROR);
|
||||
|
||||
return value as unknown as EventEnvelope;
|
||||
}
|
||||
|
||||
/** Incremental SSE field parser implementing LF, CRLF, CR, comments, and multiline data. */
|
||||
export function createSseParser(onRecord: (record: SseRecord) => void): SseParser {
|
||||
let buffer = '';
|
||||
let data: string[] = [];
|
||||
let id: string | undefined;
|
||||
|
||||
const dispatch = (): void => {
|
||||
if (data.length > 0)
|
||||
onRecord(id === undefined ? { data: data.join('\n') } : { id, data: data.join('\n') });
|
||||
data = [];
|
||||
id = undefined;
|
||||
};
|
||||
const line = (value: string): void => {
|
||||
if (value === '') {
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(':')) return;
|
||||
const colon = value.indexOf(':');
|
||||
const field = colon < 0 ? value : value.slice(0, colon);
|
||||
let fieldValue = colon < 0 ? '' : value.slice(colon + 1);
|
||||
if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1);
|
||||
if (field === 'data') data.push(fieldValue);
|
||||
else if (field === 'id' && !fieldValue.includes('\0')) id = fieldValue;
|
||||
};
|
||||
const consume = (final: boolean): void => {
|
||||
let start = 0;
|
||||
for (let index = 0; index < buffer.length; index += 1) {
|
||||
const character = buffer[index];
|
||||
if (character !== '\r' && character !== '\n') continue;
|
||||
if (character === '\r' && index + 1 === buffer.length && !final) break;
|
||||
line(buffer.slice(start, index));
|
||||
if (character === '\r' && buffer[index + 1] === '\n') index += 1;
|
||||
start = index + 1;
|
||||
}
|
||||
buffer = buffer.slice(start);
|
||||
if (final && buffer.length > 0) {
|
||||
line(buffer);
|
||||
buffer = '';
|
||||
}
|
||||
};
|
||||
return {
|
||||
push(chunk) {
|
||||
buffer += chunk;
|
||||
consume(false);
|
||||
},
|
||||
finish() {
|
||||
consume(true);
|
||||
dispatch();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sameOriginUrl(input: string): string {
|
||||
const base = globalThis.location?.href ?? 'http://localhost/';
|
||||
const url = new URL(input, base);
|
||||
const origin = new URL(base).origin;
|
||||
if (url.origin !== origin || url.username !== '' || url.password !== '')
|
||||
throw new Error('The event stream URL must be same-origin.');
|
||||
return input.startsWith('/') ? `${url.pathname}${url.search}` : url.href;
|
||||
}
|
||||
|
||||
function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, milliseconds);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function safeConnectionError(): Error {
|
||||
return new Error(CONNECTION_ERROR);
|
||||
}
|
||||
|
||||
export function createEventStreamClient(options: EventStreamClientOptions = {}): EventStreamClient {
|
||||
const fetcher = options.fetch ?? globalThis.fetch;
|
||||
const initialRetryMs = Math.max(1, options.initialRetryMs ?? 1_000);
|
||||
const maxRetryMs = Math.max(initialRetryMs, options.maxRetryMs ?? 30_000);
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
|
||||
return {
|
||||
subscribe(onEvent, onState = () => undefined) {
|
||||
const controller = new AbortController();
|
||||
let cursor: string | undefined;
|
||||
let retryMs = initialRetryMs;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
let url: string;
|
||||
try {
|
||||
url = sameOriginUrl(options.url ?? '/api/v1/events');
|
||||
} catch (error) {
|
||||
onState('closed', error instanceof Error ? error : safeConnectionError());
|
||||
return;
|
||||
}
|
||||
|
||||
onState('connecting');
|
||||
while (!controller.signal.aborted) {
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||
if (cursor !== undefined) headers['Last-Event-ID'] = cursor;
|
||||
const response = await fetcher(url, {
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status === 409 && cursor !== undefined) {
|
||||
cursor = undefined;
|
||||
options.onPositionReset?.();
|
||||
onState(
|
||||
'resetting',
|
||||
new Error('The event stream position expired; refreshing current state.'),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok || response.body === null) throw safeConnectionError();
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
if (!contentType.startsWith('text/event-stream')) throw safeConnectionError();
|
||||
|
||||
onState('open');
|
||||
retryMs = initialRetryMs;
|
||||
const decoder = new TextDecoder();
|
||||
let recordFailure: Error | undefined;
|
||||
const parser = createSseParser((record) => {
|
||||
if (recordFailure !== undefined) return;
|
||||
try {
|
||||
const event = parseEventEnvelope(JSON.parse(record.data) as unknown);
|
||||
onEvent(event);
|
||||
if (record.id !== undefined) cursor = record.id;
|
||||
} catch {
|
||||
recordFailure = new Error(INVALID_EVENT_ERROR);
|
||||
}
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
while (!controller.signal.aborted) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
parser.push(decoder.decode(result.value, { stream: true }));
|
||||
if (recordFailure !== undefined) throw recordFailure;
|
||||
}
|
||||
parser.push(decoder.decode());
|
||||
parser.finish();
|
||||
if (recordFailure !== undefined) throw recordFailure;
|
||||
} finally {
|
||||
if (controller.signal.aborted) await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) break;
|
||||
}
|
||||
if (controller.signal.aborted) break;
|
||||
// Deliberately replace fetch/parser errors: response bodies and transport details may be sensitive.
|
||||
onState('reconnecting', safeConnectionError());
|
||||
const delay = retryMs;
|
||||
retryMs = Math.min(maxRetryMs, retryMs * 2);
|
||||
try {
|
||||
await sleep(delay, controller.signal);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
onState('closed');
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => controller.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { EventEnvelope } from '@multi-simadmin/contracts';
|
||||
|
||||
import { createEventInvalidationController } from './event-invalidation-controller.js';
|
||||
import {
|
||||
createEventStreamClient,
|
||||
type EventStreamClient,
|
||||
type EventStreamState,
|
||||
} from './event-stream-client.js';
|
||||
|
||||
export interface EventRefreshState {
|
||||
readonly fleet: number;
|
||||
readonly jobs: number;
|
||||
readonly audit: number;
|
||||
readonly detail: number;
|
||||
readonly stream: EventStreamState;
|
||||
readonly positionReset: number;
|
||||
}
|
||||
|
||||
export function useControlPlaneEvents(
|
||||
activeInstanceId?: string,
|
||||
client: EventStreamClient = createEventStreamClient(),
|
||||
): EventRefreshState {
|
||||
const [state, setState] = useState<EventRefreshState>({
|
||||
fleet: 0,
|
||||
jobs: 0,
|
||||
audit: 0,
|
||||
detail: 0,
|
||||
stream: 'connecting',
|
||||
positionReset: 0,
|
||||
});
|
||||
|
||||
const activeInstanceRef = useRef(activeInstanceId);
|
||||
activeInstanceRef.current = activeInstanceId;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = createEventInvalidationController((batch) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
fleet: current.fleet + (batch.fleet ? 1 : 0),
|
||||
jobs: current.jobs + (batch.jobs ? 1 : 0),
|
||||
audit: current.audit + (batch.audit ? 1 : 0),
|
||||
detail: current.detail + (batch.instanceIds.size > 0 ? 1 : 0),
|
||||
}));
|
||||
});
|
||||
controller.setActiveInstance(activeInstanceRef.current);
|
||||
const unsubscribe = client.subscribe(
|
||||
(event: EventEnvelope) => {
|
||||
controller.setActiveInstance(activeInstanceRef.current);
|
||||
controller.push(event);
|
||||
},
|
||||
(stream) =>
|
||||
setState((current) => ({
|
||||
...current,
|
||||
stream,
|
||||
fleet: current.fleet + (stream === 'resetting' ? 1 : 0),
|
||||
jobs: current.jobs + (stream === 'resetting' ? 1 : 0),
|
||||
audit: current.audit + (stream === 'resetting' ? 1 : 0),
|
||||
detail: current.detail + (stream === 'resetting' && activeInstanceRef.current ? 1 : 0),
|
||||
positionReset: current.positionReset + (stream === 'resetting' ? 1 : 0),
|
||||
})),
|
||||
);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
controller.dispose();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export interface FleetDataSource {
|
||||
export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
readonly initialData?: FleetSnapshot;
|
||||
readonly refreshSignal?: number;
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
||||
@@ -54,7 +55,7 @@ export function canonicalHttpOrigin(value: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
||||
export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetPageProps) {
|
||||
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -76,7 +77,7 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
if (initialData && refreshSignal === 0) {
|
||||
setSnapshot(initialData);
|
||||
setError(null);
|
||||
return;
|
||||
@@ -100,7 +101,7 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [attempt, dataSource, initialData]);
|
||||
}, [attempt, dataSource, initialData, refreshSignal]);
|
||||
|
||||
const model = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -34,4 +34,23 @@ export {
|
||||
type PasswordUpdate,
|
||||
} from './instances/instance-api-data-source.js';
|
||||
|
||||
export {
|
||||
createEventStreamClient,
|
||||
createSseParser,
|
||||
parseEventEnvelope,
|
||||
type EventStreamClient,
|
||||
type EventStreamClientOptions,
|
||||
type EventStreamListener,
|
||||
type EventStreamState,
|
||||
type EventStreamStateListener,
|
||||
type SseParser,
|
||||
type SseRecord,
|
||||
} from './events/event-stream-client.js';
|
||||
export {
|
||||
createEventInvalidationController,
|
||||
type EventInvalidationController,
|
||||
type EventInvalidationOptions,
|
||||
type InvalidationBatch,
|
||||
} from './events/event-invalidation-controller.js';
|
||||
|
||||
export const webWorkspaceReady = true;
|
||||
|
||||
Reference in New Issue
Block a user