UNPKG

16 kBJavaScriptView Raw
1import { extname, resolve, join } from 'path';
2import { stat } from 'fs/promises';
3
4function createRouter() {
5 const routes = [];
6 return {
7 insert(method, pattern, payload) {
8 routes.push({
9 method,
10 pattern,
11 matcher: getMatcher(pattern),
12 payload,
13 });
14 },
15 getMatches(method, path) {
16 const results = [];
17 for (const route of routes) {
18 if (route.method !== '*' && route.method !== method) {
19 continue;
20 }
21 const captures = route.matcher(path);
22 if (captures) {
23 const { method, pattern, payload } = route;
24 results.push([payload, captures, [method, pattern]]);
25 }
26 }
27 return results;
28 },
29 };
30}
31function getMatcher(pattern) {
32 const patternSegments = pattern.slice(1).split('/');
33 const hasPlaceholder = pattern.includes('/:');
34 const hasWildcard = patternSegments.includes('*');
35 const isStatic = !hasPlaceholder && !hasWildcard;
36 return (path) => {
37 const captures = {};
38 if (isStatic && path === pattern) {
39 return captures;
40 }
41 const pathSegments = path.slice(1).split('/');
42 if (!hasWildcard && patternSegments.length !== pathSegments.length) {
43 return null;
44 }
45 const length = Math.max(patternSegments.length, pathSegments.length);
46 for (let i = 0; i < length; i++) {
47 const patternSegment = patternSegments[i];
48 if (patternSegment === '*') {
49 const remainder = pathSegments.slice(i);
50 captures[patternSegment] = remainder.join('/');
51 return remainder.length ? captures : null;
52 }
53 const pathSegment = pathSegments[i];
54 if (!pathSegment || !patternSegment) {
55 return null;
56 }
57 if (patternSegment.startsWith(':') && pathSegment) {
58 const key = patternSegment.slice(1);
59 captures[key] = pathSegment;
60 } else if (patternSegment !== pathSegment) {
61 return null;
62 }
63 }
64 return captures;
65 };
66}
67
68class HttpError extends Error {
69 status;
70 constructor(...args) {
71 const [status, message, options] = normalizeArgs(args);
72 super(message ?? String(status), options);
73 this.status = status;
74 }
75 get name() {
76 return this.constructor.name;
77 }
78 get [Symbol.toStringTag]() {
79 return this.constructor.name;
80 }
81}
82function normalizeArgs(args) {
83 if (typeof args[0] === 'number') {
84 return args;
85 }
86 const [{ status, message }, options] = args;
87 return [status, message, options];
88}
89
90const URL_BASE = 'http://0.0.0.0';
91function parseUrl(url) {
92 return new URL(url, URL_BASE);
93}
94
95const canHaveNullBody = new Set(['GET', 'DELETE', 'HEAD', 'OPTIONS']);
96class CustomRequest {
97 request;
98 method;
99 url;
100 headers;
101 path;
102 search;
103 query;
104 params;
105 _fallbackBody;
106 constructor(request) {
107 this.request = request;
108 const { method, url, headers } = request;
109 this.method = method;
110 this.url = url;
111 this.headers = headers;
112 const { pathname, search, searchParams } = parseUrl(url);
113 this.path = pathname;
114 this.search = search;
115 this.query = searchParams;
116 this.params = {};
117 }
118 get body() {
119 const body = this.request.body;
120 if (!canHaveNullBody.has(this.method) && body == null) {
121 const emptyBody =
122 this._fallbackBody ?? (this._fallbackBody = createEmptyBody());
123 return emptyBody;
124 }
125 return body;
126 }
127 get bodyUsed() {
128 return Boolean(this.request.bodyUsed);
129 }
130 arrayBuffer() {
131 return this.request.arrayBuffer();
132 }
133 text() {
134 return this.request.text();
135 }
136 async json() {
137 const contentType = getContentType(this.headers);
138 let message = 'Invalid JSON body';
139 if (contentType === 'application/json') {
140 try {
141 const parsed = await this.request.json();
142 return parsed;
143 } catch (e) {
144 message = e instanceof Error ? e.message : String(e);
145 }
146 }
147 throw new HttpError(400, message);
148 }
149}
150function getContentType(headers) {
151 const contentType = headers.get('content-type');
152 if (contentType != null) {
153 return (contentType.split(';')[0] ?? '').toLowerCase();
154 }
155}
156function createEmptyBody() {
157 const request = new Request('http://localhost/', {
158 method: 'POST',
159 body: '',
160 });
161 return request.body;
162}
163
164class StaticFile {
165 filePath;
166 responseInit;
167 options;
168 constructor(filePath, init) {
169 this.filePath = filePath;
170 const { status, statusText, headers, maxAge, cachingHeaders } = init ?? {};
171 this.responseInit = {
172 status: status ?? 200,
173 statusText: statusText ?? '',
174 headers: headers ?? {},
175 };
176 this.options = { maxAge, cachingHeaders };
177 }
178}
179
180function defineErrors(input) {
181 return Object.fromEntries(
182 Object.entries(input).map(([name, message]) => [
183 name,
184 Object.defineProperties(
185 class extends Error {
186 constructor(params, options) {
187 let resolvedMessage = params
188 ? resolveMessage(message, params)
189 : message;
190 if (options?.cause) {
191 resolvedMessage += '\n' + indent(String(options.cause));
192 }
193 super(resolvedMessage, options);
194 }
195 get name() {
196 return name;
197 }
198 get [Symbol.toStringTag]() {
199 return name;
200 }
201 },
202 {
203 name: { value: name, configurable: true },
204 },
205 ),
206 ]),
207 );
208}
209function resolveMessage(message, params) {
210 return message.replace(/\{(.*?)\}/g, (_, key) => {
211 return params[key] == null ? '' : String(params[key]);
212 });
213}
214function indent(message) {
215 const lineBreak = /\r\n|\r|\n/;
216 return message
217 .split(lineBreak)
218 .map((line) => ' ' + line)
219 .join('\n');
220}
221
222const Errors = defineErrors({
223 StringifyError:
224 'Failed to stringify value returned from route handler: {route}',
225});
226function defineAdapter(createAdapter) {
227 const createApplication = (applicationOptions = {}) => {
228 const { getContext, errorHandler } = applicationOptions;
229 const app = getApp();
230 const adapter = createAdapter(applicationOptions);
231 const defineRoutes = (fn) => fn(app);
232 const createRequestHandler = (...routeLists) => {
233 const router = createRouter();
234 for (const routeList of routeLists) {
235 for (const [method, pattern, handler] of routeList) {
236 router.insert(method, pattern, handler);
237 }
238 }
239 const routeRequest = async (request) => {
240 const context = getContext?.(request);
241 const customRequest = new CustomRequest(request);
242 if (context) {
243 Object.assign(customRequest, context);
244 }
245 const { method, path } = customRequest;
246 const matches = router.getMatches(method, path);
247 for (const [handler, captures, route] of matches) {
248 Object.assign(customRequest, { params: captures });
249 const result = await handler(customRequest);
250 if (result !== undefined) {
251 let resolvedResponse;
252 if (result instanceof Response || result instanceof StaticFile) {
253 resolvedResponse = result;
254 } else {
255 try {
256 resolvedResponse = Response.json(result);
257 } catch (e) {
258 const [method, pattern] = route;
259 throw new Errors.StringifyError(
260 { route: `${method}:${pattern}` },
261 { cause: toError(e) },
262 );
263 }
264 }
265 return await adapter.toResponse(request, resolvedResponse);
266 }
267 }
268 return await adapter.toResponse(request, undefined);
269 };
270 return async (request) => {
271 try {
272 const response = await routeRequest(request);
273 if (response) {
274 return response;
275 }
276 } catch (e) {
277 if (e instanceof HttpError) {
278 const { status, message } = e;
279 return new Response(message, { status });
280 }
281 const error = toError(e);
282 if (errorHandler) {
283 try {
284 return await errorHandler(error);
285 } catch (e) {
286 return await adapter.onError(request, toError(e));
287 }
288 }
289 return await adapter.onError(request, error);
290 }
291 return new Response('Not found', { status: 404 });
292 };
293 };
294 const attachRoutes = (...routeLists) => {
295 const handleRequest = createRequestHandler(...routeLists);
296 return adapter.createNativeHandler(handleRequest);
297 };
298 return { defineRoutes, createRequestHandler, attachRoutes };
299 };
300 return createApplication;
301}
302function getApp() {
303 return {
304 get: (path, handler) => ['GET', path, handler],
305 post: (path, handler) => ['POST', path, handler],
306 put: (path, handler) => ['PUT', path, handler],
307 delete: (path, handler) => ['DELETE', path, handler],
308 route: (method, path, handler) => [method.toUpperCase(), path, handler],
309 };
310}
311function toError(e) {
312 return e instanceof Error ? e : new Error(String(e));
313}
314
315var Request$1 = Request;
316
317class CustomResponse extends Response {
318 static file(filePath, init) {
319 return new StaticFile(filePath, init);
320 }
321}
322
323function shouldSend304(headers, serverLastModified, serverEtag) {
324 const clientModifiedSince = headers.get('if-modified-since');
325 const clientEtag = headers.get('if-none-match');
326 let clientModifiedDate;
327 if (!clientModifiedSince && !clientEtag) {
328 return false;
329 }
330 if (clientModifiedSince) {
331 try {
332 clientModifiedDate = Date.parse(clientModifiedSince);
333 } catch (err) {
334 return false;
335 }
336 if (new Date(clientModifiedDate).toString() === 'Invalid Date') {
337 return false;
338 }
339 if (clientModifiedDate < serverLastModified.valueOf()) {
340 return false;
341 }
342 }
343 if (clientEtag) {
344 if (clientEtag !== serverEtag) {
345 return false;
346 }
347 }
348 return true;
349}
350function generateEtag(stats) {
351 const datePart = stats.mtimeMs.toString(16).padStart(11, '0');
352 const sizePart = stats.size.toString(16);
353 return `W/"${sizePart}${datePart}"`;
354}
355
356const mimeTypeList =
357 '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';
358const mimeToExtensions = new Map(
359 mimeTypeList.split('&').map((item) => {
360 const [mime = '', exts = ''] = item.split('=');
361 return [mime, exts.split(',')];
362 }),
363);
364const extToMime = new Map();
365for (let [mime, exts] of mimeToExtensions) {
366 for (let ext of exts) {
367 extToMime.set(ext, mime);
368 }
369}
370function getMimeTypeFromExt(ext) {
371 return extToMime.get(ext.toLowerCase());
372}
373
374const defaultOptions = {
375 cachingHeaders: true,
376};
377async function computeHeaders(
378 requestHeaders,
379 fullFilePath,
380 fileStats,
381 options = defaultOptions,
382) {
383 const { cachingHeaders = true, maxAge } = options;
384 if (!fileStats.isFile()) {
385 return null;
386 }
387 const lastModified = new Date(fileStats.mtimeMs);
388 const etag = generateEtag(fileStats);
389 if (cachingHeaders) {
390 const send304 = shouldSend304(requestHeaders, lastModified, etag);
391 if (send304) {
392 return { status: 304 };
393 }
394 }
395 const ext = extname(fullFilePath).slice(1);
396 const headers = {
397 'Content-Length': String(fileStats.size),
398 'Content-Type': getMimeTypeFromExt(ext) ?? 'application/octet-stream',
399 };
400 if (cachingHeaders) {
401 headers['ETag'] = etag;
402 headers['Last-Modified'] = lastModified.toGMTString();
403 }
404 if (maxAge !== undefined) {
405 headers['Cache-Control'] = `max-age=${maxAge}`;
406 }
407 return {
408 status: undefined,
409 headers,
410 };
411}
412
413function resolveFilePath(filePath, options) {
414 const { root = process.cwd(), allowStaticFrom = [] } = options;
415 const projectRoot = resolve(root);
416 const fullFilePath = join(projectRoot, filePath);
417 for (let allowedPath of allowStaticFrom) {
418 const fullAllowedPath = join(root, allowedPath);
419 if (fullFilePath.startsWith(fullAllowedPath + '/')) {
420 return [fullFilePath, allowedPath];
421 }
422 }
423 return null;
424}
425
426var fs = {
427 stat,
428};
429
430async function tryAsync(fn) {
431 try {
432 return await fn();
433 } catch (e) {
434 return null;
435 }
436}
437
438async function serveFile(requestHeaders, fullFilePath, options = {}) {
439 const fileStats = await tryAsync(() => fs.stat(fullFilePath));
440 if (!fileStats || !fileStats.isFile()) {
441 return null;
442 }
443 const result = await computeHeaders(
444 requestHeaders,
445 fullFilePath,
446 fileStats,
447 options,
448 );
449 if (result == null || result.status === 304) {
450 return result;
451 }
452 return {
453 headers: result.headers,
454 body: Bun.file(fullFilePath),
455 };
456}
457
458const createApplication = defineAdapter((applicationOptions) => {
459 const fromStaticFile = async (requestHeaders, staticFile) => {
460 const { filePath, options, responseInit: init } = staticFile;
461 const resolved = resolveFilePath(filePath, applicationOptions);
462 if (!resolved) {
463 return;
464 }
465 const [fullFilePath] = resolved;
466 const customServeFile = applicationOptions.serveFile;
467 if (customServeFile) {
468 const { status, statusText, headers } = new Response(null, init);
469 const maybeResponse = await customServeFile({
470 filePath,
471 fullFilePath,
472 status,
473 statusText,
474 headers,
475 options,
476 });
477 return maybeResponse ?? undefined;
478 }
479 const fileResponse = await serveFile(requestHeaders, fullFilePath, options);
480 if (!fileResponse) {
481 return;
482 }
483 const responseStatus = fileResponse.status ?? init.status ?? 200;
484 const responseHeaders = new Headers(init.headers);
485 for (const [key, value] of Object.entries(fileResponse.headers ?? {})) {
486 if (!responseHeaders.has(key)) {
487 responseHeaders.set(key, value);
488 }
489 }
490 return new Response(fileResponse.body ?? '', {
491 ...init,
492 status: responseStatus,
493 headers: responseHeaders,
494 });
495 };
496 return {
497 onError: (request, error) => {
498 return new Response(String(error), { status: 500 });
499 },
500 toResponse: async (request, result) => {
501 if (result instanceof StaticFile) {
502 return await fromStaticFile(request.headers, result);
503 }
504 return result;
505 },
506 createNativeHandler: (handleRequest) => handleRequest,
507 };
508});
509
510export {
511 HttpError,
512 Request$1 as Request,
513 CustomResponse as Response,
514 createApplication,
515};