textmachine/frontend/src/mock/events.ts

107 lines
4.4 KiB
TypeScript

// The live stream of the fixture. A finite script, then silence with the connection held open —
// the shape of a real run, where frames arrive in bursts and the stream outlives them.
//
// The frame `id` is the book's revision and it only grows: that is what lets the client drop a read
// that overtook a frame. The script deliberately makes the counters JUMP rather than tick by one,
// because the contract allows the server to coalesce frames and a client that animated every step
// would be wrong on the first busy run.
import { HttpResponse, delay, http } from 'msw';
import type { Scenario } from '../api';
import type { components } from '../api/schema';
import { advance, live } from './live';
import type { World } from './worlds';
type Schemas = components['schemas'];
const encoder = new TextEncoder();
/**
* The version the fake platform announces in its handshake.
*
* ⚠ It has to be the SPEC's own, and nothing at run time can read the spec — so the copy is locked
* by a test instead (`src/api/contract.test.ts`). Bought by this pack: the number stood at 0.2.2 for
* a whole session after the spec moved on, and the client compares only the MAJOR, so nothing failed
* and nothing said a word.
*/
export const contractVersion = '0.2.3';
function frame(id: number, event: string, data: unknown): Uint8Array {
return encoder.encode(`id: ${String(id)}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
export function runEvents(scenario: Scenario, world: World) {
return http.get('*/v0/runs/:runId/events', ({ params }) => {
// Losing the live channel while the reads keep working is its own state, and the interface has
// to say so rather than freeze a stale number and look healthy.
if (scenario === 'offline') {
return HttpResponse.json(
{ type: 'about:blank', title: 'Поток недоступен', status: 503 },
{ status: 503, headers: { 'Content-Type': 'application/problem+json' } },
);
}
const editTotal = world.books[0]?.progress.edit.total ?? 0;
const draftTotal = world.books[0]?.progress.draft.total ?? 0;
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(
frame(live.revision, 'hello', {
contract: contractVersion,
run_id: String(params.runId),
revision: live.revision,
} satisfies Schemas['EventHello']),
);
for (let step = 0; step < 6; step++) {
await delay(400);
advance(step);
controller.enqueue(
frame(live.revision, 'progress', {
progress: {
draft: { done: live.draftDone, total: draftTotal },
edit: { done: 0, total: editTotal },
eta_seconds: live.etaSeconds,
},
} satisfies Schemas['EventProgress']),
);
// A chapter of its own on every other burst, where the world can move one: the tree draws
// progress per chapter, and a book whose own counter runs while every chapter stands at
// zero is two answers to one question.
const chapter = step % 2 === 1 ? world.advanceChapter?.() : null;
if (chapter) controller.enqueue(frame(live.revision, 'chapter', chapter));
}
// The end of the path: the run reaches its ceiling and halts. Two frames, in the order the
// contract puts them in — the fact of the halt, then the state it left behind — and the
// world is told FIRST, because a read the frames provoke must not answer with a run that
// is still going (the fixture's own invariant).
if (world.halt) {
await delay(400);
world.halt();
controller.enqueue(
frame(live.revision, 'ceiling', { halted: true } satisfies Schemas['EventCeiling']),
);
controller.enqueue(
frame(live.revision, 'status', {
status: 'paused',
paused_reason: 'credit_exhausted',
} satisfies Schemas['EventStatus']),
);
}
// No close: a run outlives its bursts, and closing here would make the browser reconnect
// and replay the script, which is not what a live run looks like.
},
});
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
},
});
});
}