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>>();
|
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
|
||||||
|
|
||||||
@@ -82,3 +82,70 @@ describe('createBlockAutoSave', () => {
|
|||||||
expect(mockSaveFn).not.toHaveBeenCalled();
|
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 {
|
function flushViaBeacon(): void {
|
||||||
for (const [blockId, text] of pendingTexts) {
|
for (const [blockId, text] of pendingTexts) {
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
|
fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
||||||
const body = JSON.stringify({ text });
|
method: 'PUT',
|
||||||
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
pendingTexts.delete(blockId);
|
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