-
Notifications
You must be signed in to change notification settings - Fork 2
Compat Http
@tundralibs/compat/http — runtime-agnostic, pure HTTP building blocks shared by
server-side packages (webserver, radrouter, rpc, a framework layer) and
client-side ones (fetch wrappers, a REST client), so none re-implement them.
Every helper here is pure computation — no filesystem, socket,
process, or other runtime global — so it runs unchanged on all five targets: Deno, Bun, Node.js, Cloudflare Workers, and the browser. OnlycontentTypeForpulls a dependency (@std/media-types, itself a pure lookup table); the rest are self-contained. Nothing here can throwUnsupportedRuntimeError.
- Installation
- Methods, status codes, status text
- Content negotiation
- Range requests (RFC 7233)
- Cookies (RFC 6265)
- Content-Type from a path
- Related Documentation
Deno:
deno add @tundralibs/compatBun:
bunx jsr add @tundralibs/compatNode.js:
npx jsr add @tundralibs/compatThree typed constants for building and reading responses:
-
HTTPMethod— the nine RFC-defined methods:GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS,TRACE,CONNECT. Custom verbs are out of scope; widen the type at the call site if you need one. -
StatusCode— the codes application code actually emits, as a literal union that still accepts anynumber(the escape hatch), so IDE autocomplete lists the known codes without rejecting a custom599. -
STATUS_TEXT— the IANA reason phrase for a code, orundefinedfor a code not in the table.
import { STATUS_TEXT, type StatusCode } from '@tundralibs/compat/http';
// Build a Response, filling statusText from the code.
function reply(status: StatusCode, body: string): Response {
return new Response(body, { status, statusText: STATUS_TEXT[status] ?? '' });
}
console.log(reply(404, 'nope').statusText); // "Not Found"
console.log(STATUS_TEXT[200]); // "OK"
STATUS_TEXT[code]returnsundefinedfor any code outside the IANA table — including a custom599, whichStatusCodeaccepts at the type level but cannot supply a phrase for. Always guard with?? ''(or your own phrase) when the code may be arbitrary. Reach for these when your response layer needs a default reason phrase; skip them if you already write reason phrases by hand.
negotiate(accept, offered) picks the client's best match by q-value and
specificity — the most-specific Accept entry decides an offer's quality, and
ties resolve to the earliest offered (server preference). A
missing/blank/unparseable Accept yields the first offer (the server default);
undefined means the client accepts none of what you offer.
import { negotiate } from '@tundralibs/compat/http';
negotiate('text/html, application/json;q=0.9', [
'application/json',
'text/html',
]); // → 'text/html' (q=1 beats q=0.9)
negotiate('*/*', ['application/json', 'text/html']); // → 'application/json' (first offer)
negotiate('text/*', ['application/json', 'text/html']); // → 'text/html' (type/* matches)
negotiate(null, ['application/json', 'text/html']); // → 'application/json' (default)
negotiate('image/png', ['application/json']); // → undefined (no match)A realistic handler — negotiate once, then branch on the winner:
import { negotiate } from '@tundralibs/compat/http';
function represent(req: Request): Response {
const type = negotiate(req.headers.get('accept'), [
'application/json',
'text/html',
]);
if (type === undefined) {
return new Response('Not Acceptable', { status: 406 });
}
const body = type === 'text/html'
? '<p>hi</p>'
: JSON.stringify({ hi: true });
return new Response(body, { headers: { 'content-type': type } });
}
represent(new Request('https://x/', { headers: { accept: 'text/html' } }));An explicit
q=0marks a type unacceptable:negotiate('application/json;q=0', ['application/json'])returnsundefinedeven though it was offered. Offered values must be fulltype/subtypemedia types — an offer with no/scores zero and can never win.negotiatehandles only theAcceptheader; language/encoding/charset negotiation is not covered.
parseRange(header, size) resolves a single Range: bytes=… header against a
known size and returns one of three things:
- an inclusive
{ start, end }(ByteRange) — answer206 Partial Content, -
'unsatisfiable'— answer416withContent-Range: bytes */<size>, -
undefined— no/invalid range, or a multi-range → serve the whole body.
import { parseRange } from '@tundralibs/compat/http';
parseRange('bytes=0-99', 500); // → { start: 0, end: 99 }
parseRange('bytes=100-', 500); // → { start: 100, end: 499 } (open-ended → EOF)
parseRange('bytes=-100', 500); // → { start: 400, end: 499 } (suffix: last 100)
parseRange('bytes=600-700', 500); // → 'unsatisfiable' (start past EOF)
parseRange('bytes=0-9,20-29', 500); // → undefined (multi-range → serve whole)
parseRange(null, 500); // → undefinedA ByteRange slots straight into readFileStream(path, range) from
@tundralibs/compat/file, so the three outcomes map cleanly onto a
file-serving handler:
import {
contentTypeFor,
parseRange,
STATUS_TEXT,
} from '@tundralibs/compat/http';
import { readFileStream, stat } from '@tundralibs/compat/file';
async function serveFile(req: Request, path: string): Promise<Response> {
const { size } = await stat(path);
const type = contentTypeFor(path);
const range = parseRange(req.headers.get('range'), size);
if (range === 'unsatisfiable') {
return new Response(STATUS_TEXT[416], {
status: 416,
headers: { 'content-range': `bytes */${size}` },
});
}
if (range === undefined) {
return new Response(await readFileStream(path), {
headers: { 'content-type': type, 'accept-ranges': 'bytes' },
});
}
return new Response(await readFileStream(path, range), {
status: 206,
headers: {
'content-type': type,
'content-range': `bytes ${range.start}-${range.end}/${size}`,
'content-length': String(range.end - range.start + 1),
},
});
}
serveFile(
new Request('https://x/v.mp4', { headers: { range: 'bytes=0-1023' } }),
'/srv/v.mp4',
);
parseRangehandles a single range only. A multi-range header, a syntactically invalid one, and a zero-length resource (size === 0) all returnundefined— the caller serves the full body, which is a valid response. The'unsatisfiable'result is the only one that must become a416; don't treatundefinedas an error. Bounds are inclusive, matching bothContent-RangeandreadFileStream's{ start, end }.
parseCookies(header) decodes a Cookie request header into a name → value map
(values percent-decoded, surrounding double-quotes stripped, malformed pairs
skipped — never thrown). serializeCookie(name, value, options) builds one
Set-Cookie value with the value percent-encoded, so it can never inject
;/CRLF into the header.
import {
type CookieOptions,
parseCookies,
serializeCookie,
} from '@tundralibs/compat/http';
// On the way out — one Set-Cookie value per call.
const opts: CookieOptions = {
maxAge: 3600, // seconds (floored); use `expires` for an absolute Date
path: '/',
httpOnly: true,
sameSite: 'Lax',
};
const setCookie = serializeCookie('sid', 'a b/c', opts);
// → 'sid=a%20b%2Fc; Max-Age=3600; Path=/; HttpOnly; SameSite=Lax'
// On the way in — decode the request's Cookie header (round-trips the value).
const jar = parseCookies(`${setCookie.split(';')[0]}; theme=dark`);
console.log(jar.sid, jar.theme); // 'a b/c' 'dark'
console.log(setCookie);An illegal cookie name is a TypeError, not a skipped attribute:
import { serializeCookie } from '@tundralibs/compat/http';
try {
serializeCookie('bad name', 'x'); // a space is illegal in a cookie name
} catch (err) {
console.log(err instanceof TypeError); // true
}
serializeCookiethrowsTypeErroron a name containing separators or control chars (space,;,=,(, …) — the value is always safe (percent-encoded), but validating the name is on you. Set at most one ofmaxAge(seconds) orexpires(aDate). Browsers ignoreSameSite: 'None'unlessSecureis also set, andserializeCookiewill not addSecurefor you. Signing and verification are a higher layer — these helpers do not authenticate cookies.
contentTypeFor(pathOrName) resolves a file's Content-Type from its extension
(via @std/media-types, with a charset appended for text types). It never reads
the file — resolution is by extension string only. Any file-serving webserver
needs this to set a correct response type.
import { contentTypeFor } from '@tundralibs/compat/http';
contentTypeFor('/public/index.html'); // → 'text/html; charset=UTF-8'
contentTypeFor('data.json'); // → 'application/json; charset=UTF-8'
contentTypeFor('logo.svg'); // → 'image/svg+xml' (no charset — not a text type)
contentTypeFor('firmware.xyz'); // → 'application/octet-stream' (unknown extension)
contentTypeFor('.env'); // → 'application/octet-stream' (dotfile — no extension)
contentTypeFor('README'); // → 'application/octet-stream' (no extension)An unknown extension, an extension-less name, and a dotfile (
.env) all fall back toapplication/octet-stream— a safe, download-forcing default, never a throw. A charset (; charset=UTF-8) is appended only for text types. Both/and\count as path separators, so a Windows-style path resolves correctly. Because it matches on the extension alone, a mislabelled file (.pngholding JSON) gets the extension's type, not the content's.
-
Compat-File —
readFileStream/stat, the file-serving side of the range example above. - Compat-Fetch — the HTTP client these primitives support.