107 lines
4.4 KiB
TypeScript
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',
|
|
},
|
|
});
|
|
});
|
|
}
|