1 | import { extname, resolve, join } from 'path';
|
2 | import { stat } from 'fs/promises';
|
3 |
|
4 | function 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 | }
|
31 | function 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 |
|
68 | class 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 | }
|
82 | function 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 |
|
90 | const URL_BASE = 'http://0.0.0.0';
|
91 | function parseUrl(url) {
|
92 | return new URL(url, URL_BASE);
|
93 | }
|
94 |
|
95 | const canHaveNullBody = new Set(['GET', 'DELETE', 'HEAD', 'OPTIONS']);
|
96 | class 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 | }
|
150 | function getContentType(headers) {
|
151 | const contentType = headers.get('content-type');
|
152 | if (contentType != null) {
|
153 | return (contentType.split(';')[0] ?? '').toLowerCase();
|
154 | }
|
155 | }
|
156 | function createEmptyBody() {
|
157 | const request = new Request('http://localhost/', {
|
158 | method: 'POST',
|
159 | body: '',
|
160 | });
|
161 | return request.body;
|
162 | }
|
163 |
|
164 | class 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 |
|
180 | function 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 | }
|
209 | function resolveMessage(message, params) {
|
210 | return message.replace(/\{(.*?)\}/g, (_, key) => {
|
211 | return params[key] == null ? '' : String(params[key]);
|
212 | });
|
213 | }
|
214 | function indent(message) {
|
215 | const lineBreak = /\r\n|\r|\n/;
|
216 | return message
|
217 | .split(lineBreak)
|
218 | .map((line) => ' ' + line)
|
219 | .join('\n');
|
220 | }
|
221 |
|
222 | const Errors = defineErrors({
|
223 | StringifyError:
|
224 | 'Failed to stringify value returned from route handler: {route}',
|
225 | });
|
226 | function 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 | }
|
302 | function 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 | }
|
311 | function toError(e) {
|
312 | return e instanceof Error ? e : new Error(String(e));
|
313 | }
|
314 |
|
315 | var Request$1 = Request;
|
316 |
|
317 | class CustomResponse extends Response {
|
318 | static file(filePath, init) {
|
319 | return new StaticFile(filePath, init);
|
320 | }
|
321 | }
|
322 |
|
323 | function 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 | }
|
350 | function 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 |
|
356 | const 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';
|
358 | const mimeToExtensions = new Map(
|
359 | mimeTypeList.split('&').map((item) => {
|
360 | const [mime = '', exts = ''] = item.split('=');
|
361 | return [mime, exts.split(',')];
|
362 | }),
|
363 | );
|
364 | const extToMime = new Map();
|
365 | for (let [mime, exts] of mimeToExtensions) {
|
366 | for (let ext of exts) {
|
367 | extToMime.set(ext, mime);
|
368 | }
|
369 | }
|
370 | function getMimeTypeFromExt(ext) {
|
371 | return extToMime.get(ext.toLowerCase());
|
372 | }
|
373 |
|
374 | const defaultOptions = {
|
375 | cachingHeaders: true,
|
376 | };
|
377 | async 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 |
|
413 | function 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 |
|
426 | var fs = {
|
427 | stat,
|
428 | };
|
429 |
|
430 | async function tryAsync(fn) {
|
431 | try {
|
432 | return await fn();
|
433 | } catch (e) {
|
434 | return null;
|
435 | }
|
436 | }
|
437 |
|
438 | async 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 |
|
458 | const 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 |
|
510 | export {
|
511 | HttpError,
|
512 | Request$1 as Request,
|
513 | CustomResponse as Response,
|
514 | createApplication,
|
515 | };
|