feat(web): add durable control-plane event updates
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json"
|
"typecheck": "tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@multi-simadmin/contracts": "workspace:*",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js';
|
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js';
|
||||||
@@ -57,6 +61,7 @@ export interface AppShellProps {
|
|||||||
instanceDataSource?: InstanceDataSource;
|
instanceDataSource?: InstanceDataSource;
|
||||||
capabilities?: InstanceCapabilityMap;
|
capabilities?: InstanceCapabilityMap;
|
||||||
capabilityDataSource?: CapabilityDataSource;
|
capabilityDataSource?: CapabilityDataSource;
|
||||||
|
eventStreamClient?: EventStreamClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODULE_LABELS = INSTANCE_MODULE_LABELS;
|
const MODULE_LABELS = INSTANCE_MODULE_LABELS;
|
||||||
@@ -123,6 +128,7 @@ function Page({
|
|||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
capabilities,
|
capabilities,
|
||||||
capabilityDataSource,
|
capabilityDataSource,
|
||||||
|
fleetRefreshSignal,
|
||||||
}: {
|
}: {
|
||||||
route: ResolvedRoute;
|
route: ResolvedRoute;
|
||||||
fleetDataSource: FleetDataSource | undefined;
|
fleetDataSource: FleetDataSource | undefined;
|
||||||
@@ -131,12 +137,14 @@ function Page({
|
|||||||
instanceDataSource: InstanceDataSource | undefined;
|
instanceDataSource: InstanceDataSource | undefined;
|
||||||
capabilities: InstanceCapabilityMap | undefined;
|
capabilities: InstanceCapabilityMap | undefined;
|
||||||
capabilityDataSource: CapabilityDataSource | undefined;
|
capabilityDataSource: CapabilityDataSource | undefined;
|
||||||
|
fleetRefreshSignal: number;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
if (route.kind === 'fleet')
|
if (route.kind === 'fleet')
|
||||||
return (
|
return (
|
||||||
<FleetPage
|
<FleetPage
|
||||||
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
||||||
{...(fleetData ? { initialData: fleetData } : {})}
|
{...(fleetData ? { initialData: fleetData } : {})}
|
||||||
|
refreshSignal={fleetRefreshSignal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (route.kind === 'instance-new')
|
if (route.kind === 'instance-new')
|
||||||
@@ -201,9 +209,17 @@ export function AppShell({
|
|||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
capabilities,
|
capabilities,
|
||||||
capabilityDataSource,
|
capabilityDataSource,
|
||||||
|
eventStreamClient,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
|
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||||
|
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
||||||
const resolved = resolveRoute(pathname);
|
const resolved = resolveRoute(pathname);
|
||||||
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
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);
|
const currentSection = section(route);
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" data-route={route.kind}>
|
<div className="app-shell" data-route={route.kind}>
|
||||||
@@ -239,12 +255,13 @@ export function AppShell({
|
|||||||
<main id="main-content" tabIndex={-1}>
|
<main id="main-content" tabIndex={-1}>
|
||||||
<Page
|
<Page
|
||||||
route={route}
|
route={route}
|
||||||
fleetDataSource={fleetDataSource ?? createFleetApiDataSource()}
|
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
|
||||||
fleetData={fleetData}
|
fleetData={fleetData}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
instanceDataSource={instanceDataSource}
|
instanceDataSource={instanceDataSource}
|
||||||
capabilities={capabilities}
|
capabilities={capabilities}
|
||||||
capabilityDataSource={capabilityDataSource}
|
capabilityDataSource={capabilityDataSource}
|
||||||
|
fleetRefreshSignal={refresh.fleet}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 {
|
export interface FleetPageProps {
|
||||||
readonly dataSource?: FleetDataSource;
|
readonly dataSource?: FleetDataSource;
|
||||||
readonly initialData?: FleetSnapshot;
|
readonly initialData?: FleetSnapshot;
|
||||||
|
readonly refreshSignal?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
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 [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
@@ -76,7 +77,7 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData && refreshSignal === 0) {
|
||||||
setSnapshot(initialData);
|
setSnapshot(initialData);
|
||||||
setError(null);
|
setError(null);
|
||||||
return;
|
return;
|
||||||
@@ -100,7 +101,7 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [attempt, dataSource, initialData]);
|
}, [attempt, dataSource, initialData, refreshSignal]);
|
||||||
|
|
||||||
const model = useMemo(
|
const model = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -34,4 +34,23 @@ export {
|
|||||||
type PasswordUpdate,
|
type PasswordUpdate,
|
||||||
} from './instances/instance-api-data-source.js';
|
} 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;
|
export const webWorkspaceReady = true;
|
||||||
|
|||||||
Generated
+3
@@ -61,6 +61,9 @@ importers:
|
|||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@multi-simadmin/contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/contracts
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
|
|||||||
Reference in New Issue
Block a user