Add SSE updates
This commit is contained in:
parent
d1732128e2
commit
688bc0cfe9
13 changed files with 958 additions and 42 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
54
frontend/src/app/utils/sse.ts
Normal file
54
frontend/src/app/utils/sse.ts
Normal 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.
|
||||
}
|
||||
};
|
||||
}
|
||||
50
frontend/src/app/utils/sse.vitest.ts
Normal file
50
frontend/src/app/utils/sse.vitest.ts
Normal 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}' }]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue