Add SSE updates

This commit is contained in:
Andras Schmelczer 2026-06-04 22:12:10 +01:00
commit 688bc0cfe9
13 changed files with 958 additions and 42 deletions

View file

@ -35,6 +35,12 @@ export interface TreeDto {
pages: Page[];
}
/** Response of GET /data: the tree plus the user's current sync revision. */
export interface DataResponse {
pages: Page[];
revision: number;
}
export type SaveStatus =
| 'idle'
| 'saving'

View file

@ -1,7 +1,17 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { TreeDto } from '../models';
import { DataResponse, TreeDto } from '../models';
import { createSseParser } from '../utils/sse';
/** Callbacks for a live event stream. */
export interface EventStreamHandlers {
/** A revision the server says is current; the store decides whether to refetch. */
onRevision: (revision: number) => void;
/** The stream ended on its own (server closed or network error) not an
* intentional close. The caller may reconnect. */
onClosed: () => void;
}
@Injectable({ providedIn: 'root' })
export class ApiService {
@ -17,16 +27,73 @@ export class ApiService {
);
}
getData(token: string): Promise<TreeDto> {
getData(token: string): Promise<DataResponse> {
return firstValueFrom(
this.http.get<TreeDto>('api/v1/data', { headers: this.authHeaders(token) }),
this.http.get<DataResponse>('api/v1/data', { headers: this.authHeaders(token) }),
);
}
async putData(token: string, tree: TreeDto): Promise<void> {
await firstValueFrom(
this.http.put('api/v1/data', tree, { headers: this.authHeaders(token) }),
/**
* Replace the user's tree. `baseRevision` is the client's compare-and-swap
* base, sent as If-Match; the server rejects with 409 if it has moved on.
* Resolves to the new revision the write produced.
*/
async putData(token: string, tree: TreeDto, baseRevision: number): Promise<number> {
const headers = this.authHeaders(token).set('If-Match', String(baseRevision));
const res = await firstValueFrom(
this.http.put<{ revision: number }>('api/v1/data', tree, { headers }),
);
return res.revision;
}
/**
* Open the SSE stream for a token via fetch()+ReadableStream so the Bearer
* token travels in a header (EventSource can't do that). Returns a function
* that closes the stream; closing it does NOT invoke `onClosed`.
*/
openEventStream(token: string, handlers: EventStreamHandlers): () => void {
const controller = new AbortController();
void this.consumeEventStream(token, handlers, controller.signal);
return () => controller.abort();
}
private async consumeEventStream(
token: string,
handlers: EventStreamHandlers,
signal: AbortSignal,
): Promise<void> {
try {
const response = await fetch('api/v1/events', {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
signal,
cache: 'no-store',
});
if (!response.ok || !response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
const feed = createSseParser((message) => {
if (message.event !== null && message.event !== 'revision') return;
try {
const parsed = JSON.parse(message.data) as { revision?: unknown };
if (typeof parsed.revision === 'number') handlers.onRevision(parsed.revision);
} catch {
/* ignore malformed frames */
}
});
for (;;) {
const { value, done } = await reader.read();
if (done) break;
feed(decoder.decode(value, { stream: true }));
}
} catch {
/* aborted or network error — handled below */
} finally {
// An intentional close (controller.abort()) should not trigger a
// reconnect; only an unexpected end does.
if (!signal.aborted) handlers.onClosed();
}
}
private authHeaders(token: string): HttpHeaders {

View file

@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ApiService } from './api.service';
import type { TreeDto } from '../models';
import type { DataResponse, TreeDto } from '../models';
describe('ApiService', () => {
let service: ApiService;
@ -21,24 +21,25 @@ describe('ApiService', () => {
http.verify();
});
it('gets data with a bearer token', async () => {
const tree: TreeDto = { pages: [] };
it('gets data with a bearer token and returns the revision', async () => {
const body: DataResponse = { pages: [], revision: 7 };
const promise = service.getData('token-1');
const req = http.expectOne('api/v1/data');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
req.flush(tree);
await expect(promise).resolves.toEqual(tree);
req.flush(body);
await expect(promise).resolves.toEqual(body);
});
it('puts data with a bearer token', async () => {
it('puts data with a bearer token + If-Match base revision and returns the new revision', async () => {
const tree: TreeDto = { pages: [] };
const promise = service.putData('token-1', tree);
const promise = service.putData('token-1', tree, 4);
const req = http.expectOne('api/v1/data');
expect(req.request.method).toBe('PUT');
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
expect(req.request.headers.get('If-Match')).toBe('4');
expect(req.request.body).toBe(tree);
req.flush(null);
await expect(promise).resolves.toBeUndefined();
req.flush({ revision: 5 });
await expect(promise).resolves.toBe(5);
});
});

View file

@ -1,13 +1,16 @@
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
import { ApiService } from './api.service';
import { AnalyticsService } from './analytics.service';
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
import { Page, Tower, Block, TreeDto, DataResponse, SaveStatus, HslColor } from '../models';
const TOKEN_KEY = 'life-towers.token.v4';
const CACHE_KEY_PREFIX = 'life-towers.cache.v4';
const PENDING_CACHE_KEY_PREFIX = 'life-towers.cache-pending.v4';
const DEBOUNCE_MS = 750;
const MAX_RETRIES = 5;
// SSE reconnect backoff after the stream drops (network blip, server restart).
const SSE_RECONNECT_BASE_MS = 1000;
const SSE_RECONNECT_MAX_MS = 30_000;
// RFC 4122 v4 UUID. Prefers crypto.randomUUID (secure contexts only) and
// falls back to crypto.getRandomValues — which works on plain http origins
@ -115,6 +118,17 @@ export class StoreService implements OnDestroy {
// clearing a newer pending cache entry when it completes.
private localMutationRevision = 0;
// ── Server revision (compare-and-swap base) ─────────────────────────────────
// The revision the server last confirmed for us; sent as the If-Match base on
// every PUT and compared against SSE notifications to decide whether to refetch.
private serverRevision = 0;
// ── Live sync (SSE) ─────────────────────────────────────────────────────────
private closeEventStream: (() => void) | null = null;
private eventStreamToken = '';
private sseReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private sseReconnectAttempts = 0;
// ── Cross-tab sync ─────────────────────────────────────────────────────────
private readonly storageListener = (e: StorageEvent) => {
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
@ -196,6 +210,12 @@ export class StoreService implements OnDestroy {
if (this.initGeneration === generation) {
this._loading.set(false);
}
// Subscribe to live updates for this token. Started even if the data load
// failed (we'll have fallen back to cache) — the stream self-heals when
// connectivity returns and its first event triggers a refetch.
if (this.isCurrentInit(generation, token)) {
this.startEventStream(token);
}
}
}
@ -207,8 +227,13 @@ export class StoreService implements OnDestroy {
* Apply a freshly-fetched server tree. If the server is empty but our local
* cache holds data, the cache wins and we schedule a push otherwise the
* "server forgot me" recovery would silently wipe offline edits.
*
* The server's revision becomes our CAS base regardless of which view we
* display: even when the cache wins, the next PUT is guarded against it.
*/
private adoptServerTree(tree: TreeDto, token: string): void {
private adoptServerTree(data: DataResponse, token: string): void {
this.setServerRevision(data, token);
if (safeGet(pendingCacheKeyForToken(token))) {
const cachedTree = this.readCachedTree(token);
if (cachedTree?.pages && cachedTree.pages.length > 0) {
@ -218,7 +243,7 @@ export class StoreService implements OnDestroy {
}
}
if (tree.pages.length === 0) {
if (data.pages.length === 0) {
const cachedTree = this.readCachedTree(token);
if (cachedTree?.pages && cachedTree.pages.length > 0) {
this._pages.set(cachedTree.pages);
@ -226,8 +251,14 @@ export class StoreService implements OnDestroy {
return;
}
}
this._pages.set(tree.pages);
this.updateCache(token, tree);
this._pages.set(data.pages);
this.updateCache(token, { pages: data.pages });
}
/** Record the server's revision as our compare-and-swap base. */
private setServerRevision(data: DataResponse, token: string): void {
if (this._token() !== token) return;
this.serverRevision = data.revision ?? 0;
}
private loadFromCache(token: string): void {
@ -481,6 +512,9 @@ export class StoreService implements OnDestroy {
const token = newToken.toLowerCase();
if (!isUuidV4(token)) return;
this.cancelPendingWrites();
// Tear down the old account's live stream before init() opens a new one.
this.stopEventStream();
this.sseReconnectAttempts = 0;
this.initGeneration++;
this.initPromise = null;
safeSet(TOKEN_KEY, token);
@ -489,6 +523,7 @@ export class StoreService implements OnDestroy {
this._loading.set(true);
this._saveStatus.set('idle');
this.localMutationRevision = 0;
this.serverRevision = 0;
void this.init();
}
@ -549,14 +584,17 @@ export class StoreService implements OnDestroy {
private async attempt(put: PendingPut, attempt: number): Promise<void> {
this._saveStatus.set(attempt === 0 ? 'saving' : 'retrying');
try {
await this.api.putData(put.token, put.tree);
const newRevision = await this.api.putData(put.token, put.tree, this.serverRevision);
this._saveStatus.set('saved');
if (
this._token() === put.token &&
put.revision === this.localMutationRevision &&
!this.dirtyDuringFlush
) {
this.updateCache(put.token, put.tree);
if (this._token() === put.token) {
// A successful write advances the revision by exactly one, so fall back
// to that if the response didn't carry a number.
this.serverRevision = Number.isFinite(newRevision)
? newRevision
: this.serverRevision + 1;
if (put.revision === this.localMutationRevision && !this.dirtyDuringFlush) {
this.updateCache(put.token, put.tree);
}
}
return;
} catch (err: unknown) {
@ -573,6 +611,24 @@ export class StoreService implements OnDestroy {
return;
}
// 409: another client wrote since our base revision. Resolve server-wins —
// refetch the current server tree and adopt it, discarding this device's
// un-pushed edit. The CAS still prevents a stale write from clobbering the
// other device's data; we just don't merge the two views.
if (status === 409) {
this._saveStatus.set('retrying');
try {
const remote = await this.api.getData(put.token);
if (this._token() !== put.token) return;
this.adoptServerData(remote, put.token);
this._saveStatus.set('saved');
return;
} catch {
// Couldn't refetch (network); fall through to backoff and retry the
// PUT, which will 409 again and re-attempt the refetch.
}
}
// 401 mid-PUT: server forgot us. Re-register (idempotent) and retry.
if (status === 401) {
try {
@ -619,6 +675,101 @@ export class StoreService implements OnDestroy {
}
}
// ── Live sync (SSE) ─────────────────────────────────────────────────────────
private startEventStream(token: string): void {
if (typeof window === 'undefined') return;
if (this.eventStreamToken === token && this.closeEventStream) return;
this.stopEventStream();
this.eventStreamToken = token;
this.closeEventStream = this.api.openEventStream(token, {
onRevision: (revision) => this.onRemoteRevision(token, revision),
onClosed: () => this.onEventStreamClosed(token),
});
}
private onRemoteRevision(token: string, revision: number): void {
if (this._token() !== token) return;
// A delivered event proves the stream works — reset the reconnect backoff.
this.sseReconnectAttempts = 0;
// Our own echo, or an out-of-order/stale frame: nothing new to pull.
if (revision <= this.serverRevision) return;
// If a save is pending/in-flight its compare-and-swap will reconcile via a
// 409; refetching now would race it. Only adopt when we're clean.
if (this.hasPendingWork()) return;
void this.pullFromRemote(token);
}
private onEventStreamClosed(token: string): void {
this.closeEventStream = null;
this.eventStreamToken = '';
if (this._token() !== token) return;
if (this.sseReconnectTimer !== null) clearTimeout(this.sseReconnectTimer);
const delay = Math.min(
SSE_RECONNECT_BASE_MS * 2 ** this.sseReconnectAttempts,
SSE_RECONNECT_MAX_MS,
);
this.sseReconnectAttempts += 1;
this.sseReconnectTimer = setTimeout(() => {
this.sseReconnectTimer = null;
if (this._token() === token) this.startEventStream(token);
}, delay);
}
private stopEventStream(): void {
if (this.sseReconnectTimer !== null) {
clearTimeout(this.sseReconnectTimer);
this.sseReconnectTimer = null;
}
if (this.closeEventStream) {
this.closeEventStream();
this.closeEventStream = null;
}
this.eventStreamToken = '';
}
/**
* Pull the server tree after a remote-change notification. Reached only when
* we're clean, so the server is authoritative and we adopt it wholesale
* unless the user starts editing during the fetch, in which case we back off
* and let the next save's compare-and-swap reconcile (so the edit survives).
*/
private async pullFromRemote(token: string): Promise<void> {
if (this.hasPendingWork()) return;
let remote: DataResponse;
try {
remote = await this.api.getData(token);
} catch {
return; // transient; a later event or reconnect retries
}
if (this._token() !== token) return;
if (this.hasPendingWork()) return; // edited mid-fetch → defer to CAS
this.adoptServerData(remote, token);
}
/**
* Adopt a server tree as the new truth. Used both when a clean client pulls a
* remote change (nothing local to lose) and on the 409 server-wins path (any
* un-pushed local edit on this device is intentionally discarded).
*/
private adoptServerData(data: DataResponse, token: string): void {
if (this._token() !== token) return;
this.setServerRevision(data, token);
this._pages.set(data.pages);
this.updateCache(token, { pages: data.pages });
}
/** True while any local change is unsaved, being saved, or awaiting retry. */
private hasPendingWork(): boolean {
return (
this.debounceTimer !== null ||
this.retryTimer !== null ||
this.flushInFlight ||
this.dirtyDuringFlush ||
!!safeGet(pendingCacheKeyForToken(this._token()))
);
}
// ── Example data ──────────────────────────────────────────────────────────
loadExample(): string {
@ -745,6 +896,7 @@ export class StoreService implements OnDestroy {
ngOnDestroy(): void {
this.cancelPendingWrites();
this.stopEventStream();
if (typeof window !== 'undefined') {
window.removeEventListener('storage', this.storageListener);
}

View file

@ -51,17 +51,35 @@ interface MockApi {
getData: ReturnType<typeof vi.fn>;
putData: ReturnType<typeof vi.fn>;
health: ReturnType<typeof vi.fn>;
openEventStream: ReturnType<typeof vi.fn>;
}
function makeMockApi(): MockApi {
return {
health: vi.fn().mockResolvedValue({ status: 'ok' }),
register: vi.fn().mockResolvedValue({ user_id: 'u' }),
getData: vi.fn().mockResolvedValue({ pages: [] } satisfies TreeDto),
putData: vi.fn().mockResolvedValue(undefined),
getData: vi.fn().mockResolvedValue({ pages: [], revision: 0 }),
putData: vi.fn().mockResolvedValue(1),
// Returns the stream's close handle; tests override to capture callbacks.
openEventStream: vi.fn().mockReturnValue(() => {}),
};
}
// Grab the handlers the store last passed to openEventStream so a test can
// simulate a server push.
function lastStreamHandlers(api: MockApi): {
onRevision: (revision: number) => void;
onClosed: () => void;
} {
const calls = api.openEventStream.mock.calls;
return calls[calls.length - 1][1];
}
// Flush awaited promise chains that contain no timers.
async function flush(): Promise<void> {
await vi.advanceTimersByTimeAsync(0);
}
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
const TOKEN_KEY = 'life-towers.token.v4';
const CACHE_KEY = `life-towers.cache.v4.${FIXED_UUID}`;
@ -673,4 +691,155 @@ describe('StoreService', () => {
expect(store.pages()).toHaveLength(1);
expect(store.pages()[0].name).toBe('from-other-tab');
});
// ── Multi-client sync: revision + compare-and-swap + SSE ────────────────────
it('sends the server revision as the PUT base and adopts the returned one', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({ pages: [], revision: 3 });
api.putData.mockResolvedValue(4);
const store = configure(api);
await store.init();
store.addPage('x');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenLastCalledWith(FIXED_UUID, expect.anything(), 3);
// The 4 returned by the first PUT becomes the base of the next one.
api.putData.mockResolvedValue(5);
store.addPage('y');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenLastCalledWith(FIXED_UUID, expect.anything(), 4);
});
it('opens an event stream for the token on init', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
await store.init();
expect(api.openEventStream).toHaveBeenCalledTimes(1);
expect(api.openEventStream.mock.calls[0][0]).toBe(FIXED_UUID);
});
it('refetches and adopts the server tree on a newer-revision SSE event when clean', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValueOnce({ pages: [], revision: 1 });
const store = configure(api);
await store.init();
api.getData.mockResolvedValueOnce({
pages: [mkPage('from-other-device')],
revision: 5,
});
lastStreamHandlers(api).onRevision(5);
await flush();
expect(api.getData).toHaveBeenCalledTimes(2);
expect(store.pages()).toHaveLength(1);
expect(store.pages()[0].name).toBe('from-other-device');
});
it('ignores an SSE event that is not newer than our revision (our own echo)', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({ pages: [], revision: 3 });
const store = configure(api);
await store.init();
api.getData.mockClear();
lastStreamHandlers(api).onRevision(3);
await flush();
expect(api.getData).not.toHaveBeenCalled();
});
it('defers an SSE refetch while there are pending local edits (CAS handles it)', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({ pages: [], revision: 1 });
const store = configure(api);
await store.init();
store.addPage('local'); // now dirty: debounce pending + pending cache
api.getData.mockClear();
lastStreamHandlers(api).onRevision(9);
await flush();
expect(api.getData).not.toHaveBeenCalled();
});
it('on 409 adopts the server tree (server wins) and discards the local edit', async () => {
const PAGE_A = 'aaaaaaaa-1111-4111-8111-111111111111';
const PAGE_B = 'bbbbbbbb-2222-4222-8222-222222222222';
const pageWith = (id: string, name: string): TreeDto['pages'][number] => ({
id,
name,
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [],
});
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValueOnce({ pages: [pageWith(PAGE_A, 'A')], revision: 1 });
// The PUT is rejected as stale; under server-wins we do NOT retry it.
api.putData.mockRejectedValueOnce(httpError(409));
// The 409 refetch returns a tree where another device added page B.
api.getData.mockResolvedValueOnce({
pages: [pageWith(PAGE_A, 'A'), pageWith(PAGE_B, 'from-other-device')],
revision: 5,
});
const store = configure(api);
await store.init();
// Local edit to page A, then save.
store.updatePage(PAGE_A, { name: 'A-edited-locally' });
await vi.advanceTimersByTimeAsync(750);
await vi.runAllTimersAsync();
const byId = new Map(store.pages().map((p) => [p.id, p.name]));
// Server wins: the local edit to A is discarded and the remote tree adopted.
expect(byId.get(PAGE_A)).toBe('A');
expect(byId.get(PAGE_B)).toBe('from-other-device');
// The stale PUT fired once and was not retried; the refetched revision (5)
// is now our CAS base.
expect(api.putData).toHaveBeenCalledTimes(1);
expect(store.saveStatus()).toBe('saved');
});
it('closes the event stream on destroy', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const close = vi.fn();
api.openEventStream.mockReturnValue(close);
const store = configure(api);
await store.init();
store.ngOnDestroy();
expect(close).toHaveBeenCalledTimes(1);
});
it('closes the old stream and opens a new one on switchToken', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const closeOld = vi.fn();
api.openEventStream.mockReturnValueOnce(closeOld).mockReturnValue(() => {});
const store = configure(api);
await store.init();
store.switchToken(OTHER_TOKEN);
await vi.advanceTimersByTimeAsync(0);
expect(closeOld).toHaveBeenCalledTimes(1);
expect(api.openEventStream.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(api.openEventStream.mock.calls[api.openEventStream.mock.calls.length - 1][0]).toBe(
OTHER_TOKEN,
);
});
});

View file

@ -0,0 +1,54 @@
/**
* Minimal incremental parser for the Server-Sent Events wire format.
*
* We consume the event stream with fetch()+ReadableStream rather than the
* EventSource API (which can't send an Authorization header), so we parse the
* frames ourselves. Returns a function you feed decoded text chunks; it buffers
* across chunk boundaries and invokes `onMessage` once per complete event
* (events are terminated by a blank line). Comment lines (": ...", used for
* keepalives) are ignored.
*/
export interface SseMessage {
event: string | null;
data: string;
}
export function createSseParser(
onMessage: (message: SseMessage) => void,
): (chunk: string) => void {
let buffer = '';
let dataLines: string[] = [];
let eventName: string | null = null;
const dispatch = (): void => {
if (dataLines.length === 0 && eventName === null) return; // stray blank line
onMessage({ event: eventName, data: dataLines.join('\n') });
dataLines = [];
eventName = null;
};
return (chunk: string): void => {
buffer += chunk;
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
let line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (line.endsWith('\r')) line = line.slice(0, -1); // tolerate CRLF
if (line === '') {
dispatch();
continue;
}
if (line.startsWith(':')) continue; // comment / keepalive
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1); // SSE strips one leading space
if (field === 'event') eventName = value;
else if (field === 'data') dataLines.push(value);
// 'id' / 'retry' are unused by this app.
}
};
}

View file

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { createSseParser, SseMessage } from './sse';
function collect(): { feed: (c: string) => void; messages: SseMessage[] } {
const messages: SseMessage[] = [];
return { feed: createSseParser((m) => messages.push(m)), messages };
}
describe('createSseParser', () => {
it('parses a complete event/data frame', () => {
const { feed, messages } = collect();
feed('event: revision\ndata: {"revision": 3}\n\n');
expect(messages).toEqual([{ event: 'revision', data: '{"revision": 3}' }]);
});
it('buffers across chunk boundaries that split a frame', () => {
const { feed, messages } = collect();
feed('event: revis');
feed('ion\ndata: {"revisi');
feed('on": 9}\n\n');
expect(messages).toEqual([{ event: 'revision', data: '{"revision": 9}' }]);
});
it('emits one message per frame for back-to-back events', () => {
const { feed, messages } = collect();
feed('event: revision\ndata: {"revision": 1}\n\nevent: revision\ndata: {"revision": 2}\n\n');
expect(messages.map((m) => m.data)).toEqual(['{"revision": 1}', '{"revision": 2}']);
});
it('ignores keepalive comment lines', () => {
const { feed, messages } = collect();
feed(': keepalive\n\n');
feed('data: {"revision": 4}\n\n');
expect(messages).toEqual([{ event: null, data: '{"revision": 4}' }]);
});
it('tolerates CRLF line endings', () => {
const { feed, messages } = collect();
feed('event: revision\r\ndata: {"revision": 5}\r\n\r\n');
expect(messages).toEqual([{ event: 'revision', data: '{"revision": 5}' }]);
});
it('does not emit until a frame is terminated by a blank line', () => {
const { feed, messages } = collect();
feed('data: {"revision": 6}\n');
expect(messages).toEqual([]);
feed('\n');
expect(messages).toEqual([{ event: null, data: '{"revision": 6}' }]);
});
});