mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Remote datasets: cap fetch body size
This commit is contained in:
@@ -8,15 +8,18 @@ function fakeResponse(opts: {
|
||||
statusText?: string;
|
||||
body?: string;
|
||||
contentType?: string | null;
|
||||
contentLength?: number;
|
||||
}): Response {
|
||||
const headers: Record<string, string | null> = {
|
||||
'content-type': opts.contentType ?? null,
|
||||
'content-length': opts.contentLength !== undefined ? String(opts.contentLength) : null,
|
||||
};
|
||||
return {
|
||||
ok: opts.ok,
|
||||
status: opts.status ?? (opts.ok ? 200 : 500),
|
||||
statusText: opts.statusText ?? '',
|
||||
text: () => Promise.resolve(opts.body ?? ''),
|
||||
headers: {
|
||||
get: (k: string) => (k.toLowerCase() === 'content-type' ? (opts.contentType ?? null) : null),
|
||||
},
|
||||
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
@@ -84,6 +87,36 @@ describe('fetchRemoteData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an oversized body declared by Content-Length, before reading it', async () => {
|
||||
const text = vi.fn(() => Promise.resolve('x'));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: '',
|
||||
text,
|
||||
headers: { get: (k: string) => (k.toLowerCase() === 'content-length' ? '999' : null) },
|
||||
} as unknown as Response),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
fetchRemoteData('https://example.com/huge.csv', { maxBytes: 100 }),
|
||||
).rejects.toMatchObject({ reason: 'too-large' });
|
||||
expect(text).not.toHaveBeenCalled(); // never read the body
|
||||
});
|
||||
|
||||
test('rejects an oversized body as a backstop when no length is declared', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(fakeResponse({ ok: true, body: 'abcdefghij' }))),
|
||||
);
|
||||
await expect(
|
||||
fetchRemoteData('https://example.com/chunked.csv', { maxBytes: 5 }),
|
||||
).rejects.toMatchObject({ reason: 'too-large' });
|
||||
});
|
||||
|
||||
test('the thrown error is a RemoteFetchError', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
/** Why a remote fetch failed, in terms the UI maps to copy + a recovery path. */
|
||||
export type RemoteFetchReason = 'network' | 'http' | 'empty' | 'timeout';
|
||||
export type RemoteFetchReason = 'network' | 'http' | 'empty' | 'timeout' | 'too-large';
|
||||
|
||||
/** A classified remote-fetch failure. `status` is set only for `http`. */
|
||||
export class RemoteFetchError extends Error {
|
||||
@@ -42,9 +42,20 @@ export interface RemoteData {
|
||||
/** Default ceiling on a single fetch before it is aborted as a timeout. */
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Hard ceiling on a snapshotted remote body. A URL dataset is held whole in memory
|
||||
* while profiling and then stored whole in IndexedDB, so an unbounded download
|
||||
* (a multi-hundred-MB file) would jank or exhaust the tab. Checked against the
|
||||
* declared `Content-Length` before reading, and against the body as a backstop for
|
||||
* chunked responses that declare no length.
|
||||
*/
|
||||
export const MAX_REMOTE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export interface FetchRemoteDataOptions {
|
||||
/** Abort the fetch after this many ms (default 30 s). */
|
||||
timeoutMs?: number;
|
||||
/** Reject a body larger than this many bytes (default {@link MAX_REMOTE_BYTES}). */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
function describeNetworkError(err: unknown): string {
|
||||
@@ -52,6 +63,11 @@ function describeNetworkError(err: unknown): string {
|
||||
return `Couldn't reach the URL (${reason}).`;
|
||||
}
|
||||
|
||||
/** A byte count as a rounded MB string for diagnostic messages (e.g. `120 MB`). */
|
||||
function megabytes(bytes: number): string {
|
||||
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote dataset resource once and return its raw body. Throws
|
||||
* `RemoteFetchError` on any failure — the caller distinguishes recovery by
|
||||
@@ -63,6 +79,7 @@ export async function fetchRemoteData(
|
||||
options: FetchRemoteDataOptions = {},
|
||||
): Promise<RemoteData> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxBytes = options.maxBytes ?? MAX_REMOTE_BYTES;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
@@ -88,12 +105,24 @@ export async function fetchRemoteData(
|
||||
);
|
||||
}
|
||||
|
||||
// Reject an oversized body before reading it, when the server declares the size —
|
||||
// so a huge download never lands in memory in the first place.
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
throw new RemoteFetchError('too-large', `The file is about ${megabytes(declared)}.`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (text.trim() === '') {
|
||||
throw new RemoteFetchError('empty', 'The URL returned no data.');
|
||||
}
|
||||
|
||||
// TODO: guard very large responses (cap or warn) before snapshotting into
|
||||
// IndexedDB — a multi-hundred-MB file would load fully into memory + storage.
|
||||
// Backstop for a chunked response that declared no Content-Length: the body is
|
||||
// already in memory, but this still keeps it out of IndexedDB. `text.length`
|
||||
// (UTF-16 units) slightly under-counts multibyte bytes — fine for a ceiling.
|
||||
if (text.length > maxBytes) {
|
||||
throw new RemoteFetchError('too-large', `The file is over ${megabytes(maxBytes)}.`);
|
||||
}
|
||||
|
||||
return { text, contentType: response.headers.get('content-type') };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user