diff --git a/src/lib/server/http.ts b/src/lib/server/http.ts new file mode 100644 index 0000000..2009b9c --- /dev/null +++ b/src/lib/server/http.ts @@ -0,0 +1,91 @@ +export type FetchOptions = { + maxBytes?: number; + timeoutMs?: number; + userAgent?: string; +}; + +const DEFAULTS: Required = { + 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 { + 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 { + 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') }; +} diff --git a/tests/integration/http.test.ts b/tests/integration/http.test.ts new file mode 100644 index 0000000..67de641 --- /dev/null +++ b/tests/integration/http.test.ts @@ -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((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((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('hi'); + }); + expect(await fetchText(`${baseUrl}/`)).toBe('hi'); + }); + + 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'); + }); +});