// Sending a book. The one call of this surface that carries a body worth watching, and therefore // the one that is not written on `fetch`. // // ⚠ WHY XHR AND NOT FETCH. A book is tens of megabytes on a domestic connection — minutes of // silence — and `fetch` reports nothing about a body going out: the Fetch standard exposes progress // on the RESPONSE (`response.body`) and has no equivalent for the request. The only standard way to // stream a request and watch it is a `ReadableStream` body, which needs `duplex: 'half'`, is HTTP/2 // only, and is shipped by no browser but Chromium — so on Firefox and Safari it would not upload at // all. `XMLHttpRequest.upload` is the interoperable mechanism, it is what every upload widget in the // industry still uses for this, and it is legal here and only here: the network gate // (eslint.config.js) forbids it outside `src/api/`. // // The seam does not leak: the screen gets a promise and a stream of numbers, exactly as it would // from a `fetch` written here. import { ApiError, basePath } from './client'; import { narrow } from './contract'; import type { Book, Problem } from './contract'; import type { components } from './schema'; type Schemas = components['schemas']; /** The add-a-book form, as a screen fills it in. */ export interface BookIntake { file: File; sourceLang: string; targetLang: string; /** Empty means "the parse will name it" — the contract's own reading of an absent title. */ title?: string; genre?: string; } export interface UploadProgress { /** Bytes already handed to the network. */ sent: number; /** Bytes in the whole body, or `null` while the browser has not said. */ total: number | null; } interface Options { /** * Called as the body goes out. MAY NEVER BE CALLED, and that is not a defect to be worked * around: the browser reports the progress of a request it sends itself, and a request answered * by a service worker it does not (measured — under the mock network Chromium fires `loadstart` * with the right total and then nothing at all). A screen therefore has to have a state for "it * is going, how far is not known", and must not draw a share out of a zero. */ onProgress?: (progress: UploadProgress) => void; signal?: AbortSignal; } /** * `POST /books`. * * ⚠ The ORDER of the parts is the contract (0.2.3, PD-172): `file` goes LAST, and every text field * before it. The platform reads the form as a stream and writes the book's row — the thing that * makes an upload visible while it arrives — before the body of the file; a field that arrives * after the file is not read at all and the call is answered `400`. `FormData` keeps the order it * was appended in, so this function is the one place that has to get it right. */ export function uploadBook(intake: BookIntake, options: Options = {}): Promise { const form = new FormData(); const title = intake.title?.trim() ?? ''; if (title !== '') form.append('title', title); form.append('source_lang', intake.sourceLang); form.append('target_lang', intake.targetLang); if (intake.genre !== undefined && intake.genre.trim() !== '') form.append('genre', intake.genre.trim()); form.append('file', intake.file, intake.file.name); return new Promise((resolve, reject) => { if (options.signal?.aborted === true) { reject(new UploadAborted()); return; } const request = new XMLHttpRequest(); request.open('POST', `${basePath}/books`); request.setRequestHeader('Accept', 'application/json'); // The client half of the CSRF rule, as on every unsafe call. `Content-Type` is deliberately NOT // set: only the browser knows the multipart boundary it is about to generate. request.setRequestHeader('X-TM-Client', 'web'); request.upload.addEventListener('progress', (event) => { options.onProgress?.({ sent: event.loaded, // `lengthComputable` is false while the browser does not know the size — a chunked body, // or an interceptor standing in for the network. A percentage invented out of a zero total // would be a number that means nothing. total: event.lengthComputable ? event.total : null, }); }); request.addEventListener('load', () => { if (request.status < 200 || request.status >= 300) { reject(new ApiError(request.status, problemOf(request), 'Request refused')); return; } try { resolve(narrow.book(JSON.parse(request.responseText) as Schemas['Book'])); } catch { // A 2xx whose body is not the book: the call succeeded and the answer is unusable, which is // a refusal from where the screen stands. reject(new ApiError(request.status, null, 'Request refused')); } }); // A body that never left, a connection that dropped: the platform was not reached, which is a // different thing from a refusal and the screen says so differently (status 0, as in client.ts). request.addEventListener('error', () => { reject(new ApiError(0, null, 'Server unreachable')); }); request.addEventListener('abort', () => { reject(new UploadAborted()); }); options.signal?.addEventListener( 'abort', () => { request.abort(); }, { once: true }, ); request.send(form); }); } /** The user cancelled the sending. Not a failure of anything, so not an `ApiError`. */ export class UploadAborted extends Error { constructor() { super('Upload aborted'); this.name = 'UploadAborted'; } } // The same rule as on the `fetch` path: a refusal carries an RFC 9457 body whose phrases are // already product language, and anything else is read as "no problem body". function problemOf(request: XMLHttpRequest): Problem | null { if (!request.getResponseHeader('Content-Type')?.includes('problem+json')) return null; try { return JSON.parse(request.responseText) as Problem; } catch { return null; } }