feat(http): add fetchText/fetchBuffer with timeout and size limits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
src/lib/server/http.ts
Normal file
91
src/lib/server/http.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
export type FetchOptions = {
|
||||||
|
maxBytes?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
userAgent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS: Required<FetchOptions> = {
|
||||||
|
maxBytes: 10 * 1024 * 1024,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
userAgent: 'Kochwas/0.1'
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertSafeUrl(url: string): void {
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid URL: ${url}`);
|
||||||
|
}
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||||
|
throw new Error(`Unsupported URL scheme: ${u.protocol}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(
|
||||||
|
response: Response,
|
||||||
|
maxBytes: number
|
||||||
|
): Promise<{ data: Uint8Array; total: number }> {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
const buf = new Uint8Array(await response.arrayBuffer());
|
||||||
|
if (buf.byteLength > maxBytes) throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||||
|
return { data: buf, total: buf.byteLength };
|
||||||
|
}
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value) {
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merged = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const c of chunks) {
|
||||||
|
merged.set(c, offset);
|
||||||
|
offset += c.byteLength;
|
||||||
|
}
|
||||||
|
return { data: merged, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
||||||
|
assertSafeUrl(url);
|
||||||
|
const merged = { ...DEFAULTS, ...opts };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), merged.timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'follow',
|
||||||
|
headers: { 'user-agent': merged.userAgent }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||||
|
return res;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string> {
|
||||||
|
const merged = { ...DEFAULTS, ...opts };
|
||||||
|
const res = await doFetch(url, merged);
|
||||||
|
const { data } = await readBody(res, merged.maxBytes);
|
||||||
|
return new TextDecoder('utf-8').decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBuffer(
|
||||||
|
url: string,
|
||||||
|
opts: FetchOptions = {}
|
||||||
|
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
||||||
|
const merged = { ...DEFAULTS, ...opts };
|
||||||
|
const res = await doFetch(url, merged);
|
||||||
|
const { data } = await readBody(res, merged.maxBytes);
|
||||||
|
return { data, contentType: res.headers.get('content-type') };
|
||||||
|
}
|
||||||
60
tests/integration/http.test.ts
Normal file
60
tests/integration/http.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { fetchText, fetchBuffer } from '../../src/lib/server/http';
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = createServer();
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const addr = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchText', () => {
|
||||||
|
it('returns body text', async () => {
|
||||||
|
server.on('request', (_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html' });
|
||||||
|
res.end('<html>hi</html>');
|
||||||
|
});
|
||||||
|
expect(await fetchText(`${baseUrl}/`)).toBe('<html>hi</html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-http schemes', async () => {
|
||||||
|
await expect(fetchText('file:///etc/passwd')).rejects.toThrow(/scheme/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces max bytes', async () => {
|
||||||
|
const big = 'x'.repeat(200);
|
||||||
|
server.on('request', (_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||||
|
res.end(big);
|
||||||
|
});
|
||||||
|
await expect(fetchText(`${baseUrl}/`, { maxBytes: 100 })).rejects.toThrow(/exceeds/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out slow responses', async () => {
|
||||||
|
server.on('request', () => {
|
||||||
|
// never respond
|
||||||
|
});
|
||||||
|
await expect(fetchText(`${baseUrl}/`, { timeoutMs: 150 })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchBuffer', () => {
|
||||||
|
it('returns bytes + content-type', async () => {
|
||||||
|
server.on('request', (_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'image/png' });
|
||||||
|
res.end(Buffer.from([1, 2, 3, 4]));
|
||||||
|
});
|
||||||
|
const { data, contentType } = await fetchBuffer(`${baseUrl}/`);
|
||||||
|
expect(Array.from(data)).toEqual([1, 2, 3, 4]);
|
||||||
|
expect(contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user