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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
33
frontend/src/routes/api/[...path]/+server.ts
Normal file
33
frontend/src/routes/api/[...path]/+server.ts
Normal 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);
|
||||
Reference in New Issue
Block a user