From b0524d95f2f9ac53fb4a8835931c38ff2b33bb80 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Sun, 12 Jan 2025 21:58:42 +0800 Subject: [PATCH] feat: support cjs and esm both by tshy (#1) BREAKING CHANGE: drop Node.js < 18.19.0 support part of https://github.com/eggjs/egg/issues/3644 https://github.com/eggjs/egg/issues/5257 ## Summary by CodeRabbit - **New Features** - Added static cache middleware for Koa. - Introduced TypeScript support for the package. - Implemented comprehensive configuration for package management. - **Infrastructure** - Updated GitHub Actions workflows for CI/CD. - Added ESLint configuration. - Updated project build and testing configurations. - **Documentation** - Refreshed README with new badges and installation instructions. - Updated package description and licensing. - **Maintenance** - Upgraded Node.js engine support to version 18.19.0+. - Migrated package to `@eggjs/koa-static-cache`. - Removed legacy Travis CI configuration. - Added new TypeScript configuration file. - Removed unnecessary files and configurations, streamlining the project structure. --- .eslintignore | 2 + .eslintrc | 6 + .github/workflows/nodejs.yml | 16 + .github/workflows/pkg.pr.new.yml | 23 ++ .github/workflows/release.yml | 13 + .gitignore | 15 +- .travis.yml | 8 - HISTORY.md => CHANGELOG.md | 0 LICENSE | 22 ++ Makefile | 33 -- README.md | 100 +++-- index.js | 218 +---------- package.json | 108 +++--- src/index.ts | 341 +++++++++++++++++ test/index.js | 604 ----------------------------- test/index.test.ts | 625 +++++++++++++++++++++++++++++++ tsconfig.json | 10 + 17 files changed, 1178 insertions(+), 966 deletions(-) create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .github/workflows/nodejs.yml create mode 100644 .github/workflows/pkg.pr.new.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .travis.yml rename HISTORY.md => CHANGELOG.md (100%) create mode 100644 LICENSE delete mode 100644 Makefile create mode 100644 src/index.ts delete mode 100644 test/index.js create mode 100644 test/index.test.ts create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a24e501 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +test/fixtures +coverage diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..9bcdb46 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] +} diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..fd73aac --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,16 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + Job: + name: Node.js + uses: node-modules/github-actions/.github/workflows/node-test.yml@master + with: + version: '18.19.0, 20, 22' + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml new file mode 100644 index 0000000..bac3fac --- /dev/null +++ b/.github/workflows/pkg.pr.new.yml @@ -0,0 +1,23 @@ +name: Publish Any Commit +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run prepublishOnly --if-present + + - run: npx pkg-pr-new publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a2bf04a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,13 @@ +name: Release + +on: + push: + branches: [ master ] + +jobs: + release: + name: Node.js + uses: eggjs/github-actions/.github/workflows/node-release.yml@master + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} diff --git a/.gitignore b/.gitignore index 9a39620..c010914 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ -.DS_Store* -ehthumbs.db -Thumbs.db -node_modules +logs/ npm-debug.log -coverage +node_modules/ +coverage/ +test/fixtures/**/run +.DS_Store +.tshy* +.eslintcache +dist +package-lock.json +.package-lock.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index eba0894..0000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: node_js -node_js: - - "8" - - "10" - - "12" - - "14" -script: "make test-travis" -after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls" diff --git a/HISTORY.md b/CHANGELOG.md similarity index 100% rename from HISTORY.md rename to CHANGELOG.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..402b5ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2025 - present eggjs and the contributors. +Copyright (c) 2013 Jonathan Ong me@jongleberry.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile deleted file mode 100644 index 9b2173a..0000000 --- a/Makefile +++ /dev/null @@ -1,33 +0,0 @@ -test: - @NODE_ENV=test ./node_modules/.bin/mocha \ - --require should \ - --require should-http \ - --harmony \ - --reporter spec \ - --bail - -test-cov: - @NODE_ENV=test node --harmony \ - node_modules/.bin/istanbul cover \ - ./node_modules/.bin/_mocha \ - -- -u exports \ - --require should \ - --require should-http \ - --reporter spec \ - --bail - -test-travis: - @NODE_ENV=test node --harmony \ - node_modules/.bin/istanbul cover \ - ./node_modules/.bin/_mocha \ - --report lcovonly \ - -- -u exports \ - --require should \ - --require should-http \ - --reporter spec \ - --bail - -clean: - @rm -rf node_modules - -.PHONY: test clean diff --git a/README.md b/README.md index ba9afb8..0bb9186 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,23 @@ # Koa Static Cache [![NPM version][npm-image]][npm-url] -[![build status][travis-image]][travis-url] -[![Test coverage][coveralls-image]][coveralls-url] -[![David deps][david-image]][david-url] - -[npm-image]: https://img.shields.io/npm/v/koa-static-cache.svg?style=flat-square -[npm-url]: https://npmjs.org/package/koa-static-cache -[travis-image]: https://img.shields.io/travis/koajs/static-cache.svg?style=flat-square -[travis-url]: https://travis-ci.org/koajs/static-cache -[coveralls-image]: https://img.shields.io/coveralls/koajs/static-cache.svg?style=flat-square -[coveralls-url]: https://coveralls.io/r/koajs/static-cache?branch=master -[david-image]: https://img.shields.io/david/koajs/static-cache.svg?style=flat-square -[david-url]: https://david-dm.org/koajs/static-cache - -Static server for koa. +[![Node.js CI](https://github.com/eggjs/koa-static-cache/actions/workflows/nodejs.yml/badge.svg)](https://github.com/eggjs/koa-static-cache/actions/workflows/nodejs.yml) +[![Test coverage][codecov-image]][codecov-url] +[![Known Vulnerabilities][snyk-image]][snyk-url] +[![npm download][download-image]][download-url] +[![Node.js Version](https://img.shields.io/node/v/@eggjs/koa-static-cache.svg?style=flat)](https://nodejs.org/en/download/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) + +[npm-image]: https://img.shields.io/npm/v/@eggjs/koa-static-cache.svg?style=flat-square +[npm-url]: https://npmjs.org/package/@eggjs/koa-static-cache +[codecov-image]: https://img.shields.io/codecov/c/github/eggjs/koa-static-cache.svg?style=flat-square +[codecov-url]: https://codecov.io/github/eggjs/koa-static-cache?branch=master +[snyk-image]: https://snyk.io/test/npm/@eggjs/koa-static-cache/badge.svg?style=flat-square +[snyk-url]: https://snyk.io/test/npm/@eggjs/koa-static-cache +[download-image]: https://img.shields.io/npm/dm/@eggjs/koa-static-cache.svg?style=flat-square +[download-url]: https://npmjs.org/package/@eggjs/koa-static-cache + +Static cache middleware for koa. Differences between this library and other libraries such as [static](https://github.com/koajs/static): @@ -22,28 +25,29 @@ Differences between this library and other libraries such as [static](https://gi - You may optionally store the data in memory - it streams by default. - Caches the assets on initialization - you need to restart the process to update the assets.(can turn off with options.preload = false) - Uses MD5 hash sum as an ETag. -- Uses .gz files if present on disk, like nginx gzip_static module +- Uses `.gz` files if present on disk, like nginx gzip_static module + +> Forked from https://github.com/koajs/static-cache, refactor with TypeScript to support CommonJS and ESM both. ## Installation -```js -$ npm install koa-static-cache +```bash +npm install @eggjs/koa-static-cache ``` ## API -### staticCache(dir [, options] [, files]) +### staticCache([options]) ```js -var path = require('path') -var staticCache = require('koa-static-cache') +const path = require('path'); +const { staticCache } = require('@eggjs/koa-static-cache'); app.use(staticCache(path.join(__dirname, 'public'), { maxAge: 365 * 24 * 60 * 60 -})) +})); ``` -- `dir` (str) - the directory you wish to serve, priority than `options.dir`. - `options.dir` (str) - the directory you wish to serve, default to `process.cwd`. - `options.maxAge` (int) - cache control max age for the files, `0` by default. - `options.cacheControl` (str) - optional cache control header. Overrides `options.maxAge`. @@ -56,14 +60,16 @@ app.use(staticCache(path.join(__dirname, 'public'), { - `options.filter` (function | array) - filter files at init dir, for example - skip non build (source) files. If array set - allow only listed files - `options.preload` (bool) - caches the assets on initialization or not, default to `true`. always work together with `options.dynamic`. - `options.files` (obj) - optional files object. See below. -- `files` (obj) - optional files object. See below. + ### Aliases -For example, if you have this alias object: +For example, if you have this `alias` object: ```js -{ - '/favicon.png': '/favicon-32.png' +const options = { + alias: { + '/favicon.png': '/favicon-32.png' + } } ``` @@ -87,13 +93,13 @@ app.use(staticCache('/public/css')) You can do this: ```js -var files = {} +const files = {}; // Mount the middleware -app.use(staticCache('/public/js', {}, files)) +app.use(staticCache('/public/js', {}, files)); // Add additional files -staticCache('/public/css', {}, files) +staticCache('/public/css', {}, files); ``` The benefit is that you'll have one less function added to the stack as well as doing one hash lookup instead of two. @@ -103,13 +109,13 @@ The benefit is that you'll have one less function added to the stack as well as For example, if you want to change the max age of `/package.json`, you can do the following: ```js -var files = {} +const files = {}; app.use(staticCache('/public', { maxAge: 60 * 60 * 24 * 365 -}, files)) +}, files)); -files['/package.json'].maxAge = 60 * 60 * 24 * 30 +files['/package.json'].maxAge = 60 * 60 * 24 * 30; ``` #### Using a LRU cache to avoid OOM when dynamic mode enabled @@ -117,36 +123,22 @@ files['/package.json'].maxAge = 60 * 60 * 24 * 30 You can pass in a lru cache instance which has tow methods: `get(key)` and `set(key, value)`. ```js -var LRU = require('lru-cache') -var files = new LRU({ max: 1000 }) +const LRU = require('lru-cache'); +const files = new LRU({ max: 1000 }); app.use(staticCache({ dir: '/public', dynamic: true, - files: files -})) + files, +})); ``` ## License -The MIT License (MIT) - -Copyright (c) 2013 Jonathan Ong me@jongleberry.com +[MIT](LICENSE) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +## Contributors -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +[![Contributors](https://contrib.rocks/image?repo=eggjs/koa-static-cache)](https://github.com/eggjs/koa-static-cache/graphs/contributors) -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +Made with [contributors-img](https://contrib.rocks). diff --git a/index.js b/index.js index 7b1f8d2..b2ec379 100644 --- a/index.js +++ b/index.js @@ -1,217 +1 @@ -var crypto = require('crypto') -var fs = require('mz/fs') -var zlib = require('mz/zlib') -var path = require('path') -var mime = require('mime-types') -var compressible = require('compressible') -var readDir = require('fs-readdir-recursive') -var debug = require('debug')('koa-static-cache') - -module.exports = function staticCache(dir, options, files) { - if (typeof dir === 'object') { - files = options - options = dir - dir = null - } - - options = options || {} - // prefix must be ASCII code - options.prefix = (options.prefix || '').replace(/\/*$/, '/') - files = new FileManager(files || options.files) - dir = dir || options.dir || process.cwd() - dir = path.normalize(dir) - var enableGzip = !!options.gzip - var filePrefix = path.normalize(options.prefix.replace(/^\//, '')) - - // option.filter - var fileFilter = function () { return true } - if (Array.isArray(options.filter)) fileFilter = function (file) { return ~options.filter.indexOf(file) } - if (typeof options.filter === 'function') fileFilter = options.filter - - if (options.preload !== false) { - readDir(dir).filter(fileFilter).forEach(function (name) { - loadFile(name, dir, options, files) - }) - } - - return async (ctx, next) => { - // only accept HEAD and GET - if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return await next() - // check prefix first to avoid calculate - if (ctx.path.indexOf(options.prefix) !== 0) return await next() - - // decode for `/%E4%B8%AD%E6%96%87` - // normalize for `//index` - var filename = path.normalize(safeDecodeURIComponent(ctx.path)) - - // check alias - if (options.alias && options.alias[filename]) filename = options.alias[filename]; - - var file = files.get(filename) - // try to load file - if (!file) { - if (!options.dynamic) return await next() - if (path.basename(filename)[0] === '.') return await next() - if (filename.charAt(0) === path.sep) filename = filename.slice(1) - - // trim prefix - if (options.prefix !== '/') { - if (filename.indexOf(filePrefix) !== 0) return await next() - filename = filename.slice(filePrefix.length) - } - - var fullpath = path.join(dir, filename) - // files that can be accessd should be under options.dir - if (fullpath.indexOf(dir) !== 0) { - return await next() - } - - var s - try { - s = await fs.stat(fullpath) - } catch (err) { - return await next() - } - if (!s.isFile()) return await next() - - file = loadFile(filename, dir, options, files) - } - - ctx.status = 200 - - if (enableGzip) ctx.vary('Accept-Encoding') - - if (!file.buffer) { - var stats = await fs.stat(file.path) - if (stats.mtime.getTime() !== file.mtime.getTime()) { - file.mtime = stats.mtime - file.md5 = null - file.length = stats.size - } - } - - ctx.response.lastModified = file.mtime - if (file.md5) ctx.response.etag = file.md5 - - if (ctx.fresh) return ctx.status = 304 - - ctx.type = file.type - ctx.length = file.zipBuffer ? file.zipBuffer.length : file.length - ctx.set('cache-control', file.cacheControl || 'public, max-age=' + file.maxAge) - if (file.md5) ctx.set('content-md5', file.md5) - - if (ctx.method === 'HEAD') return - - var acceptGzip = ctx.acceptsEncodings('gzip') === 'gzip' - - if (file.zipBuffer) { - if (acceptGzip) { - ctx.set('content-encoding', 'gzip') - ctx.body = file.zipBuffer - } else { - ctx.body = file.buffer - } - return - } - - var shouldGzip = enableGzip - && file.length > 1024 - && acceptGzip - && compressible(file.type) - - if (file.buffer) { - if (shouldGzip) { - - var gzFile = files.get(filename + '.gz') - if (options.usePrecompiledGzip && gzFile && gzFile.buffer) { // if .gz file already read from disk - file.zipBuffer = gzFile.buffer - } else { - file.zipBuffer = await zlib.gzip(file.buffer) - } - ctx.set('content-encoding', 'gzip') - ctx.body = file.zipBuffer - } else { - ctx.body = file.buffer - } - return - } - - var stream = fs.createReadStream(file.path) - - // update file hash - if (!file.md5) { - var hash = crypto.createHash('md5') - stream.on('data', hash.update.bind(hash)) - stream.on('end', function () { - file.md5 = hash.digest('base64') - }) - } - - ctx.body = stream - // enable gzip will remove content length - if (shouldGzip) { - ctx.remove('content-length') - ctx.set('content-encoding', 'gzip') - ctx.body = stream.pipe(zlib.createGzip()) - } - } -} - -function safeDecodeURIComponent(text) { - try { - return decodeURIComponent(text) - } catch (e) { - return text - } -} - -/** - * load file and add file content to cache - * - * @param {String} name - * @param {String} dir - * @param {Object} options - * @param {Object} files - * @return {Object} - * @api private - */ - -function loadFile(name, dir, options, files) { - var pathname = path.normalize(path.join(options.prefix, name)) - if (!files.get(pathname)) files.set(pathname, {}) - var obj = files.get(pathname) - var filename = obj.path = path.join(dir, name) - var stats = fs.statSync(filename) - var buffer = fs.readFileSync(filename) - - obj.cacheControl = options.cacheControl - obj.maxAge = obj.maxAge ? obj.maxAge : options.maxAge || 0 - obj.type = obj.mime = mime.lookup(pathname) || 'application/octet-stream' - obj.mtime = stats.mtime - obj.length = stats.size - obj.md5 = crypto.createHash('md5').update(buffer).digest('base64') - - debug('file: ' + JSON.stringify(obj, null, 2)) - if (options.buffer) - obj.buffer = buffer - - buffer = null - return obj -} - -function FileManager(store) { - if (store && typeof store.set === 'function' && typeof store.get === 'function') { - this.store = store - } else { - this.map = store || Object.create(null) - } -} - -FileManager.prototype.get = function (key) { - return this.store ? this.store.get(key) : this.map[key] -} - -FileManager.prototype.set = function (key, value) { - if (this.store) return this.store.set(key, value) - this.map[key] = value -} +test file content for the /test/index.test.ts diff --git a/package.json b/package.json index 22254f3..054e64b 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,7 @@ { - "name": "koa-static-cache", - "description": "Static cache for koa", + "name": "@eggjs/koa-static-cache", + "description": "Static cache middleware for koa", "version": "5.1.4", - "author": { - "name": "Jonathan Ong", - "email": "me@jongleberry.com", - "url": "http://jongleberry.com", - "twitter": "https://twitter.com/jongleberry" - }, - "contributors": [ - { - "name": "Jeremiah Senkpiel", - "email": "fishrock123@rocketmail.com", - "url": "https://searchbeam.jit.su", - "twitter": "https://twitter.com/fishrock123" - }, - { - "name": "dead_horse", - "email": "dead_horse@qq.com", - "url": "http://deadhorse.me", - "twitter": "https://twitter.com/deadhorse_busi" - } - ], - "files": [ - "index.js" - ], "keywords": [ "koa", "middleware", @@ -37,33 +14,74 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/koajs/static-cache.git" + "url": "https://github.com/eggjs/koa-static-cache.git" }, "bugs": { - "mail": "me@jongleberry.com", - "url": "https://github.com/koajs/static-cache/issues" + "url": "https://github.com/eggjs/koa-static-cache/issues" + }, + "engines": { + "node": ">= 18.19.0" }, "dependencies": { - "compressible": "^2.0.6", - "debug": "^3.1.0", - "fs-readdir-recursive": "^1.0.0", - "mime-types": "^2.1.8", - "mz": "^2.7.0" + "compressible": "^2.0.18", + "fs-readdir-recursive": "^1.1.0", + "mime-types": "^2.1.35", + "utility": "^2.4.0" }, "devDependencies": { - "bluebird": "3", - "istanbul": "~0.4.1", - "koa": "2", - "mocha": "2", - "should": "8", - "should-http": "0.0.4", - "supertest": "1", - "ylru": "1" + "@arethetypeswrong/cli": "^0.17.1", + "@eggjs/bin": "7", + "@eggjs/koa": "^2.20.6", + "@eggjs/supertest": "^8.1.1", + "@eggjs/tsconfig": "1", + "@types/compressible": "^2.0.2", + "@types/fs-readdir-recursive": "^1.1.3", + "@types/mime-types": "^2.1.4", + "@types/mocha": "10", + "@types/node": "22", + "eslint": "8", + "eslint-config-egg": "14", + "rimraf": "6", + "tshy": "3", + "tshy-after": "1", + "typescript": "5", + "ylru": "^2.0.0" }, "scripts": { - "test": "make test" + "lint": "eslint --cache src test --ext .ts", + "pretest": "npm run clean && npm run lint -- --fix", + "test": "egg-bin test", + "preci": "npm run clean && npm run lint", + "ci": "egg-bin cov", + "postci": "npm run prepublishOnly && npm run clean", + "clean": "rimraf dist", + "prepublishOnly": "tshy && tshy-after && attw --pack" }, - "engines": { - "node": ">= 7.6.0" - } + "type": "module", + "tshy": { + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "types": "./dist/commonjs/index.d.ts", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js" } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3f5d12e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,341 @@ +import crypto from 'node:crypto'; +import { debuglog, promisify } from 'node:util'; +import fs from 'node:fs/promises'; +import { createReadStream, statSync, readFileSync } from 'node:fs'; +import zlib from 'node:zlib'; +import path from 'node:path'; +import mime from 'mime-types'; +import compressible from 'compressible'; +import readDir from 'fs-readdir-recursive'; +import { exists, decodeURIComponent as safeDecodeURIComponent } from 'utility'; + +const debug = debuglog('@eggjs/koa-static-cache'); + +const gzip = promisify(zlib.gzip); + +export type FileFilter = (path: string) => boolean; + +export interface FileMeta { + maxAge?: number; + cacheControl?: string; + buffer?: Buffer; + zipBuffer?: Buffer; + type?: string; + mime?: string; + mtime?: Date; + path?: string; + md5?: string; + length?: number; +} + +export interface FileMap { + [path: string]: FileMeta; +} + +export interface FileStore { + get(key: string): unknown; + set(key: string, value: unknown): void; +} + +export interface Options { + /** + * The root directory from which to serve static assets + * Default to `process.cwd` + */ + dir?: string; + /** + * The max age for cache control + * Default to `0` + */ + maxAge?: number; + /** + * The cache control header for static files + * Default to `undefined` + * Overrides `options.maxAge` + */ + cacheControl?: string; + /** + * store the files in memory instead of streaming from the filesystem on each request + */ + buffer?: boolean; + /** + * when request's accept-encoding include gzip, files will compressed by gzip + * Default to `false` + */ + gzip?: boolean; + /** + * try use gzip files, loaded from disk, like nginx gzip_static + * Default to `false` + */ + usePrecompiledGzip?: boolean; + /** + * object map of aliases + * Default to `{}` + */ + alias?: Record; + /** + * the url prefix you wish to add + * Default to `''` + */ + prefix?: string; + /** + * filter files at init dir, for example - skip non build (source) files. + * If array set - allow only listed files + * Default to `undefined` + */ + filter?: FileFilter | string[]; + /** + * dynamic load file which not cached on initialization + * Default to `false + */ + dynamic?: boolean; + /** + * caches the assets on initialization or not, + * always work together with `options.dynamic` + * Default to `true` + */ + preload?: boolean; + /** + * file store for caching + * Default to `undefined` + */ + files?: FileMap | FileStore; +} + +type Next = () => Promise; + +export class FileManager { + store?: FileStore; + map?: FileMap; + + constructor(store?: FileStore | FileMap) { + if (store && typeof store.set === 'function' && typeof store.get === 'function') { + this.store = store as FileStore; + } else { + this.map = store || Object.create(null); + } + } + + get(key: string) { + return this.store ? this.store.get(key) : this.map![key]; + } + + set(key: string, value: FileMeta) { + if (this.store) { + return this.store.set(key, value); + } + this.map![key] = value; + } +} + +type MiddlewareFunc = (ctx: any, next: Next) => Promise | void; + +export function staticCache(): MiddlewareFunc; +export function staticCache(dir: string): MiddlewareFunc; +export function staticCache(options: Options): MiddlewareFunc; +export function staticCache(dir: string, options: Options): MiddlewareFunc; +export function staticCache(dir: string, options: Options, files: FileMap | FileStore): MiddlewareFunc; +export function staticCache( + dirOrOptions?: string | Options, + options: Options = {}, + filesStoreOrMap?: FileMap | FileStore, +): MiddlewareFunc { + let dir = ''; + if (typeof dirOrOptions === 'string') { + // dir priority than options.dir + dir = dirOrOptions; + } else if (dirOrOptions) { + options = dirOrOptions; + } + if (!dir && options.dir) { + dir = options.dir; + } + if (!dir) { + // default to process.cwd + dir = process.cwd(); + } + dir = path.normalize(dir); + debug('staticCache dir: %s', dir); + + // prefix must be ASCII code + options.prefix = (options.prefix ?? '').replace(/\/*$/, '/'); + const files = new FileManager(filesStoreOrMap ?? options.files); + const enableGzip = !!options.gzip; + const filePrefix = path.normalize(options.prefix.replace(/^\//, '')); + + // option.filter + let fileFilter: FileFilter = () => { return true; }; + if (Array.isArray(options.filter)) { + fileFilter = (file: string) => { + return (options.filter as string[]).includes(file); + }; + } + if (typeof options.filter === 'function') { + fileFilter = options.filter; + } + + if (options.preload !== false) { + readDir(dir).filter(fileFilter).forEach(name => { + loadFile(name, dir, options, files); + }); + } + + return async (ctx: any, next: Next) => { + // only accept HEAD and GET + if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return await next(); + // check prefix first to avoid calculate + if (!ctx.path.startsWith(options.prefix)) return await next(); + + // decode for `/%E4%B8%AD%E6%96%87` + // normalize for `//index` + let filename = path.normalize(safeDecodeURIComponent(ctx.path)); + + // check alias + if (options.alias && options.alias[filename]) { + filename = options.alias[filename]; + } + + let file = files.get(filename) as FileMeta; + // try to load file + if (!file) { + if (!options.dynamic) return await next(); + if (path.basename(filename)[0] === '.') return await next(); + if (filename.charAt(0) === path.sep) { + filename = filename.slice(1); + } + + // trim prefix + if (options.prefix !== '/') { + if (filename.indexOf(filePrefix) !== 0) { + return await next(); + } + filename = filename.slice(filePrefix.length); + } + + const fullpath = path.join(dir, filename); + // files that can be accessed should be under options.dir + if (!fullpath.startsWith(dir)) { + return await next(); + } + + const stats = await exists(fullpath); + if (!stats) return await next(); + if (!stats.isFile()) return await next(); + + file = loadFile(filename, dir, options, files); + } + + ctx.status = 200; + + if (enableGzip) ctx.vary('Accept-Encoding'); + + if (!file.buffer) { + const stats = await fs.stat(file.path!); + if (stats.mtime.getTime() !== file.mtime!.getTime()) { + file.mtime = stats.mtime; + file.md5 = undefined; + file.length = stats.size; + } + } + + ctx.response.lastModified = file.mtime; + if (file.md5) { + ctx.response.etag = file.md5; + } + + if (ctx.fresh) { + ctx.status = 304; + return; + } + + ctx.type = file.type; + ctx.length = file.zipBuffer ? file.zipBuffer.length : file.length!; + ctx.set('cache-control', file.cacheControl ?? 'public, max-age=' + file.maxAge); + if (file.md5) ctx.set('content-md5', file.md5); + + if (ctx.method === 'HEAD') { + return; + } + + const acceptGzip = ctx.acceptsEncodings('gzip') === 'gzip'; + + if (file.zipBuffer) { + if (acceptGzip) { + ctx.set('content-encoding', 'gzip'); + ctx.body = file.zipBuffer; + } else { + ctx.body = file.buffer; + } + return; + } + + const shouldGzip = enableGzip + && file.length! > 1024 + && acceptGzip + && compressible(file.type!); + + if (file.buffer) { + if (shouldGzip) { + + const gzFile = files.get(filename + '.gz') as FileMeta; + if (options.usePrecompiledGzip && gzFile && gzFile.buffer) { + // if .gz file already read from disk + file.zipBuffer = gzFile.buffer; + } else { + file.zipBuffer = await gzip(file.buffer); + } + ctx.set('content-encoding', 'gzip'); + ctx.body = file.zipBuffer; + } else { + ctx.body = file.buffer; + } + return; + } + + const stream = createReadStream(file.path!); + + // update file hash + if (!file.md5) { + const hash = crypto.createHash('md5'); + stream.on('data', hash.update.bind(hash)); + stream.on('end', () => { + file.md5 = hash.digest('base64'); + }); + } + + ctx.body = stream; + // enable gzip will remove content length + if (shouldGzip) { + ctx.remove('content-length'); + ctx.set('content-encoding', 'gzip'); + ctx.body = stream.pipe(zlib.createGzip()); + } + }; +} + +/** + * load file and add file content to cache + */ +function loadFile(name: string, dir: string, options: Options, fileManager: FileManager) { + const pathname = path.normalize(path.join(options.prefix!, name)); + if (!fileManager.get(pathname)) { + fileManager.set(pathname, {}); + } + const obj = fileManager.get(pathname) as FileMeta; + const filename = obj.path = path.join(dir, name); + const stats = statSync(filename); + const buffer = readFileSync(filename); + + obj.cacheControl = options.cacheControl; + obj.maxAge = obj.maxAge ? obj.maxAge : options.maxAge || 0; + obj.type = obj.mime = mime.lookup(pathname) || 'application/octet-stream'; + obj.mtime = stats.mtime; + obj.length = stats.size; + obj.md5 = crypto.createHash('md5').update(buffer).digest('base64'); + + debug('file: %s', JSON.stringify(obj, null, 2)); + if (options.buffer) { + obj.buffer = buffer; + } + return obj; +} diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 1edeb0f..0000000 --- a/test/index.js +++ /dev/null @@ -1,604 +0,0 @@ -var fs = require('fs') -var crypto = require('crypto') -var zlib = require('zlib') -var request = require('supertest') -var should = require('should') -var Koa = require('koa') -var http = require('http') -var path = require('path') -var staticCache = require('..') -var LRU = require('ylru') - -var app = new Koa() -var files = {} -app.use(staticCache(path.join(__dirname, '..'), { - alias: { - '/package': '/package.json' - }, - filter(file) { - return !file.includes('node_modules') - } -}, files)) - -var server = http.createServer(app.callback()) - -var app2 = new Koa() -app2.use(staticCache(path.join(__dirname, '..'), { - buffer: true, - filter(file) { - return !file.includes('node_modules') - } -})) -var server2 = http.createServer(app2.callback()) - -var app3 = new Koa() -app3.use(staticCache(path.join(__dirname, '..'), { - buffer: true, - gzip: true, - filter(file) { - return !file.includes('node_modules') - } -})) -var server3 = http.createServer(app3.callback()) - -var app4 = new Koa() -var files4 = {} -app4.use(staticCache(path.join(__dirname, '..'), { - gzip: true, - filter(file) { - return !file.includes('node_modules') - } -}, files4)) - -var server4 = http.createServer(app4.callback()) - -var app5 = new Koa() -app5.use(staticCache({ - buffer: true, - prefix: '/static', - dir: path.join(__dirname, '..'), - filter(file) { - return !file.includes('node_modules') - } -})) -var server5 = http.createServer(app5.callback()) - -describe('Static Cache', function () { - - it('should dir priority than options.dir', function (done) { - var app = new Koa() - app.use(staticCache(path.join(__dirname, '..'), { - dir: __dirname - })) - var server = app.listen() - request(server) - .get('/index.js') - .expect(200, done) - }) - - it('should default options.dir works fine', function (done) { - var app = new Koa() - app.use(staticCache({ - dir: path.join(__dirname, '..') - })) - var server = app.listen() - request(server) - .get('/index.js') - .expect(200, done) - }) - - it('should accept abnormal path', function (done) { - var app = new Koa() - app.use(staticCache({ - dir: path.join(__dirname, '..') - })) - var server = app.listen() - request(server) - .get('//index.js') - .expect(200, done) - }) - - it('should default process.cwd() works fine', function (done) { - var app = new Koa() - app.use(staticCache()) - var server = app.listen() - request(server) - .get('/index.js') - .expect(200, done) - }) - - var etag - it('should serve files', function (done) { - request(server) - .get('/index.js') - .expect(200) - .expect('Cache-Control', 'public, max-age=0') - .expect('Content-Type', /javascript/) - .end(function (err, res) { - if (err) - return done(err) - - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.have.header('ETag') - etag = res.headers.etag - - done() - }) - }) - - it('should serve files as buffers', function (done) { - request(server2) - .get('/index.js') - .expect(200) - .expect('Cache-Control', 'public, max-age=0') - .expect('Content-Type', /javascript/) - .end(function (err, res) { - if (err) - return done(err) - - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.have.header('ETag') - - etag = res.headers.etag - - done() - }) - }) - - it('should serve recursive files', function (done) { - request(server) - .get('/test/index.js') - .expect(200) - .expect('Cache-Control', 'public, max-age=0') - .expect('Content-Type', /javascript/) - .end(function (err, res) { - if (err) - return done(err) - - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.have.header('ETag') - - done() - }) - }) - - it('should not serve hidden files', function (done) { - request(server) - .get('/.gitignore') - .expect(404, done) - }) - - it('should support conditional HEAD requests', function (done) { - request(server) - .head('/index.js') - .set('If-None-Match', etag) - .expect(304, done) - }) - - it('should support conditional GET requests', function (done) { - request(server) - .get('/index.js') - .set('If-None-Match', etag) - .expect(304, done) - }) - - it('should support HEAD', function (done) { - request(server) - .head('/index.js') - .expect(200) - .expect('', done) - }) - - it('should support 404 Not Found for other Methods to allow downstream', - function (done) { - request(server) - .put('/index.js') - .expect(404, done) - }) - - it('should ignore query strings', function (done) { - request(server) - .get('/index.js?query=string') - .expect(200, done) - }) - - it('should alias paths', function (done) { - request(server) - .get('/package') - .expect('Content-Type', /json/) - .expect(200, done) - }) - - it('should be configurable via object', function (done) { - files['/package.json'].maxAge = 1 - - request(server) - .get('/package.json') - .expect('Cache-Control', 'public, max-age=1') - .expect(200, done) - }) - - it('should set the etag and content-md5 headers', function (done) { - var pk = fs.readFileSync('package.json') - var md5 = crypto.createHash('md5').update(pk).digest('base64') - - request(server) - .get('/package.json') - .expect('ETag', '"' + md5 + '"') - .expect('Content-MD5', md5) - .expect(200, done) - }) - - it('should set Last-Modified if file modified and not buffered', function (done) { - setTimeout(function () { - var readme = fs.readFileSync('README.md', 'utf8') - fs.writeFileSync('README.md', readme, 'utf8') - var mtime = fs.statSync('README.md').mtime - var md5 = files['/README.md'].md5 - request(server) - .get('/README.md') - .expect(200, function (err, res) { - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.not.have.header('ETag') - files['/README.md'].mtime.should.eql(mtime) - setTimeout(function () { - files['/README.md'].md5.should.equal(md5) - }, 10) - done() - }) - }, 1000) - }) - - it('should set Last-Modified if file rollback and not buffered', function (done) { - setTimeout(function () { - var readme = fs.readFileSync('README.md', 'utf8') - fs.writeFileSync('README.md', readme, 'utf8') - var mtime = fs.statSync('README.md').mtime - var md5 = files['/README.md'].md5 - request(server) - .get('/README.md') - .expect(200, function (err, res) { - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.not.have.header('ETag') - files['/README.md'].mtime.should.eql(mtime) - setTimeout(function () { - files['/README.md'].md5.should.equal(md5) - }, 10) - done() - }) - }, 1000) - }) - - it('should serve files with gzip buffer', function (done) { - var index = fs.readFileSync('index.js') - zlib.gzip(index, function (err, content) { - request(server3) - .get('/index.js') - .set('Accept-Encoding', 'gzip') - .expect(200) - .expect('Cache-Control', 'public, max-age=0') - .expect('Content-Encoding', 'gzip') - .expect('Content-Type', /javascript/) - .expect('Content-Length', content.length) - .expect('Vary', 'Accept-Encoding') - .expect(index.toString()) - .end(function (err, res) { - if (err) - return done(err) - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.have.header('ETag') - - etag = res.headers.etag - - done() - }) - }) - }) - - it('should not serve files with gzip buffer when accept encoding not include gzip', - function (done) { - var index = fs.readFileSync('index.js') - request(server3) - .get('/index.js') - .set('Accept-Encoding', '') - .expect(200) - .expect('Cache-Control', 'public, max-age=0') - .expect('Content-Type', /javascript/) - .expect('Content-Length', index.length) - .expect('Vary', 'Accept-Encoding') - .expect(index.toString()) - .end(function (err, res) { - if (err) - return done(err) - res.should.not.have.header('Content-Encoding') - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.have.header('ETag') - done() - }) - }) - - it('should serve files with gzip stream', function (done) { - var index = fs.readFileSync('index.js') - zlib.gzip(index, function (err, content) { - request(server4) - .get('/index.js') - .set('Accept-Encoding', 'gzip') - .expect(200) - .expect('Cache-Control', 'public, max-age=0') - .expect('Content-Encoding', 'gzip') - .expect('Content-Type', /javascript/) - .expect('Vary', 'Accept-Encoding') - .expect(index.toString()) - .end(function (err, res) { - if (err) - return done(err) - res.should.not.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.have.header('ETag') - - etag = res.headers.etag - - done() - }) - }) - }) - - it('should serve files with prefix', function (done) { - request(server5) - .get('/static/index.js') - .expect(200) - .expect('Cache-Control', 'public, max-age=0') - .expect('Content-Type', /javascript/) - .end(function (err, res) { - if (err) - return done(err) - - res.should.have.header('Content-Length') - res.should.have.header('Last-Modified') - res.should.have.header('ETag') - - etag = res.headers.etag - - done() - }) - }) - - it('should 404 when dynamic = false', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: false})) - var server = app.listen() - fs.writeFileSync('a.js', 'hello world') - - request(server) - .get('/a.js') - .expect(404, function(err) { - fs.unlinkSync('a.js') - done(err) - }) - }) - - it('should work fine when new file added in dynamic mode', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: true})) - var server = app.listen() - fs.writeFileSync('a.js', 'hello world') - - request(server) - .get('/a.js') - .expect(200, function(err) { - fs.unlinkSync('a.js') - done(err) - }) - }) - - it('should work fine when new file added in dynamic and prefix mode', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: true, prefix: '/static'})) - var server = app.listen() - fs.writeFileSync('a.js', 'hello world') - - request(server) - .get('/static/a.js') - .expect(200, function(err) { - fs.unlinkSync('a.js') - done(err) - }) - }) - - it('should work fine when new file added in dynamic mode with LRU', function (done) { - var app = new Koa() - var files = new LRU(1) - app.use(staticCache({dynamic: true, files: files})) - var server = app.listen() - fs.writeFileSync('a.js', 'hello world a') - fs.writeFileSync('b.js', 'hello world b') - fs.writeFileSync('c.js', 'hello world b') - - request(server) - .get('/a.js') - .expect(200, function(err) { - should.exist(files.get('/a.js')) - should.not.exist(err) - - request(server) - .get('/b.js') - .expect(200, function (err) { - should.not.exist(files.get('/a.js')) - should.exist(files.get('/b.js')) - should.not.exist(err) - - request(server) - .get('/c.js') - .expect(200, function (err) { - should.not.exist(files.get('/b.js')) - should.exist(files.get('/c.js')) - should.not.exist(err) - - request(server) - .get('/a.js') - .expect(200, function (err) { - should.not.exist(files.get('/c.js')) - should.exist(files.get('/a.js')) - should.not.exist(err) - fs.unlinkSync('a.js') - fs.unlinkSync('b.js') - fs.unlinkSync('c.js') - done() - }) - }) - }) - }) - }) - - it('should 404 when url without prefix in dynamic and prefix mode', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: true, prefix: '/static'})) - var server = app.listen() - fs.writeFileSync('a.js', 'hello world') - - request(server) - .get('/a.js') - .expect(404, function(err) { - fs.unlinkSync('a.js') - done(err) - }) - }) - - it('should 404 when new hidden file added in dynamic mode', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: true})) - var server = app.listen() - fs.writeFileSync('.a.js', 'hello world') - - request(server) - .get('/.a.js') - .expect(404, function(err) { - fs.unlinkSync('.a.js') - done(err) - }) - }) - - it('should 404 when file not exist in dynamic mode', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: true})) - var server = app.listen() - request(server) - .get('/a.js') - .expect(404, done) - }) - - it('should 404 when file not exist', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: true})) - var server = app.listen() - request(server) - .get('/a.js') - .expect(404, done) - }) - - it('should 404 when is folder in dynamic mode', function (done) { - var app = new Koa() - app.use(staticCache({dynamic: true})) - var server = app.listen() - request(server) - .get('/test') - .expect(404, done) - }) - - it('should array options.filter works fine', function (done) { - var app = new Koa() - app.use(staticCache({ - dir: path.join(__dirname, '..'), - filter: ['index.js'] - })) - var server = app.listen() - request(server) - .get('/Makefile') - .expect(404, done) - }) - - it('should function options.filter works fine', function (done) { - var app = new Koa() - app.use(staticCache({ - dir: path.join(__dirname, '..'), - filter: function (file) { return file.indexOf('index.js') === 0 } - })) - var server = app.listen() - request(server) - .get('/Makefile') - .expect(404, done) - }) - - it('should options.dynamic and options.preload works fine', function (done) { - var app = new Koa() - var files = {} - app.use(staticCache({ - dir: path.join(__dirname, '..'), - preload: false, - dynamic: true, - files: files - })) - files.should.eql({}) - request(app.listen()) - .get('/Makefile') - .expect(200, function (err, res) { - should.not.exist(err) - files.should.have.keys('/Makefile') - done() - }) - }) - - it('should options.alias and options.preload works fine', function (done) { - var app = new Koa() - var files = {} - app.use(staticCache({ - dir: path.join(__dirname, '..'), - preload: false, - dynamic: true, - alias: { - '/package': '/package.json' - }, - files: files - })) - files.should.eql({}) - request(app.listen()) - .get('/package') - .expect(200, function (err, res) { - if (err) return done(err) - should.not.exist(err) - files.should.have.keys('/package.json') - files.should.not.have.keys('/package') - - request(app.listen()) - .get('/package.json') - .expect(200, function (err, res) { - if (err) return done(err) - should.not.exist(err) - files.should.have.keys('/package.json') - should.ok(Object.keys(files).length === 1) - done() - }) - }) - }) - - - it('should loadFile under options.dir', function (done) { - var app = new Koa() - app.use(staticCache({ - dir: __dirname, - preload: false, - dynamic: true, - })) - request(app.listen()) - .get('/%2E%2E/package.json') - .expect(404) - .end(done) - }) -}) diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..52e34f0 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,625 @@ +import { strict as assert } from 'node:assert'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import zlib from 'node:zlib'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { request } from '@eggjs/supertest'; +import { LRU } from 'ylru'; +import { Application as Koa } from '@eggjs/koa'; +import { staticCache } from '../src/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = new Koa(); +const files: Record = {}; +app.use(staticCache(path.join(__dirname, '..'), { + alias: { + '/package': '/package.json', + // windows + '\\package': '\\package.json', + }, + filter(file: string) { + return !file.includes('node_modules'); + }, +}, files)); + +const server = http.createServer(app.callback()); + +const app2 = new Koa(); +app2.use(staticCache({ + dir: path.join(__dirname, '..'), + buffer: true, + filter(file: string) { + return !file.includes('node_modules'); + }, +})); +const server2 = http.createServer(app2.callback()); + +const app3 = new Koa(); +app3.use(staticCache(path.join(__dirname, '..'), { + buffer: true, + gzip: true, + filter(file: string) { + return !file.includes('node_modules'); + }, +})); +const server3 = http.createServer(app3.callback()); + +const app4 = new Koa(); +const files4: Record = {}; +app4.use(staticCache(path.join(__dirname, '..'), { + gzip: true, + filter(file: string) { + return !file.includes('node_modules'); + }, + files: files4, +})); + +const server4 = http.createServer(app4.callback()); + +const app5 = new Koa(); +app5.use(staticCache({ + buffer: true, + prefix: '/static', + dir: path.join(__dirname, '..'), + filter(file: string) { + return !file.includes('node_modules'); + }, +})); +const server5 = http.createServer(app5.callback()); + +describe('Static Cache', () => { + it('should dir priority than options.dir', function(done) { + const app = new Koa(); + app.use(staticCache(path.join(__dirname, '..'), { + dir: __dirname, + })); + const server = app.listen(); + request(server) + .get('/index.js') + .expect(200, done); + }); + + it('should default options.dir works fine', function(done) { + const app = new Koa(); + app.use(staticCache({ + dir: path.join(__dirname, '..'), + })); + const server = app.listen(); + request(server) + .get('/index.js') + .expect(200, done); + }); + + it('should accept abnormal path', function(done) { + const app = new Koa(); + app.use(staticCache({ + dir: path.join(__dirname, '..'), + })); + const server = app.listen(); + request(server) + .get('//index.js') + .expect(200, done); + }); + + it('should default process.cwd() works fine', function(done) { + const app = new Koa(); + app.use(staticCache()); + const server = app.listen(); + request(server) + .get('/index.js') + .expect(200, done); + }); + + let etag: string; + it('should serve files', function(done) { + request(server) + .get('/index.js') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Type', /javascript/) + .end(function(err, res) { + if (err) return done(err); + + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(res.headers.etag); + etag = res.headers.etag; + + done(); + }); + }); + + it('should serve files as buffers', function(done) { + request(server2) + .get('/index.js') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Type', /javascript/) + .end(function(err, res) { + if (err) return done(err); + + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(res.headers.etag); + + etag = res.headers.etag; + + done(); + }); + }); + + it('should serve recursive files', function(done) { + request(server) + .get('/test/index.test.ts') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Type', /video\/mp2t/) + .end(function(err, res) { + if (err) return done(err); + + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(res.headers.etag); + + done(); + }); + }); + + it('should not serve hidden files', function(done) { + request(server) + .get('/.gitignore') + .expect(404, done); + }); + + it('should support conditional HEAD requests', function(done) { + request(server) + .head('/index.js') + .set('If-None-Match', etag) + .expect(304, done); + }); + + it('should support conditional GET requests', function(done) { + request(server) + .get('/index.js') + .set('If-None-Match', etag) + .expect(304, done); + }); + + it('should support HEAD', function(done) { + request(server) + .head('/index.js') + .expect(200, done); + }); + + it('should support 404 Not Found for other Methods to allow downstream', + function(done) { + request(server) + .put('/index.js') + .expect(404, done); + }); + + it('should ignore query strings', function(done) { + request(server) + .get('/index.js?query=string') + .expect(200, done); + }); + + it('should alias paths', function(done) { + request(server) + .get('/package') + .expect('Content-Type', /json/) + .expect(200, done); + }); + + it('should be configurable via object', function(done) { + if (process.platform === 'win32') { + files['\\package.json'].maxAge = 1; + } else { + files['/package.json'].maxAge = 1; + } + + request(server) + .get('/package.json') + .expect('Cache-Control', 'public, max-age=1') + .expect(200, done); + }); + + it('should set the etag and content-md5 headers', function(done) { + const pk = fs.readFileSync('package.json'); + const md5 = crypto.createHash('md5').update(pk).digest('base64'); + + request(server) + .get('/package.json') + .expect('ETag', `"${md5}"`) + .expect('Content-MD5', md5) + .expect(200, done); + }); + + it('should set Last-Modified if file modified and not buffered', function(done) { + setTimeout(function() { + const readme = fs.readFileSync('README.md', 'utf8'); + fs.writeFileSync('README.md', readme, 'utf8'); + const mtime = fs.statSync('README.md').mtime; + const filename = process.platform === 'win32' ? '\\README.md' : '/README.md'; + const md5 = files[filename].md5; + request(server) + .get('/README.md') + .expect(200, function(err, res) { + if (err) return done(err); + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(!res.headers.etag); + assert.deepEqual(files[filename].mtime, mtime); + setTimeout(function() { + assert.equal(files[filename].md5, md5); + }, 10); + done(); + }); + }, 1000); + }); + + it('should set Last-Modified if file rollback and not buffered', function(done) { + setTimeout(function() { + const readme = fs.readFileSync('README.md', 'utf8'); + fs.writeFileSync('README.md', readme, 'utf8'); + const mtime = fs.statSync('README.md').mtime; + const filename = process.platform === 'win32' ? '\\README.md' : '/README.md'; + const md5 = files[filename].md5; + request(server) + .get('/README.md') + .expect(200, function(err, res) { + if (err) return done(err); + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(!res.headers.etag); + assert.deepEqual(files[filename].mtime, mtime); + setTimeout(function() { + assert.equal(files[filename].md5, md5); + }, 10); + done(); + }); + }, 1000); + }); + + it('should serve files with gzip buffer', function(done) { + const index = fs.readFileSync(path.join(__dirname, '../CHANGELOG.md')); + zlib.gzip(index, function(err, content) { + if (err) return done(err); + request(server3) + .get('/CHANGELOG.md') + .set('Accept-Encoding', 'gzip') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Encoding', 'gzip') + .expect('Content-Type', 'text/markdown; charset=utf-8') + .expect('Content-Length', `${content.length}`) + .expect('Vary', 'Accept-Encoding') + .expect(index.toString()) + .end(function(err, res) { + if (err) return done(err); + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(res.headers.etag); + + etag = res.headers.etag; + + done(); + }); + }); + }); + + it('should not serve files with gzip buffer when accept encoding not include gzip', + function(done) { + const index = fs.readFileSync('index.js'); + request(server3) + .get('/index.js') + .set('Accept-Encoding', '') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Type', /javascript/) + .expect('Content-Length', `${index.length}`) + .expect('Vary', 'Accept-Encoding') + .expect(index.toString()) + .end(function(err, res) { + if (err) return done(err); + assert(!res.headers['content-encoding']); + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(res.headers.etag); + done(); + }); + }); + + it('should serve files with gzip stream', function(done) { + const index = fs.readFileSync(path.join(__dirname, '../CHANGELOG.md')); + zlib.gzip(index, function(err, content) { + if (err) return done(err); + assert(content.length > 0); + request(server4) + .get('/CHANGELOG.md') + .set('Accept-Encoding', 'gzip') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Encoding', 'gzip') + .expect('Content-Type', /markdown/) + .expect('Vary', 'Accept-Encoding') + .expect(index.toString()) + .end(function(err, res) { + if (err) return done(err); + assert(!res.headers['content-length']); + assert(res.headers['last-modified']); + assert(res.headers.etag); + + etag = res.headers.etag; + + done(); + }); + }); + }); + + it('should serve files with prefix', function(done) { + request(server5) + .get('/static/index.js') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Type', /javascript/) + .end(function(err, res) { + if (err) return done(err); + + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert(res.headers.etag); + + etag = res.headers.etag; + + done(); + }); + }); + + it('should 404 when dynamic = false', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: false })); + const server = app.listen(); + fs.writeFileSync('a.js', 'hello world'); + + request(server) + .get('/a.js') + .expect(404, function(err) { + fs.unlinkSync('a.js'); + done(err); + }); + }); + + it('should work fine when new file added in dynamic mode', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: true })); + const server = app.listen(); + fs.writeFileSync('a.js', 'hello world'); + + request(server) + .get('/a.js') + .expect(200, function(err) { + fs.unlinkSync('a.js'); + done(err); + }); + }); + + it('should work fine when new file added in dynamic and prefix mode', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: true, prefix: '/static' })); + const server = app.listen(); + fs.writeFileSync('a.js', 'hello world'); + + request(server) + .get('/static/a.js') + .expect(200, function(err) { + fs.unlinkSync('a.js'); + done(err); + }); + }); + + it('should work fine when new file added in dynamic mode with LRU', function(done) { + const app = new Koa(); + const files = new LRU(1); + app.use(staticCache({ dynamic: true, files })); + const server = app.listen(); + fs.writeFileSync('a.js', 'hello world a'); + fs.writeFileSync('b.js', 'hello world b'); + fs.writeFileSync('c.js', 'hello world b'); + const filea = process.platform === 'win32' ? '\\a.js' : '/a.js'; + const fileb = process.platform === 'win32' ? '\\b.js' : '/b.js'; + const filec = process.platform === 'win32' ? '\\c.js' : '/c.js'; + + request(server) + .get('/a.js') + .expect(200, function(err) { + assert(files.get(filea)); + assert(!err); + + request(server) + .get('/b.js') + .expect(200, function(err) { + assert(!files.get(filea)); + assert(files.get(fileb)); + assert(!err); + + request(server) + .get('/c.js') + .expect(200, function(err) { + assert(!files.get(fileb)); + assert(files.get(filec)); + assert(!err); + + request(server) + .get('/a.js') + .expect(200, function(err) { + assert(!files.get(filec)); + assert(files.get(filea)); + assert(!err); + fs.unlinkSync('a.js'); + fs.unlinkSync('b.js'); + fs.unlinkSync('c.js'); + done(); + }); + }); + }); + }); + }); + + it('should 404 when url without prefix in dynamic and prefix mode', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: true, prefix: '/static' })); + const server = app.listen(); + fs.writeFileSync('a.js', 'hello world'); + + request(server) + .get('/a.js') + .expect(404, function(err) { + fs.unlinkSync('a.js'); + done(err); + }); + }); + + it('should 404 when new hidden file added in dynamic mode', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: true })); + const server = app.listen(); + fs.writeFileSync('.a.js', 'hello world'); + + request(server) + .get('/.a.js') + .expect(404, function(err) { + fs.unlinkSync('.a.js'); + done(err); + }); + }); + + it('should 404 when file not exist in dynamic mode', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: true })); + const server = app.listen(); + request(server) + .get('/a.js') + .expect(404, done); + }); + + it('should 404 when file not exist', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: true })); + const server = app.listen(); + request(server) + .get('/a.js') + .expect(404, done); + }); + + it('should 404 when is folder in dynamic mode', function(done) { + const app = new Koa(); + app.use(staticCache({ dynamic: true })); + const server = app.listen(); + request(server) + .get('/test') + .expect(404, done); + }); + + it('should array options.filter works fine', function(done) { + const app = new Koa(); + app.use(staticCache({ + dir: path.join(__dirname, '..'), + filter: [ 'index.js' ], + })); + const server = app.listen(); + request(server) + .get('/Makefile') + .expect(404, done); + }); + + it('should function options.filter works fine', function(done) { + const app = new Koa(); + app.use(staticCache({ + dir: path.join(__dirname, '..'), + filter(file: string) { return file.indexOf('index.js') === 0; }, + })); + const server = app.listen(); + request(server) + .get('/Makefile') + .expect(404, done); + }); + + it('should options.dynamic and options.preload works fine', function(done) { + const app = new Koa(); + const files: Record = {}; + app.use(staticCache({ + dir: path.join(__dirname, '..'), + preload: false, + dynamic: true, + files, + })); + assert.deepEqual(files, {}); + request(app.listen()) + .get('/package.json') + .expect(200, function(err, res) { + assert(!err); + const filename = process.platform === 'win32' ? '\\package.json' : '/package.json'; + assert(files[filename]); + assert(res.headers['content-length']); + assert(res.headers['last-modified']); + assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); + done(); + }); + }); + + it('should options.alias and options.preload works fine', function(done) { + const app = new Koa(); + const files: Record = {}; + app.use(staticCache({ + dir: path.join(__dirname, '..'), + preload: false, + dynamic: true, + alias: { + '/package': '/package.json', + '\\package': '\\package.json', + }, + files, + })); + assert.deepEqual(files, {}); + request(app.listen()) + .get('/package') + .expect(200, function(err, res) { + if (err) return done(err); + assert(!err); + const filename = process.platform === 'win32' ? '\\package.json' : '/package.json'; + assert(files[filename]); + assert(!files['/package']); + assert(!files['\\package']); + assert(res.headers['content-length']); + + request(app.listen()) + .get('/package.json') + .expect(200, function(err, res) { + if (err) return done(err); + assert(!err); + assert(files[filename]); + assert(Object.keys(files).length === 1); + assert(res.headers['content-length']); + done(); + }); + }); + }); + + it('should loadFile under options.dir', function(done) { + const app = new Koa(); + app.use(staticCache({ + dir: __dirname, + preload: false, + dynamic: true, + })); + request(app.listen()) + .get('/%2E%2E/package.json') + .expect(404) + .end(done); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff41b73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@eggjs/tsconfig", + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +}