@nbit/bun
Version:
A simple, declarative, type-safe way to build web services and REST APIs for [Bun](https://bun.sh), [Node](https://nodejs.org) and [Cloudflare Workers](https://workers.cloudflare.com/).
516 lines (497 loc) • 16 kB
JavaScript
import { extname, resolve, join } from 'path';
import { stat } from 'fs/promises';
function createRouter() {
const routes = [];
return {
insert(method, pattern, payload) {
routes.push({
method,
pattern,
matcher: getMatcher(pattern),
payload,
});
},
getMatches(method, path) {
const results = [];
for (const route of routes) {
if (route.method !== '*' && route.method !== method) {
continue;
}
const captures = route.matcher(path);
if (captures) {
const { method, pattern, payload } = route;
results.push([payload, captures, [method, pattern]]);
}
}
return results;
},
};
}
function getMatcher(pattern) {
const patternSegments = pattern.slice(1).split('/');
const hasPlaceholder = pattern.includes('/:');
const hasWildcard = patternSegments.includes('*');
const isStatic = !hasPlaceholder && !hasWildcard;
return (path) => {
const captures = {};
if (isStatic && path === pattern) {
return captures;
}
const pathSegments = path.slice(1).split('/');
if (!hasWildcard && patternSegments.length !== pathSegments.length) {
return null;
}
const length = Math.max(patternSegments.length, pathSegments.length);
for (let i = 0; i < length; i++) {
const patternSegment = patternSegments[i];
if (patternSegment === '*') {
const remainder = pathSegments.slice(i);
captures[patternSegment] = remainder.join('/');
return remainder.length ? captures : null;
}
const pathSegment = pathSegments[i];
if (!pathSegment || !patternSegment) {
return null;
}
if (patternSegment.startsWith(':') && pathSegment) {
const key = patternSegment.slice(1);
captures[key] = pathSegment;
} else if (patternSegment !== pathSegment) {
return null;
}
}
return captures;
};
}
class HttpError extends Error {
status;
constructor(...args) {
const [status, message, options] = normalizeArgs(args);
super(message ?? String(status), options);
this.status = status;
}
get name() {
return this.constructor.name;
}
get [Symbol.toStringTag]() {
return this.constructor.name;
}
}
function normalizeArgs(args) {
if (typeof args[0] === 'number') {
return args;
}
const [{ status, message }, options] = args;
return [status, message, options];
}
const URL_BASE = 'http://0.0.0.0';
function parseUrl(url) {
return new URL(url, URL_BASE);
}
const canHaveNullBody = new Set(['GET', 'DELETE', 'HEAD', 'OPTIONS']);
class CustomRequest {
request;
method;
url;
headers;
path;
search;
query;
params;
_fallbackBody;
constructor(request) {
this.request = request;
const { method, url, headers } = request;
this.method = method;
this.url = url;
this.headers = headers;
const { pathname, search, searchParams } = parseUrl(url);
this.path = pathname;
this.search = search;
this.query = searchParams;
this.params = {};
}
get body() {
const body = this.request.body;
if (!canHaveNullBody.has(this.method) && body == null) {
const emptyBody =
this._fallbackBody ?? (this._fallbackBody = createEmptyBody());
return emptyBody;
}
return body;
}
get bodyUsed() {
return Boolean(this.request.bodyUsed);
}
arrayBuffer() {
return this.request.arrayBuffer();
}
text() {
return this.request.text();
}
async json() {
const contentType = getContentType(this.headers);
let message = 'Invalid JSON body';
if (contentType === 'application/json') {
try {
const parsed = await this.request.json();
return parsed;
} catch (e) {
message = e instanceof Error ? e.message : String(e);
}
}
throw new HttpError(400, message);
}
}
function getContentType(headers) {
const contentType = headers.get('content-type');
if (contentType != null) {
return (contentType.split(';')[0] ?? '').toLowerCase();
}
}
function createEmptyBody() {
const request = new Request('http://localhost/', {
method: 'POST',
body: '',
});
return request.body;
}
class StaticFile {
filePath;
responseInit;
options;
constructor(filePath, init) {
this.filePath = filePath;
const { status, statusText, headers, maxAge, cachingHeaders } = init ?? {};
this.responseInit = {
status: status ?? 200,
statusText: statusText ?? '',
headers: headers ?? {},
};
this.options = { maxAge, cachingHeaders };
}
}
function defineErrors(input) {
return Object.fromEntries(
Object.entries(input).map(([name, message]) => [
name,
Object.defineProperties(
class extends Error {
constructor(params, options) {
let resolvedMessage = params
? resolveMessage(message, params)
: message;
if (options?.cause) {
resolvedMessage += '\n' + indent(String(options.cause));
}
super(resolvedMessage, options);
}
get name() {
return name;
}
get [Symbol.toStringTag]() {
return name;
}
},
{
name: { value: name, configurable: true },
},
),
]),
);
}
function resolveMessage(message, params) {
return message.replace(/\{(.*?)\}/g, (_, key) => {
return params[key] == null ? '' : String(params[key]);
});
}
function indent(message) {
const lineBreak = /\r\n|\r|\n/;
return message
.split(lineBreak)
.map((line) => ' ' + line)
.join('\n');
}
const Errors = defineErrors({
StringifyError:
'Failed to stringify value returned from route handler: {route}',
});
function defineAdapter(createAdapter) {
const createApplication = (applicationOptions = {}) => {
const { getContext, errorHandler } = applicationOptions;
const app = getApp();
const adapter = createAdapter(applicationOptions);
const defineRoutes = (fn) => fn(app);
const createRequestHandler = (...routeLists) => {
const router = createRouter();
for (const routeList of routeLists) {
for (const [method, pattern, handler] of routeList) {
router.insert(method, pattern, handler);
}
}
const routeRequest = async (request) => {
const context = getContext?.(request);
const customRequest = new CustomRequest(request);
if (context) {
Object.assign(customRequest, context);
}
const { method, path } = customRequest;
const matches = router.getMatches(method, path);
for (const [handler, captures, route] of matches) {
Object.assign(customRequest, { params: captures });
const result = await handler(customRequest);
if (result !== undefined) {
let resolvedResponse;
if (result instanceof Response || result instanceof StaticFile) {
resolvedResponse = result;
} else {
try {
resolvedResponse = Response.json(result);
} catch (e) {
const [method, pattern] = route;
throw new Errors.StringifyError(
{ route: `${method}:${pattern}` },
{ cause: toError(e) },
);
}
}
return await adapter.toResponse(request, resolvedResponse);
}
}
return await adapter.toResponse(request, undefined);
};
return async (request) => {
try {
const response = await routeRequest(request);
if (response) {
return response;
}
} catch (e) {
if (e instanceof HttpError) {
const { status, message } = e;
return new Response(message, { status });
}
const error = toError(e);
if (errorHandler) {
try {
return await errorHandler(error);
} catch (e) {
return await adapter.onError(request, toError(e));
}
}
return await adapter.onError(request, error);
}
return new Response('Not found', { status: 404 });
};
};
const attachRoutes = (...routeLists) => {
const handleRequest = createRequestHandler(...routeLists);
return adapter.createNativeHandler(handleRequest);
};
return { defineRoutes, createRequestHandler, attachRoutes };
};
return createApplication;
}
function getApp() {
return {
get: (path, handler) => ['GET', path, handler],
post: (path, handler) => ['POST', path, handler],
put: (path, handler) => ['PUT', path, handler],
delete: (path, handler) => ['DELETE', path, handler],
route: (method, path, handler) => [method.toUpperCase(), path, handler],
};
}
function toError(e) {
return e instanceof Error ? e : new Error(String(e));
}
var Request$1 = Request;
class CustomResponse extends Response {
static file(filePath, init) {
return new StaticFile(filePath, init);
}
}
function shouldSend304(headers, serverLastModified, serverEtag) {
const clientModifiedSince = headers.get('if-modified-since');
const clientEtag = headers.get('if-none-match');
let clientModifiedDate;
if (!clientModifiedSince && !clientEtag) {
return false;
}
if (clientModifiedSince) {
try {
clientModifiedDate = Date.parse(clientModifiedSince);
} catch (err) {
return false;
}
if (new Date(clientModifiedDate).toString() === 'Invalid Date') {
return false;
}
if (clientModifiedDate < serverLastModified.valueOf()) {
return false;
}
}
if (clientEtag) {
if (clientEtag !== serverEtag) {
return false;
}
}
return true;
}
function generateEtag(stats) {
const datePart = stats.mtimeMs.toString(16).padStart(11, '0');
const sizePart = stats.size.toString(16);
return `W/"${sizePart}${datePart}"`;
}
const mimeTypeList =
'audio/aac=aac&application/x-abiword=abw&application/x-freearc=arc&image/avif=avif&video/x-msvideo=avi&application/vnd.amazon.ebook=azw&application/octet-stream=bin&image/bmp=bmp&application/x-bzip=bz&application/x-bzip2=bz2&application/x-cdf=cda&application/x-csh=csh&text/css=css&text/csv=csv&application/msword=doc&application/vnd.openxmlformats-officedocument.wordprocessingml.document=docx&application/vnd.ms-fontobject=eot&application/epub+zip=epub&application/gzip=gz&image/gif=gif&text/html=html,htm&image/vnd.microsoft.icon=ico&text/calendar=ics&application/java-archive=jar&image/jpeg=jpeg,jpg&text/javascript=js,mjs&application/json=json&application/ld+json=jsonld&audio/midi+audio/x-midi=midi,mid&audio/mpeg=mp3&video/mp4=mp4&video/mpeg=mpeg&application/vnd.apple.installer+xml=mpkg&application/vnd.oasis.opendocument.presentation=odp&application/vnd.oasis.opendocument.spreadsheet=ods&application/vnd.oasis.opendocument.text=odt&audio/ogg=oga&video/ogg=ogv&application/ogg=ogx&audio/opus=opus&font/otf=otf&image/png=png&application/pdf=pdf&application/x-httpd-php=php&application/vnd.ms-powerpoint=ppt&application/vnd.openxmlformats-officedocument.presentationml.presentation=pptx&application/vnd.rar=rar&application/rtf=rtf&application/x-sh=sh&image/svg+xml=svg&application/x-shockwave-flash=swf&application/x-tar=tar&image/tiff=tif,tiff&video/mp2t=ts&font/ttf=ttf&text/plain=txt&application/vnd.visio=vsd&audio/wav=wav&audio/webm=weba&video/webm=webm&image/webp=webp&font/woff=woff&font/woff2=woff2&application/xhtml+xml=xhtml&application/vnd.ms-excel=xls&application/vnd.openxmlformats-officedocument.spreadsheetml.sheet=xlsx&application/xml=xml&application/vnd.mozilla.xul+xml=xul&application/zip=zip&video/3gpp=3gp&video/3gpp2=3g2&application/x-7z-compressed=7z';
const mimeToExtensions = new Map(
mimeTypeList.split('&').map((item) => {
const [mime = '', exts = ''] = item.split('=');
return [mime, exts.split(',')];
}),
);
const extToMime = new Map();
for (let [mime, exts] of mimeToExtensions) {
for (let ext of exts) {
extToMime.set(ext, mime);
}
}
function getMimeTypeFromExt(ext) {
return extToMime.get(ext.toLowerCase());
}
const defaultOptions = {
cachingHeaders: true,
};
async function computeHeaders(
requestHeaders,
fullFilePath,
fileStats,
options = defaultOptions,
) {
const { cachingHeaders = true, maxAge } = options;
if (!fileStats.isFile()) {
return null;
}
const lastModified = new Date(fileStats.mtimeMs);
const etag = generateEtag(fileStats);
if (cachingHeaders) {
const send304 = shouldSend304(requestHeaders, lastModified, etag);
if (send304) {
return { status: 304 };
}
}
const ext = extname(fullFilePath).slice(1);
const headers = {
'Content-Length': String(fileStats.size),
'Content-Type': getMimeTypeFromExt(ext) ?? 'application/octet-stream',
};
if (cachingHeaders) {
headers['ETag'] = etag;
headers['Last-Modified'] = lastModified.toGMTString();
}
if (maxAge !== undefined) {
headers['Cache-Control'] = `max-age=${maxAge}`;
}
return {
status: undefined,
headers,
};
}
function resolveFilePath(filePath, options) {
const { root = process.cwd(), allowStaticFrom = [] } = options;
const projectRoot = resolve(root);
const fullFilePath = join(projectRoot, filePath);
for (let allowedPath of allowStaticFrom) {
const fullAllowedPath = join(root, allowedPath);
if (fullFilePath.startsWith(fullAllowedPath + '/')) {
return [fullFilePath, allowedPath];
}
}
return null;
}
var fs = {
stat,
};
async function tryAsync(fn) {
try {
return await fn();
} catch (e) {
return null;
}
}
async function serveFile(requestHeaders, fullFilePath, options = {}) {
const fileStats = await tryAsync(() => fs.stat(fullFilePath));
if (!fileStats || !fileStats.isFile()) {
return null;
}
const result = await computeHeaders(
requestHeaders,
fullFilePath,
fileStats,
options,
);
if (result == null || result.status === 304) {
return result;
}
return {
headers: result.headers,
body: Bun.file(fullFilePath),
};
}
const createApplication = defineAdapter((applicationOptions) => {
const fromStaticFile = async (requestHeaders, staticFile) => {
const { filePath, options, responseInit: init } = staticFile;
const resolved = resolveFilePath(filePath, applicationOptions);
if (!resolved) {
return;
}
const [fullFilePath] = resolved;
const customServeFile = applicationOptions.serveFile;
if (customServeFile) {
const { status, statusText, headers } = new Response(null, init);
const maybeResponse = await customServeFile({
filePath,
fullFilePath,
status,
statusText,
headers,
options,
});
return maybeResponse ?? undefined;
}
const fileResponse = await serveFile(requestHeaders, fullFilePath, options);
if (!fileResponse) {
return;
}
const responseStatus = fileResponse.status ?? init.status ?? 200;
const responseHeaders = new Headers(init.headers);
for (const [key, value] of Object.entries(fileResponse.headers ?? {})) {
if (!responseHeaders.has(key)) {
responseHeaders.set(key, value);
}
}
return new Response(fileResponse.body ?? '', {
...init,
status: responseStatus,
headers: responseHeaders,
});
};
return {
onError: (request, error) => {
return new Response(String(error), { status: 500 });
},
toResponse: async (request, result) => {
if (result instanceof StaticFile) {
return await fromStaticFile(request.headers, result);
}
return result;
},
createNativeHandler: (handleRequest) => handleRequest,
};
});
export {
HttpError,
Request$1 as Request,
CustomResponse as Response,
createApplication,
};