UNPKG

@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
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, };