build.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. const fs = require('node:fs');
  2. const { join, normalize, resolve, sep } = require('node:path');
  3. const { totalist } = require('totalist/sync');
  4. const { parse } = require('@polka/url');
  5. const { lookup } = require('mrmime');
  6. const noop = () => {};
  7. function isMatch(uri, arr) {
  8. for (let i=0; i < arr.length; i++) {
  9. if (arr[i].test(uri)) return true;
  10. }
  11. }
  12. function toAssume(uri, extns) {
  13. let i=0, x, len=uri.length - 1;
  14. if (uri.charCodeAt(len) === 47) {
  15. uri = uri.substring(0, len);
  16. }
  17. let arr=[], tmp=`${uri}/index`;
  18. for (; i < extns.length; i++) {
  19. x = extns[i] ? `.${extns[i]}` : '';
  20. if (uri) arr.push(uri + x);
  21. arr.push(tmp + x);
  22. }
  23. return arr;
  24. }
  25. function viaCache(cache, uri, extns) {
  26. let i=0, data, arr=toAssume(uri, extns);
  27. for (; i < arr.length; i++) {
  28. if (data = cache[arr[i]]) return data;
  29. }
  30. }
  31. function viaLocal(dir, isEtag, uri, extns) {
  32. let i=0, arr=toAssume(uri, extns);
  33. let abs, stats, name, headers;
  34. for (; i < arr.length; i++) {
  35. abs = normalize(
  36. join(dir, name=arr[i])
  37. );
  38. if (abs.startsWith(dir) && fs.existsSync(abs)) {
  39. stats = fs.statSync(abs);
  40. if (stats.isDirectory()) continue;
  41. headers = toHeaders(name, stats, isEtag);
  42. headers['Cache-Control'] = isEtag ? 'no-cache' : 'no-store';
  43. return { abs, stats, headers };
  44. }
  45. }
  46. }
  47. function is404(req, res) {
  48. return (res.statusCode=404,res.end());
  49. }
  50. function send(req, res, file, stats, headers) {
  51. let code=200, tmp, opts={};
  52. headers = { ...headers };
  53. for (let key in headers) {
  54. tmp = res.getHeader(key);
  55. if (tmp) headers[key] = tmp;
  56. }
  57. if (tmp = res.getHeader('content-type')) {
  58. headers['Content-Type'] = tmp;
  59. }
  60. if (req.headers.range) {
  61. code = 206;
  62. let [x, y] = req.headers.range.replace('bytes=', '').split('-');
  63. let end = opts.end = parseInt(y, 10) || stats.size - 1;
  64. let start = opts.start = parseInt(x, 10) || 0;
  65. if (end >= stats.size) {
  66. end = stats.size - 1;
  67. }
  68. if (start >= stats.size) {
  69. res.setHeader('Content-Range', `bytes */${stats.size}`);
  70. res.statusCode = 416;
  71. return res.end();
  72. }
  73. headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
  74. headers['Content-Length'] = (end - start + 1);
  75. headers['Accept-Ranges'] = 'bytes';
  76. }
  77. res.writeHead(code, headers);
  78. fs.createReadStream(file, opts).pipe(res);
  79. }
  80. const ENCODING = {
  81. '.br': 'br',
  82. '.gz': 'gzip',
  83. };
  84. function toHeaders(name, stats, isEtag) {
  85. let enc = ENCODING[name.slice(-3)];
  86. let ctype = lookup(name.slice(0, enc && -3)) || '';
  87. if (ctype === 'text/html') ctype += ';charset=utf-8';
  88. let headers = {
  89. 'Content-Length': stats.size,
  90. 'Content-Type': ctype,
  91. 'Last-Modified': stats.mtime.toUTCString(),
  92. };
  93. if (enc) headers['Content-Encoding'] = enc;
  94. if (isEtag) headers['ETag'] = `W/"${stats.size}-${stats.mtime.getTime()}"`;
  95. return headers;
  96. }
  97. module.exports = function (dir, opts={}) {
  98. dir = resolve(dir || '.');
  99. let isNotFound = opts.onNoMatch || is404;
  100. let setHeaders = opts.setHeaders || noop;
  101. let extensions = opts.extensions || ['html', 'htm'];
  102. let gzips = opts.gzip && extensions.map(x => `${x}.gz`).concat('gz');
  103. let brots = opts.brotli && extensions.map(x => `${x}.br`).concat('br');
  104. const FILES = {};
  105. let fallback = '/';
  106. let isEtag = !!opts.etag;
  107. let isSPA = !!opts.single;
  108. if (typeof opts.single === 'string') {
  109. let idx = opts.single.lastIndexOf('.');
  110. fallback += !!~idx ? opts.single.substring(0, idx) : opts.single;
  111. }
  112. let ignores = [];
  113. if (opts.ignores !== false) {
  114. ignores.push(/[/]([A-Za-z\s\d~$._-]+\.\w+){1,}$/); // any extn
  115. if (opts.dotfiles) ignores.push(/\/\.\w/);
  116. else ignores.push(/\/\.well-known/);
  117. [].concat(opts.ignores || []).forEach(x => {
  118. ignores.push(new RegExp(x, 'i'));
  119. });
  120. }
  121. let cc = opts.maxAge != null && `public,max-age=${opts.maxAge}`;
  122. if (cc && opts.immutable) cc += ',immutable';
  123. else if (cc && opts.maxAge === 0) cc += ',must-revalidate';
  124. if (!opts.dev) {
  125. totalist(dir, (name, abs, stats) => {
  126. if (/\.well-known[\\+\/]/.test(name)) {} // keep
  127. else if (!opts.dotfiles && /(^\.|[\\+|\/+]\.)/.test(name)) return;
  128. let headers = toHeaders(name, stats, isEtag);
  129. if (cc) headers['Cache-Control'] = cc;
  130. FILES['/' + name.normalize().replace(/\\+/g, '/')] = { abs, stats, headers };
  131. });
  132. }
  133. let lookup = opts.dev ? viaLocal.bind(0, dir + sep, isEtag) : viaCache.bind(0, FILES);
  134. return function (req, res, next) {
  135. let extns = [''];
  136. let pathname = parse(req).pathname;
  137. let val = req.headers['accept-encoding'] || '';
  138. if (gzips && val.includes('gzip')) extns.unshift(...gzips);
  139. if (brots && /(br|brotli)/i.test(val)) extns.unshift(...brots);
  140. extns.push(...extensions); // [...br, ...gz, orig, ...exts]
  141. if (pathname.indexOf('%') !== -1) {
  142. try { pathname = decodeURI(pathname) }
  143. catch (err) { /* malform uri */ }
  144. }
  145. let data = lookup(pathname, extns) || isSPA && !isMatch(pathname, ignores) && lookup(fallback, extns);
  146. if (!data) return next ? next() : isNotFound(req, res);
  147. if (isEtag && req.headers['if-none-match'] === data.headers['ETag']) {
  148. res.writeHead(304);
  149. return res.end();
  150. }
  151. if (gzips || brots) {
  152. res.setHeader('Vary', 'Accept-Encoding');
  153. }
  154. setHeaders(res, pathname, data.stats);
  155. send(req, res, data.abs, data.stats, data.headers);
  156. };
  157. }