textmachine/frontend/src/api/upload.ts

143 lines
5.9 KiB
TypeScript

// 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<Book> {
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<Book>((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;
}
}