143 lines
5.9 KiB
TypeScript
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;
|
|
}
|
|
}
|