You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
185 lines
5.0 KiB
185 lines
5.0 KiB
/**
|
|
* koa-send@5.0.1 转换为ES Module版本
|
|
* 静态资源服务中间件
|
|
*/
|
|
import fs from 'fs';
|
|
import { promisify } from 'util';
|
|
import logger from 'log4js';
|
|
import resolvePath from './resolve-path.js';
|
|
import createError from 'http-errors';
|
|
import assert from 'assert';
|
|
import { normalize, basename, extname, resolve, parse, sep } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import path from "path"
|
|
|
|
// 转换为ES Module格式
|
|
const log = logger.getLogger('koa-send');
|
|
const stat = promisify(fs.stat);
|
|
const access = promisify(fs.access);
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
/**
|
|
* 检查文件是否存在
|
|
* @param {string} path - 文件路径
|
|
* @returns {Promise<boolean>} 文件是否存在
|
|
*/
|
|
async function exists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 发送文件给客户端
|
|
* @param {Context} ctx - Koa上下文对象
|
|
* @param {String} path - 文件路径
|
|
* @param {Object} [opts] - 配置选项
|
|
* @returns {Promise} - 异步Promise
|
|
*/
|
|
async function send(ctx, path, opts = {}) {
|
|
assert(ctx, 'koa context required');
|
|
assert(path, 'pathname required');
|
|
|
|
// 移除硬编码的public目录,要求必须通过opts.root配置
|
|
const root = opts.root;
|
|
if (!root) {
|
|
throw new Error('Static root directory must be configured via opts.root');
|
|
}
|
|
const trailingSlash = path[path.length - 1] === '/';
|
|
path = path.substr(parse(path).root.length);
|
|
const index = opts.index || 'index.html';
|
|
const maxage = opts.maxage || opts.maxAge || 0;
|
|
const immutable = opts.immutable || false;
|
|
const hidden = opts.hidden || false;
|
|
const format = opts.format !== false;
|
|
const extensions = Array.isArray(opts.extensions) ? opts.extensions : false;
|
|
const brotli = opts.brotli !== false;
|
|
const gzip = opts.gzip !== false;
|
|
const setHeaders = opts.setHeaders;
|
|
|
|
if (setHeaders && typeof setHeaders !== 'function') {
|
|
throw new TypeError('option setHeaders must be function');
|
|
}
|
|
|
|
// 解码路径
|
|
path = decode(path);
|
|
if (path === -1) return ctx.throw(400, 'failed to decode');
|
|
|
|
// 索引文件支持
|
|
if (index && trailingSlash) path += index;
|
|
|
|
path = resolvePath(root, path);
|
|
|
|
// 隐藏文件支持
|
|
if (!hidden && isHidden(root, path)) return;
|
|
|
|
let encodingExt = '';
|
|
// 尝试提供压缩文件
|
|
if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) {
|
|
path = path + '.br';
|
|
ctx.set('Content-Encoding', 'br');
|
|
ctx.res.removeHeader('Content-Length');
|
|
encodingExt = '.br';
|
|
} else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) {
|
|
path = path + '.gz';
|
|
ctx.set('Content-Encoding', 'gzip');
|
|
ctx.res.removeHeader('Content-Length');
|
|
encodingExt = '.gz';
|
|
}
|
|
|
|
// 尝试添加文件扩展名
|
|
if (extensions && !/\./.exec(basename(path))) {
|
|
const list = [].concat(extensions);
|
|
for (let i = 0; i < list.length; i++) {
|
|
let ext = list[i];
|
|
if (typeof ext !== 'string') {
|
|
throw new TypeError('option extensions must be array of strings or false');
|
|
}
|
|
if (!/^\./.exec(ext)) ext = `.${ext}`;
|
|
if (await exists(`${path}${ext}`)) {
|
|
path = `${path}${ext}`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 获取文件状态
|
|
let stats;
|
|
try {
|
|
stats = await stat(path);
|
|
|
|
// 处理目录
|
|
if (stats.isDirectory()) {
|
|
if (format && index) {
|
|
path += `/${index}`;
|
|
stats = await stat(path);
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'];
|
|
if (notfound.includes(err.code)) {
|
|
throw createError(404, err);
|
|
}
|
|
err.status = 500;
|
|
throw err;
|
|
}
|
|
|
|
if (setHeaders) setHeaders(ctx.res, path, stats);
|
|
|
|
// 设置响应头
|
|
ctx.set('Content-Length', stats.size);
|
|
if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString());
|
|
if (!ctx.response.get('Cache-Control')) {
|
|
const directives = [`max-age=${(maxage / 1000) | 0}`];
|
|
if (immutable) directives.push('immutable');
|
|
ctx.set('Cache-Control', directives.join(','));
|
|
}
|
|
if (!ctx.type) ctx.type = type(path, encodingExt);
|
|
ctx.body = fs.createReadStream(path);
|
|
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* 检查是否为隐藏文件
|
|
* @param {string} root - 根目录
|
|
* @param {string} path - 文件路径
|
|
* @returns {boolean} 是否为隐藏文件
|
|
*/
|
|
function isHidden(root, path) {
|
|
path = path.substr(root.length).split(sep);
|
|
for (let i = 0; i < path.length; i++) {
|
|
if (path[i][0] === '.') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 获取文件类型
|
|
* @param {string} file - 文件路径
|
|
* @param {string} ext - 编码扩展名
|
|
* @returns {string} 文件MIME类型
|
|
*/
|
|
function type(file, ext) {
|
|
return ext !== '' ? extname(basename(file, ext)) : extname(file);
|
|
}
|
|
|
|
/**
|
|
* 解码URL路径
|
|
* @param {string} path - 需要解码的路径
|
|
* @returns {string|number} 解码后的路径或错误代码
|
|
*/
|
|
function decode(path) {
|
|
try {
|
|
return decodeURIComponent(path);
|
|
} catch (err) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
export default send;
|
|
|