diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts index dd13a7a6..148df5de 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise>(); @@ -82,3 +82,70 @@ describe('createBlockAutoSave', () => { expect(mockSaveFn).not.toHaveBeenCalled(); }); }); + +describe('flushViaBeacon', () => { + let mockFetch: Mock; + + beforeEach(() => { + vi.useFakeTimers(); + mockSaveFn.mockClear(); + mockSaveFn.mockResolvedValue(undefined); + mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('sends a PUT request with keepalive:true for each pending block', () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.handleTextChange('block-1', 'hello'); + as.handleTextChange('block-2', 'world'); + as.flushViaBeacon(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith( + '/api/documents/doc-1/transcription-blocks/block-1', + expect.objectContaining({ + method: 'PUT', + keepalive: true, + body: JSON.stringify({ text: 'hello' }) + }) + ); + expect(mockFetch).toHaveBeenCalledWith( + '/api/documents/doc-1/transcription-blocks/block-2', + expect.objectContaining({ + method: 'PUT', + keepalive: true, + body: JSON.stringify({ text: 'world' }) + }) + ); + }); + + it('does not call navigator.sendBeacon', () => { + const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.handleTextChange('block-1', 'text'); + as.flushViaBeacon(); + + expect(sendBeaconSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when there are no pending edits', () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.flushViaBeacon(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('cancels the debounce timer so saveFn is not also called', async () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.handleTextChange('block-1', 'text'); + as.flushViaBeacon(); + + await vi.advanceTimersByTimeAsync(2000); + expect(mockSaveFn).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts index a627f50e..c90b669f 100644 --- a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts @@ -97,9 +97,12 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { function flushViaBeacon(): void { for (const [blockId, text] of pendingTexts) { clearDebounce(blockId); - const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`; - const body = JSON.stringify({ text }); - navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); + fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + keepalive: true + }); pendingTexts.delete(blockId); } } diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts new file mode 100644 index 00000000..dab2308c --- /dev/null +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -0,0 +1,33 @@ +import type { RequestHandler } from './$types'; +import { env } from 'process'; + +const NO_BODY_METHODS = new Set(['GET', 'HEAD', 'DELETE']); + +async function proxy(event: Parameters[0]): Promise { + const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const backendUrl = `${apiUrl}/api/${event.params.path}${event.url.search}`; + + const contentType = event.request.headers.get('Content-Type'); + const hasBody = !NO_BODY_METHODS.has(event.request.method); + + const response = await event.fetch(backendUrl, { + method: event.request.method, + headers: contentType ? { 'Content-Type': contentType } : {}, + body: hasBody ? await event.request.arrayBuffer() : undefined + }); + + const responseHeaders: Record = {}; + const responseContentType = response.headers.get('Content-Type'); + if (responseContentType) responseHeaders['Content-Type'] = responseContentType; + + return new Response(response.body, { + status: response.status, + headers: responseHeaders + }); +} + +export const GET: RequestHandler = (event) => proxy(event); +export const POST: RequestHandler = (event) => proxy(event); +export const PUT: RequestHandler = (event) => proxy(event); +export const PATCH: RequestHandler = (event) => proxy(event); +export const DELETE: RequestHandler = (event) => proxy(event);