fix(transcription): replace sendBeacon with fetch keepalive; add catch-all API proxy

sendBeacon always sends POST, but the backend expects PUT for block updates, so
saves were silently dropped on page unload.  Replace with fetch({ keepalive: true,
method: 'PUT' }) which survives navigation and uses the correct HTTP method.

Add a catch-all SvelteKit server route at /api/[...path] so all client-side API
calls work in production (without the Vite dev proxy).  More-specific routes
(/api/persons, /api/tags, /api/documents/[id]/file) keep precedence.

Closes #204

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 15:56:53 +02:00
committed by marcel
parent b6bfb9148e
commit e1304b6512
3 changed files with 107 additions and 4 deletions

View File

@@ -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<void>>();
@@ -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();
});
});

View File

@@ -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);
}
}

View File

@@ -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<RequestHandler>[0]): Promise<Response> {
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<string, string> = {};
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);