156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
// Live progress. `EventSource` and nothing else: the transport is ratified (STACK_DECISIONS §5),
|
|
// and the gate forbids it anywhere but here.
|
|
//
|
|
// Two things the browser already does for us, and doing them again would be a second carrier of
|
|
// the same rule: it re-sends `Last-Event-ID` on reconnect, and it reconnects on its own. What is
|
|
// ours is the version handshake, the revision guard and the resync answer.
|
|
|
|
import { acceptFrame, forget } from './revision';
|
|
import { narrow } from './contract';
|
|
import { bookStatus, pausedReason } from './vocabulary';
|
|
import type { BookStatus, Note, PausedReason } from './contract';
|
|
import type { components } from './schema';
|
|
|
|
type Schemas = components['schemas'];
|
|
|
|
/** Major version this build speaks. A major bump means refuse and tell the user, per the contract. */
|
|
export const supportedMajor = 0;
|
|
|
|
export type ConnectionState = 'connecting' | 'open' | 'lost' | 'refused';
|
|
|
|
// The vocabularies of a frame are narrowed exactly like those of a read. The stream is the SECOND
|
|
// door into the application, and a door that skipped the narrowing would put a raw wire value into
|
|
// the same cache the reads fill — the crash of BACKLOG Ф-22, arriving by the other route.
|
|
export type Frame =
|
|
| { event: 'hello'; data: Schemas['EventHello'] }
|
|
| { event: 'status'; data: { status: BookStatus | null; paused_reason: PausedReason | null } }
|
|
| { event: 'progress'; data: Schemas['EventProgress'] }
|
|
| { event: 'chapter'; data: Schemas['EventChapter'] }
|
|
| { event: 'note'; data: { note: Note } }
|
|
| { event: 'bank'; data: Schemas['EventBank'] }
|
|
| { event: 'ceiling'; data: Schemas['EventCeiling'] }
|
|
| { event: 'resync_required'; data: Schemas['EventResyncRequired'] };
|
|
|
|
/** Frame names of version 0.x. A name outside this list is ignored, not an error: minor bumps add. */
|
|
const frameNames = [
|
|
'hello',
|
|
'status',
|
|
'progress',
|
|
'chapter',
|
|
'note',
|
|
'bank',
|
|
'ceiling',
|
|
'resync_required',
|
|
] as const;
|
|
|
|
/**
|
|
* Turns one wire frame into a typed one. Separate from the transport on purpose: `EventSource`
|
|
* does not exist under the test DOM (measured — no global at all), so the dispatch has to be
|
|
* reachable without a browser.
|
|
*/
|
|
export function readFrame(name: string, data: string): Frame | null {
|
|
if (!(frameNames as readonly string[]).includes(name)) return null;
|
|
|
|
let payload: unknown;
|
|
try {
|
|
payload = JSON.parse(data);
|
|
} catch {
|
|
// A frame we cannot parse is dropped rather than thrown: one bad frame must not take the
|
|
// stream down and with it the live progress of an hours-long run.
|
|
return null;
|
|
}
|
|
|
|
if (name === 'status') {
|
|
const wire = payload as Schemas['EventStatus'];
|
|
return {
|
|
event: 'status',
|
|
data: {
|
|
status: bookStatus.read(wire.status),
|
|
paused_reason: wire.paused_reason === null ? null : pausedReason.read(wire.paused_reason),
|
|
},
|
|
};
|
|
}
|
|
if (name === 'note') {
|
|
const wire = payload as Schemas['EventNote'];
|
|
return { event: 'note', data: { note: narrow.note(wire.note) } };
|
|
}
|
|
return { event: name, data: payload } as Frame;
|
|
}
|
|
|
|
export function majorOf(version: string): number {
|
|
return Number.parseInt(version.split('.')[0] ?? '', 10);
|
|
}
|
|
|
|
export interface Subscription {
|
|
runId: string;
|
|
/** Revision scope. The frame `id` is the BOOK's revision, so the guard is keyed by book. */
|
|
bookId: string;
|
|
/** The frame, and the book revision it carries — the number a late read is compared against. */
|
|
onFrame: (frame: Frame, revision: number) => void;
|
|
onState: (state: ConnectionState) => void;
|
|
/** The stream cannot be resumed: re-read the snapshots. Replaying history is forbidden. */
|
|
onResync: (reason: string) => void;
|
|
}
|
|
|
|
export function subscribeToRun(subscription: Subscription): () => void {
|
|
const scope = `stream:${subscription.bookId}`;
|
|
const source = new EventSource(`/v0/runs/${encodeURIComponent(subscription.runId)}/events`);
|
|
let closed = false;
|
|
|
|
const close = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
source.close();
|
|
forget(scope);
|
|
};
|
|
|
|
subscription.onState('connecting');
|
|
|
|
const handle = (message: MessageEvent<string>, name: string) => {
|
|
// A closed subscription must stay closed even if a frame is already in flight: the effect that
|
|
// owns it has been torn down, and delivering into it would write to a screen that is gone.
|
|
if (closed) return;
|
|
|
|
const frame = readFrame(name, message.data);
|
|
if (!frame) return;
|
|
|
|
// The handshake is the stream's own business and does not reach the subscriber.
|
|
if (frame.event === 'hello') {
|
|
if (majorOf(frame.data.contract) !== supportedMajor) {
|
|
subscription.onState('refused');
|
|
close();
|
|
return;
|
|
}
|
|
subscription.onState('open');
|
|
return;
|
|
}
|
|
|
|
// The frame id is the book revision. A frame older than what has been applied is dropped here,
|
|
// once, rather than in every handler downstream. A resync is exempt: it is the answer to "I
|
|
// cannot resume you", so filtering it by the mark it exists to reset would strand the client.
|
|
const revision = Number(message.lastEventId);
|
|
if (frame.event === 'resync_required') {
|
|
subscription.onResync(frame.data.reason);
|
|
forget(scope);
|
|
return;
|
|
}
|
|
if (message.lastEventId !== '' && !acceptFrame(scope, revision)) return;
|
|
|
|
subscription.onFrame(frame, Number.isFinite(revision) ? revision : 0);
|
|
};
|
|
|
|
for (const name of frameNames) {
|
|
source.addEventListener(name, (message: MessageEvent<string>) => {
|
|
handle(message, name);
|
|
});
|
|
}
|
|
|
|
source.onerror = () => {
|
|
// CONNECTING means the browser is retrying by itself and will re-send `Last-Event-ID`; CLOSED
|
|
// means it gave up. The screen says "live progress is unavailable" either way, and the reads
|
|
// keep working — that separation is the whole point of pushing progress rather than polling.
|
|
subscription.onState(source.readyState === source.CLOSED ? 'refused' : 'lost');
|
|
};
|
|
|
|
return close;
|
|
}
|